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 3902f6487 perf(table): reuse adjacent partition values during fanout
(#2226)
3902f6487 is described below
commit 3902f648734288be160f6c879608cbb9fd5d300e
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 19:23:34 2026 +0200
perf(table): reuse adjacent partition values during fanout (#2226)
* perf(table): reuse adjacent partition source values
* test(table): cover adjacent partition source comparisons
* perf(table): reuse adjacent large binary and decimal partition values
Signed-off-by: Minh Vu <[email protected]>
* test(table): verify adjacent large binary and decimal reuse
Signed-off-by: Minh Vu <[email protected]>
* style(table): fix lint findings
* style(table): fix lint findings
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/partition_consecutive_runs_test.go | 481 +++++++++++++++++++++++++++++++
table/partitioned_fanout_writer.go | 60 ++++
2 files changed, 541 insertions(+)
diff --git a/table/partition_consecutive_runs_test.go
b/table/partition_consecutive_runs_test.go
new file mode 100644
index 000000000..8f9206d12
--- /dev/null
+++ b/table/partition_consecutive_runs_test.go
@@ -0,0 +1,481 @@
+// 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 (
+ "fmt"
+ "math"
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/decimal128"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/iceberg-go"
+)
+
+// This deliberately retains the old per-row extraction path as a benchmark
+// baseline and as an independent oracle for consecutive-row reuse.
+func partitionExtractionPerRowForTest(plan *partitionExtractionPlan, record
arrow.RecordBatch) ([]*partitionInfo, error) {
+ partitions := newPartitionMapNode()
+ values := make(partitionRecord, len(plan.fields))
+ for row := range record.NumRows() {
+ for i, field := range plan.fields {
+ col := record.Column(field.columnPath[0])
+ if col.IsNull(int(row)) {
+ values[i] = nil
+
+ continue
+ }
+
+ value, err := field.valueAt(col, int(row))
+ if err != nil {
+ return nil, err
+ }
+ values[i] = value
+ }
+ part := partitions.getOrCreate(values, plan.fields,
record.NumRows())
+ part.rows = append(part.rows, row)
+ }
+
+ return partitions.collectPartitions(), nil
+}
+
+func assertPartitionExtractionEquivalent(t *testing.T, plan
*partitionExtractionPlan, record arrow.RecordBatch) {
+ t.Helper()
+ want, err := partitionExtractionPerRowForTest(plan, record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := plan.getRecordPartitions(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sortByFirstRow := func(parts []*partitionInfo) {
+ sort.Slice(parts, func(i, j int) bool { return parts[i].rows[0]
< parts[j].rows[0] })
+ }
+ sortByFirstRow(want)
+ sortByFirstRow(got)
+ if len(got) != len(want) {
+ t.Fatalf("partition count: got %d, want %d", len(got),
len(want))
+ }
+ for i := range got {
+ if !reflect.DeepEqual(got[i].rows, want[i].rows) ||
+ !reflect.DeepEqual(got[i].partitionValues,
want[i].partitionValues) ||
+ !reflect.DeepEqual(got[i].partitionRec,
want[i].partitionRec) {
+ t.Fatalf("partition %d differs: got %+v, want %+v", i,
got[i], want[i])
+ }
+ }
+}
+
+func TestPartitionExtractionConsecutiveRows(t *testing.T) {
+ const microsPerDay = 86_400_000_000
+ tsType := &arrow.TimestampType{Unit: arrow.Microsecond}
+ schema := arrow.NewSchema([]arrow.Field{
+ {Name: "timestamp", Type: tsType, Nullable: true},
+ {Name: "hostname", Type: arrow.BinaryTypes.String, Nullable:
true},
+ {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
+ }, nil)
+ icebergSchema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "timestamp", Type:
iceberg.PrimitiveTypes.Timestamp},
+ iceberg.NestedField{ID: 2, Name: "hostname", Type:
iceberg.PrimitiveTypes.String},
+ iceberg.NestedField{ID: 3, Name: "id", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ spec := iceberg.NewPartitionSpec(
+ iceberg.PartitionField{SourceIDs: []int{1}, FieldID: 1000,
Name: "day", Transform: iceberg.DayTransform{}},
+ iceberg.PartitionField{SourceIDs: []int{2}, FieldID: 1001,
Name: "host_bucket", Transform: iceberg.BucketTransform{NumBuckets: 32}},
+ iceberg.PartitionField{SourceIDs: []int{3}, FieldID: 1002,
Name: "id", Transform: iceberg.IdentityTransform{}},
+ )
+ at := func(v int64) *int64 { return &v }
+ str := func(v string) *string { return &v }
+ rows := []struct {
+ ts *int64
+ host *string
+ id *int64
+ }{
+ {at(0), str("alpha"), at(1)},
+ {at(0), str("alpha"), at(1)},
+ {at(0), str("alpha"), at(1)},
+ {at(0), str("beta"), at(1)},
+ {at(microsPerDay), str("beta"), at(1)},
+ {at(microsPerDay), str("beta"), at(1)},
+ {nil, nil, nil},
+ {nil, nil, nil},
+ {nil, str("beta"), nil},
+ {at(0), str("alpha"), at(1)},
+ {at(0), str("alpha"), at(1)},
+ }
+
+ tb := array.NewTimestampBuilder(memory.DefaultAllocator, tsType)
+ hb := array.NewStringBuilder(memory.DefaultAllocator)
+ ib := array.NewInt64Builder(memory.DefaultAllocator)
+ defer tb.Release()
+ defer hb.Release()
+ defer ib.Release()
+ for _, item := range rows {
+ if item.ts == nil {
+ tb.AppendNull()
+ } else {
+ tb.Append(arrow.Timestamp(*item.ts))
+ }
+ if item.host == nil {
+ hb.AppendNull()
+ } else {
+ hb.Append(*item.host)
+ }
+ if item.id == nil {
+ ib.AppendNull()
+ } else {
+ ib.Append(*item.id)
+ }
+ }
+ columns := []arrow.Array{tb.NewArray(), hb.NewArray(), ib.NewArray()}
+ record := array.NewRecordBatch(schema, columns, int64(len(rows)))
+ for _, col := range columns {
+ col.Release()
+ }
+ defer record.Release()
+
+ plan, err := newPartitionExtractionPlan(spec, icebergSchema, schema)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertPartitionExtractionEquivalent(t, plan, record)
+}
+
+func TestSameAdjacentPartitionSourceSupportedTypes(t *testing.T) {
+ mem := memory.DefaultAllocator
+ tests := []struct {
+ name string
+ newArray func() arrow.Array
+ }{
+ {
+ name: "boolean",
+ newArray: func() arrow.Array {
+ b := array.NewBooleanBuilder(mem)
+ defer b.Release()
+ b.Append(true)
+ b.Append(true)
+ b.Append(false)
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "int32",
+ newArray: func() arrow.Array {
+ b := array.NewInt32Builder(mem)
+ defer b.Release()
+ b.Append(1)
+ b.Append(1)
+ b.Append(2)
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "int64",
+ newArray: func() arrow.Array {
+ b := array.NewInt64Builder(mem)
+ defer b.Release()
+ b.Append(1)
+ b.Append(1)
+ b.Append(2)
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "date32",
+ newArray: func() arrow.Array {
+ b := array.NewDate32Builder(mem)
+ defer b.Release()
+ b.Append(arrow.Date32(1))
+ b.Append(arrow.Date32(1))
+ b.Append(arrow.Date32(2))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "time64",
+ newArray: func() arrow.Array {
+ b := array.NewTime64Builder(mem,
&arrow.Time64Type{Unit: arrow.Microsecond})
+ defer b.Release()
+ b.Append(arrow.Time64(1))
+ b.Append(arrow.Time64(1))
+ b.Append(arrow.Time64(2))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "timestamp",
+ newArray: func() arrow.Array {
+ dtype := &arrow.TimestampType{Unit:
arrow.Microsecond}
+ b := array.NewTimestampBuilder(mem, dtype)
+ defer b.Release()
+ b.Append(arrow.Timestamp(1))
+ b.Append(arrow.Timestamp(1))
+ b.Append(arrow.Timestamp(2))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "string",
+ newArray: func() arrow.Array {
+ b := array.NewStringBuilder(mem)
+ defer b.Release()
+ b.Append("same")
+ b.Append("same")
+ b.Append("other")
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "large string",
+ newArray: func() arrow.Array {
+ b := array.NewLargeStringBuilder(mem)
+ defer b.Release()
+ b.Append("same")
+ b.Append("same")
+ b.Append("other")
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "binary",
+ newArray: func() arrow.Array {
+ b := array.NewBinaryBuilder(mem,
arrow.BinaryTypes.Binary)
+ defer b.Release()
+ b.Append([]byte("same"))
+ b.Append([]byte("same"))
+ b.Append([]byte("other"))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "fixed-size binary",
+ newArray: func() arrow.Array {
+ b := array.NewFixedSizeBinaryBuilder(mem,
&arrow.FixedSizeBinaryType{ByteWidth: 4})
+ defer b.Release()
+ b.Append([]byte("same"))
+ b.Append([]byte("same"))
+ b.Append([]byte("diff"))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "large binary",
+ newArray: func() arrow.Array {
+ b := array.NewBinaryBuilder(mem,
arrow.BinaryTypes.LargeBinary)
+ defer b.Release()
+ b.Append([]byte("same"))
+ b.Append([]byte("same"))
+ b.Append([]byte("other"))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "decimal128",
+ newArray: func() arrow.Array {
+ b := array.NewDecimal128Builder(mem,
&arrow.Decimal128Type{Precision: 10, Scale: 2})
+ defer b.Release()
+ b.Append(decimal128.FromI64(1234))
+ b.Append(decimal128.FromI64(1234))
+ b.Append(decimal128.FromI64(5678))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "float32 NaN payloads",
+ newArray: func() arrow.Array {
+ first := math.Float32frombits(0x7fc00001)
+ second := math.Float32frombits(0x7fc00002)
+ b := array.NewFloat32Builder(mem)
+ defer b.Release()
+ b.Append(first)
+ b.Append(first)
+ b.Append(second)
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "float32 signed zero",
+ newArray: func() arrow.Array {
+ b := array.NewFloat32Builder(mem)
+ defer b.Release()
+ b.Append(0)
+ b.Append(0)
+ b.Append(math.Float32frombits(1 << 31))
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "float64 NaN payloads",
+ newArray: func() arrow.Array {
+ first :=
math.Float64frombits(0x7ff8000000000001)
+ second :=
math.Float64frombits(0x7ff8000000000002)
+ b := array.NewFloat64Builder(mem)
+ defer b.Release()
+ b.Append(first)
+ b.Append(first)
+ b.Append(second)
+
+ return b.NewArray()
+ },
+ },
+ {
+ name: "float64 signed zero",
+ newArray: func() arrow.Array {
+ b := array.NewFloat64Builder(mem)
+ defer b.Release()
+ b.Append(0)
+ b.Append(0)
+ b.Append(math.Float64frombits(1 << 63))
+
+ return b.NewArray()
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ column := tc.newArray()
+ defer column.Release()
+ if !sameAdjacentPartitionSource(column, 1) {
+ t.Fatal("equal adjacent values were not
recognized")
+ }
+ if sameAdjacentPartitionSource(column, 2) {
+ t.Fatal("different adjacent values were treated
as equal")
+ }
+ })
+ }
+}
+
+func TestSameAdjacentPartitionSourceNullsAndUnsupportedTypes(t *testing.T) {
+ b := array.NewStringBuilder(memory.DefaultAllocator)
+ b.AppendNull()
+ b.AppendNull()
+ b.Append("value")
+ b.Append("value")
+ b.AppendNull()
+ column := b.NewArray()
+ b.Release()
+ defer column.Release()
+
+ for _, tc := range []struct {
+ row int
+ want bool
+ }{
+ {row: 1, want: true}, // null to null
+ {row: 2, want: false}, // null to value
+ {row: 3, want: true}, // value to same value
+ {row: 4, want: false}, // value to null
+ } {
+ if got := sameAdjacentPartitionSource(column, tc.row); got !=
tc.want {
+ t.Fatalf("row %d: got %t, want %t", tc.row, got,
tc.want)
+ }
+ }
+
+ unsupported := array.NewInt8Builder(memory.DefaultAllocator)
+ unsupported.Append(1)
+ unsupported.Append(1)
+ unsupportedColumn := unsupported.NewArray()
+ unsupported.Release()
+ defer unsupportedColumn.Release()
+ if sameAdjacentPartitionSource(unsupportedColumn, 1) {
+ t.Fatal("unsupported type should use the original conversion
path")
+ }
+}
+
+func TestSameAdjacentPartitionSourceSlicedColumn(t *testing.T) {
+ b := array.NewStringBuilder(memory.DefaultAllocator)
+ b.Append("before")
+ b.Append("same")
+ b.Append("same")
+ b.Append("after")
+ full := b.NewArray()
+ b.Release()
+ defer full.Release()
+
+ column := array.NewSlice(full, 1, 4)
+ defer column.Release()
+ if !sameAdjacentPartitionSource(column, 1) {
+ t.Fatal("equal adjacent values in a sliced column were not
recognized")
+ }
+ if sameAdjacentPartitionSource(column, 2) {
+ t.Fatal("different adjacent values in a sliced column were
treated as equal")
+ }
+}
+
+func BenchmarkPartitionExtractionConsecutiveRows(b *testing.B) {
+ const rows = 65_536
+ for _, runSize := range []int{1, 8, 256, 4096} {
+ b.Run(fmt.Sprintf("run_%d", runSize), func(b *testing.B) {
+ schema := arrow.NewSchema([]arrow.Field{{Name: "host",
Type: arrow.BinaryTypes.String}}, nil)
+ icebergSchema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "host", Type:
iceberg.PrimitiveTypes.String},
+ )
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name:
"bucket", Transform: iceberg.BucketTransform{NumBuckets: 32},
+ })
+ builder :=
array.NewStringBuilder(memory.DefaultAllocator)
+ for row := range rows {
+ builder.Append(fmt.Sprintf("host-%04d",
(row/runSize)%128))
+ }
+ col := builder.NewArray()
+ builder.Release()
+ record := array.NewRecordBatch(schema,
[]arrow.Array{col}, rows)
+ col.Release()
+ defer record.Release()
+
+ plan, err := newPartitionExtractionPlan(spec,
icebergSchema, schema)
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Run("per_row", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ if _, err :=
partitionExtractionPerRowForTest(plan, record); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ b.Run("reuse", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ if _, err :=
plan.getRecordPartitions(record); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ })
+ }
+}
diff --git a/table/partitioned_fanout_writer.go
b/table/partitioned_fanout_writer.go
index 8d5795a3a..825601520 100644
--- a/table/partitioned_fanout_writer.go
+++ b/table/partitioned_fanout_writer.go
@@ -18,6 +18,7 @@
package table
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -480,6 +481,51 @@ func leafColumnAt(chain []arrow.Array, row int)
(arrow.Array, bool) {
return leaf, true
}
+// sameAdjacentPartitionSource checks whether two rows have the same raw
+// partition source value. An equal source always gives the same transform
+// result, so callers can reuse the previous value without converting it.
+// Unsupported Arrow types take the original conversion path.
+func sameAdjacentPartitionSource(column arrow.Array, row int) bool {
+ currentNull := column.IsNull(row)
+ previousNull := column.IsNull(row - 1)
+ if currentNull || previousNull {
+ return currentNull && previousNull
+ }
+
+ switch values := column.(type) {
+ case *array.Boolean:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Int32:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Int64:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Date32:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Time64:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Timestamp:
+ return values.Value(row) == values.Value(row-1)
+ case *array.String:
+ return values.Value(row) == values.Value(row-1)
+ case *array.LargeString:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Binary:
+ return bytes.Equal(values.Value(row), values.Value(row-1))
+ case *array.LargeBinary:
+ return bytes.Equal(values.Value(row), values.Value(row-1))
+ case *array.FixedSizeBinary:
+ return bytes.Equal(values.Value(row), values.Value(row-1))
+ case *array.Decimal128:
+ return values.Value(row) == values.Value(row-1)
+ case *array.Float32:
+ return math.Float32bits(values.Value(row)) ==
math.Float32bits(values.Value(row-1))
+ case *array.Float64:
+ return math.Float64bits(values.Value(row)) ==
math.Float64bits(values.Value(row-1))
+ default:
+ return false
+ }
+}
+
func (p *partitionExtractionPlan) getRecordPartitions(record
arrow.RecordBatch) ([]*partitionInfo, error) {
// Preserve support for iterators whose batch schema changes. The usual
path compares
// schema pointers; equivalent independently-built schemas also reuse
the plan.
@@ -494,6 +540,7 @@ func (p *partitionExtractionPlan)
getRecordPartitions(record arrow.RecordBatch)
partitionMap := newPartitionMapNode()
partitionRec := make(partitionRecord, len(p.fields))
+ var previousPartition *partitionInfo
// we track these separately to avoid traversing the chain for top
level fields.
topLevelColumns := make([]arrow.Array, len(p.fields))
@@ -514,6 +561,7 @@ func (p *partitionExtractionPlan)
getRecordPartitions(record arrow.RecordBatch)
}
for row := range record.NumRows() {
+ unchanged := row > 0
for i, fieldInfo := range p.fields {
var col arrow.Array
var ok bool
@@ -521,6 +569,10 @@ func (p *partitionExtractionPlan)
getRecordPartitions(record arrow.RecordBatch)
switch {
case topLevelColumns[i] != nil:
col = topLevelColumns[i]
+ if row > 0 && sameAdjacentPartitionSource(col,
int(row)) {
+ // partitionRec[i] already contains
this source's transformed value.
+ continue
+ }
ok = !col.IsNull(int(row))
case columnChains[i] != nil:
col, ok = leafColumnAt(columnChains[i],
int(row))
@@ -530,6 +582,7 @@ func (p *partitionExtractionPlan)
getRecordPartitions(record arrow.RecordBatch)
continue
}
+ unchanged = false
if ok {
value, err := fieldInfo.valueAt(col, int(row))
if err != nil {
@@ -549,9 +602,16 @@ func (p *partitionExtractionPlan)
getRecordPartitions(record arrow.RecordBatch)
}
}
+ if unchanged && previousPartition != nil {
+ previousPartition.rows = append(previousPartition.rows,
row)
+
+ continue
+ }
+
// Get or create partition info for this partition key
partVal := partitionMap.getOrCreate(partitionRec, p.fields,
record.NumRows())
partVal.rows = append(partVal.rows, row)
+ previousPartition = partVal
}
return partitionMap.collectPartitions(), nil