This is an automated email from the ASF dual-hosted git repository.

laskoviymishka 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 d883b2c68 perf(table): avoid gathering already ordered compaction 
batches (#2373)
d883b2c68 is described below

commit d883b2c6873386babaa6ea2c5cbea18a30c2459c
Author: Minh Vu <[email protected]>
AuthorDate: Sat Oct 31 11:09:24 2026 +0100

    perf(table): avoid gathering already ordered compaction batches (#2373)
---
 table/sort_compaction.go                       |  52 +++
 table/sort_compaction_ordered_fastpath_test.go | 510 +++++++++++++++++++++++++
 2 files changed, 562 insertions(+)

diff --git a/table/sort_compaction.go b/table/sort_compaction.go
index e6ce185b8..ebebfaad2 100644
--- a/table/sort_compaction.go
+++ b/table/sort_compaction.go
@@ -561,6 +561,33 @@ func yieldSortedPartition(
        }
        slices.SortStableFunc(refs, rows.bind(batches))
 
+       if sortedRefsAreIdentity(refs, batches) {
+               // Keep the scan's natural order. Every output owns its 
reference to
+               // the original batch; no Arrow Take arrays are needed.
+               for _, batch := range batches {
+                       for off := int64(0); off < batch.NumRows(); off += 
batchRows {
+                               if err := ctx.Err(); err != nil {
+                                       yield(nil, err)
+
+                                       return false
+                               }
+                               end := min(off+batchRows, batch.NumRows())
+                               var out arrow.RecordBatch
+                               if off == 0 && end == batch.NumRows() {
+                                       batch.Retain()
+                                       out = batch
+                               } else {
+                                       out = batch.NewSlice(off, end)
+                               }
+                               if !yield(out, nil) {
+                                       return false
+                               }
+                       }
+               }
+
+               return true
+       }
+
        takeFn := partitionBatchByKey(ctx)
        mem := compute.GetAllocator(ctx)
        for off := 0; off < len(refs); off += int(batchRows) {
@@ -585,6 +612,31 @@ func yieldSortedPartition(
        return true
 }
 
+// sortedRefsAreIdentity reports whether the stable sort preserved input order.
+func sortedRefsAreIdentity(refs []rowRef, batches []arrow.RecordBatch) bool {
+       // Check the final row first to reject a common late inversion quickly.
+       if len(refs) > 0 {
+               lastBatch := len(batches) - 1
+               for batches[lastBatch].NumRows() == 0 {
+                       lastBatch--
+               }
+               if refs[len(refs)-1] != newRowRef(lastBatch, 
int(batches[lastBatch].NumRows())-1) {
+                       return false
+               }
+       }
+       index := 0
+       for batch, rec := range batches {
+               for row := range int(rec.NumRows()) {
+                       if refs[index] != newRowRef(batch, row) {
+                               return false
+                       }
+                       index++
+               }
+       }
+
+       return true
+}
+
 // gatherSortedRows builds one batch holding the rows refs of batches, in
 // that order. Each source batch is taken from directly so no column is
 // ever concatenated beyond one output batch.
diff --git a/table/sort_compaction_ordered_fastpath_test.go 
b/table/sort_compaction_ordered_fastpath_test.go
new file mode 100644
index 000000000..55c104758
--- /dev/null
+++ b/table/sort_compaction_ordered_fastpath_test.go
@@ -0,0 +1,510 @@
+// 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"
+       "errors"
+       "slices"
+       "strconv"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go"
+)
+
+type orderedPartitionTestRow struct {
+       key   int64
+       valid bool
+       label string
+}
+
+func orderedPartitionTestSetup(t testing.TB) (*arrow.Schema, rowComparator) {
+       t.Helper()
+       sc := arrow.NewSchema([]arrow.Field{
+               {Name: "key", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
+               {Name: "label", Type: arrow.BinaryTypes.String},
+       }, nil)
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "key", Type: 
iceberg.PrimitiveTypes.Int64},
+               iceberg.NestedField{ID: 2, Name: "label", Type: 
iceberg.PrimitiveTypes.String},
+       )
+       order, err := NewSortOrder(1, []SortField{{
+               SourceIDs: []int{1}, Transform: iceberg.IdentityTransform{},
+               Direction: SortASC, NullOrder: NullsFirst,
+       }})
+       if err != nil {
+               t.Fatal(err)
+       }
+       cmp, err := newRowComparator(order, schema, sc)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       return sc, cmp
+}
+
+func makeOrderedPartitionTestBatch(mem memory.Allocator, sc *arrow.Schema, 
rows []orderedPartitionTestRow) arrow.RecordBatch {
+       builder := array.NewRecordBuilder(mem, sc)
+       defer builder.Release()
+       keys := builder.Field(0).(*array.Int64Builder)
+       labels := builder.Field(1).(*array.StringBuilder)
+       for _, row := range rows {
+               if row.valid {
+                       keys.Append(row.key)
+               } else {
+                       keys.AppendNull()
+               }
+               labels.Append(row.label)
+       }
+
+       return builder.NewRecordBatch()
+}
+
+func TestYieldSortedPartitionFastPathPreservesTiesAndNulls(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       t.Cleanup(func() { mem.AssertSize(t, 0) })
+       sc, cmp := orderedPartitionTestSetup(t)
+       batches := []arrow.RecordBatch{
+               makeOrderedPartitionTestBatch(mem, sc, 
[]orderedPartitionTestRow{
+                       {label: "null"}, {key: 1, valid: true, label: "a"}, 
{key: 1, valid: true, label: "b"},
+               }),
+               makeOrderedPartitionTestBatch(mem, sc, 
[]orderedPartitionTestRow{
+                       {key: 1, valid: true, label: "c"},
+                       {key: 2, valid: true, label: "d"},
+                       {key: 3, valid: true, label: "e"},
+                       {key: 3, valid: true, label: "f"},
+                       {key: 4, valid: true, label: "g"},
+               }),
+       }
+       defer func() {
+               for _, batch := range batches {
+                       batch.Release()
+               }
+       }()
+       var got []string
+       var sizes []int64
+       calls := 0
+       ok := yieldSortedPartition(t.Context(), batches, cmp, 3, func(rec 
arrow.RecordBatch, err error) bool {
+               if err != nil {
+                       t.Fatalf("yield: %v", err)
+               }
+               defer rec.Release()
+               if calls == 0 && rec != batches[0] {
+                       t.Fatal("full input batch was copied")
+               }
+               calls++
+               sizes = append(sizes, rec.NumRows())
+               col := rec.Column(1).(*array.String)
+               for i := range col.Len() {
+                       got = append(got, col.Value(i))
+               }
+
+               return true
+       })
+       if !ok {
+               t.Fatal("iteration stopped early")
+       }
+       if want := []int64{3, 3, 2}; !slices.Equal(sizes, want) {
+               t.Fatalf("batch sizes %v; want %v", sizes, want)
+       }
+       if want := []string{"null", "a", "b", "c", "d", "e", "f", "g"}; 
!slices.Equal(got, want) {
+               t.Fatalf("row order %v; want %v", got, want)
+       }
+}
+
+func TestYieldSortedPartitionFastPathMatchesLegacyAcrossBatchBoundaries(t 
*testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       t.Cleanup(func() { mem.AssertSize(t, 0) })
+       sc, cmp := orderedPartitionTestSetup(t)
+
+       // Source batches intentionally do not align with the four-row output
+       // limit. Ties cross the batch boundary, and the first key is null.
+       input := [][]orderedPartitionTestRow{
+               {
+                       {label: "null"},
+                       {key: 1, valid: true, label: "a"},
+                       {key: 1, valid: true, label: "b"},
+               },
+               {
+                       {key: 1, valid: true, label: "c"},
+                       {key: 2, valid: true, label: "d"},
+                       {key: 2, valid: true, label: "e"},
+                       {key: 3, valid: true, label: "f"},
+                       {key: 4, valid: true, label: "g"},
+               },
+       }
+       batches := make([]arrow.RecordBatch, len(input))
+       var want []orderedPartitionTestRow
+       for i, rows := range input {
+               batches[i] = makeOrderedPartitionTestBatch(mem, sc, rows)
+               want = append(want, rows...)
+       }
+       defer func() {
+               for _, batch := range batches {
+                       batch.Release()
+               }
+       }()
+
+       var legacyRows []orderedPartitionTestRow
+       for _, method := range []struct {
+               name      string
+               fn        func(context.Context, []arrow.RecordBatch, 
rowComparator, int64, func(arrow.RecordBatch, error) bool) bool
+               wantSizes []int64
+       }{
+               {"legacy", legacyYieldSortedPartition, []int64{4, 4}},
+               {"fastpath", yieldSortedPartition, []int64{3, 4, 1}},
+       } {
+               t.Run(method.name, func(t *testing.T) {
+                       var got []orderedPartitionTestRow
+                       var sizes []int64
+                       ok := method.fn(t.Context(), batches, cmp, 4, func(rec 
arrow.RecordBatch, err error) bool {
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               defer rec.Release()
+                               sizes = append(sizes, rec.NumRows())
+                               keys := rec.Column(0).(*array.Int64)
+                               labels := rec.Column(1).(*array.String)
+                               for i := range int(rec.NumRows()) {
+                                       row := orderedPartitionTestRow{
+                                               valid: !keys.IsNull(i),
+                                               label: labels.Value(i),
+                                       }
+                                       if row.valid {
+                                               row.key = keys.Value(i)
+                                       }
+                                       got = append(got, row)
+                               }
+
+                               return true
+                       })
+                       if !ok {
+                               t.Fatal("iteration stopped early")
+                       }
+                       if !slices.Equal(got, want) {
+                               t.Fatalf("rows = %v, want %v", got, want)
+                       }
+                       if !slices.Equal(sizes, method.wantSizes) {
+                               t.Fatalf("batch sizes = %v, want %v", sizes, 
method.wantSizes)
+                       }
+                       if method.name == "legacy" {
+                               legacyRows = slices.Clone(got)
+                       } else if !slices.Equal(got, legacyRows) {
+                               t.Fatalf("fast-path rows = %v, legacy rows = 
%v", got, legacyRows)
+                       }
+               })
+       }
+}
+
+func TestYieldSortedPartitionFastPathFallsBack(t *testing.T) {
+       tests := []struct {
+               name  string
+               input [][]orderedPartitionTestRow
+               want  []string
+       }{
+               {name: "overlapping batches", input: 
[][]orderedPartitionTestRow{
+                       {{key: 1, valid: true, label: "a"}, {key: 4, valid: 
true, label: "b"}, {key: 4, valid: true, label: "c"}},
+                       {{key: 0, valid: true, label: "d"}, {key: 2, valid: 
true, label: "e"}, {key: 4, valid: true, label: "f"}},
+               }, want: []string{"d", "a", "e", "b", "c", "f"}},
+               {name: "unordered within batch", input: 
[][]orderedPartitionTestRow{
+                       {{key: 5, valid: true, label: "a"}, {key: 1, valid: 
true, label: "b"}, {key: 5, valid: true, label: "c"}},
+                       {{key: 2, valid: true, label: "d"}, {key: 3, valid: 
true, label: "e"}, {key: 4, valid: true, label: "f"}},
+               }, want: []string{"b", "d", "e", "f", "a", "c"}},
+       }
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       t.Cleanup(func() { mem.AssertSize(t, 0) })
+                       sc, cmp := orderedPartitionTestSetup(t)
+                       batches := make([]arrow.RecordBatch, len(tc.input))
+                       for i, rows := range tc.input {
+                               batches[i] = makeOrderedPartitionTestBatch(mem, 
sc, rows)
+                       }
+                       defer func() {
+                               for _, batch := range batches {
+                                       batch.Release()
+                               }
+                       }()
+                       var got []string
+                       ok := yieldSortedPartition(t.Context(), batches, cmp, 
2, func(rec arrow.RecordBatch, err error) bool {
+                               if err != nil {
+                                       t.Fatalf("yield: %v", err)
+                               }
+                               defer rec.Release()
+                               col := rec.Column(1).(*array.String)
+                               for i := range col.Len() {
+                                       got = append(got, col.Value(i))
+                               }
+
+                               return true
+                       })
+                       if !ok || !slices.Equal(got, tc.want) {
+                               t.Fatalf("got %v (ok=%v), want %v", got, ok, 
tc.want)
+                       }
+               })
+       }
+}
+
+func TestYieldSortedPartitionFastPathCancellation(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       t.Cleanup(func() { mem.AssertSize(t, 0) })
+       sc, cmp := orderedPartitionTestSetup(t)
+       batch := makeOrderedPartitionTestBatch(mem, sc, 
[]orderedPartitionTestRow{{key: 1, valid: true, label: "a"}})
+       defer batch.Release()
+       ctx, cancel := context.WithCancel(t.Context())
+       cancel()
+       calls := 0
+       ok := yieldSortedPartition(ctx, []arrow.RecordBatch{batch}, cmp, 1, 
func(rec arrow.RecordBatch, err error) bool {
+               calls++
+               if rec != nil {
+                       rec.Release()
+                       t.Fatal("expected cancellation, got record")
+               }
+               if err != context.Canceled {
+                       t.Fatalf("got %v, want context.Canceled", err)
+               }
+
+               return true
+       })
+       if ok || calls != 1 {
+               t.Fatalf("ok=%v calls=%d", ok, calls)
+       }
+}
+
+// legacyYieldSortedPartition retains the pre-fast-path implementation to
+// measure before and after on the same Go toolchain and Arrow version.
+func legacyYieldSortedPartition(
+       ctx context.Context, batches []arrow.RecordBatch, rows rowComparator, 
batchRows int64,
+       yield func(arrow.RecordBatch, error) bool,
+) bool {
+       if len(batches) == 0 {
+               return true
+       }
+       sc := batches[0].Schema()
+       var total int64
+       for _, rec := range batches {
+               if !rec.Schema().Equal(sc) {
+                       yield(nil, errors.New("different schemas"))
+
+                       return false
+               }
+               total += rec.NumRows()
+       }
+       refs := make([]rowRef, 0, total)
+       for batch, rec := range batches {
+               for row := range int(rec.NumRows()) {
+                       refs = append(refs, newRowRef(batch, row))
+               }
+       }
+       slices.SortStableFunc(refs, rows.bind(batches))
+       takeFn := partitionBatchByKey(ctx)
+       mem := compute.GetAllocator(ctx)
+       for off := 0; off < len(refs); off += int(batchRows) {
+               if err := ctx.Err(); err != nil {
+                       yield(nil, err)
+
+                       return false
+               }
+               out, err := gatherSortedRows(mem, takeFn, batches, 
refs[off:min(off+int(batchRows), len(refs))])
+               if err != nil {
+                       yield(nil, err)
+
+                       return false
+               }
+               if !yield(out, nil) {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func BenchmarkYieldSortedPartitionFastPath(b *testing.B) {
+       const (
+               batchCount   = 8
+               rowsPerBatch = 4096
+               columnCount  = 16
+               outputRows   = 8192
+       )
+       for _, mode := range []string{"globally_sorted", "nearly_sorted", 
"near_middle", "near_center", "center_swap", "tail_swap", "interleaved", 
"shuffled"} {
+               b.Run(mode, func(b *testing.B) {
+                       fields := make([]arrow.Field, columnCount)
+                       for i := range fields {
+                               fields[i] = arrow.Field{Name: "c" + 
strconv.Itoa(i), Type: arrow.PrimitiveTypes.Int64}
+                       }
+                       sc := arrow.NewSchema(fields, nil)
+                       schemaFields := make([]iceberg.NestedField, columnCount)
+                       for i := range fields {
+                               schemaFields[i] = iceberg.NestedField{ID: i + 
1, Name: fields[i].Name, Type: iceberg.PrimitiveTypes.Int64, Required: true}
+                       }
+                       order, err := NewSortOrder(1, []SortField{{
+                               SourceIDs: []int{1}, Transform: 
iceberg.IdentityTransform{},
+                               Direction: SortASC, NullOrder: NullsFirst,
+                       }})
+                       if err != nil {
+                               b.Fatal(err)
+                       }
+                       comparator, err := newRowComparator(order, 
iceberg.NewSchema(0, schemaFields...), sc)
+                       if err != nil {
+                               b.Fatal(err)
+                       }
+                       batches := make([]arrow.RecordBatch, batchCount)
+                       for batch := range batches {
+                               builder := 
array.NewRecordBuilder(memory.DefaultAllocator, sc)
+                               for row := range rowsPerBatch {
+                                       var key int64
+                                       switch mode {
+                                       case "globally_sorted", 
"nearly_sorted", "near_middle", "near_center", "center_swap", "tail_swap":
+                                               key = int64(batch*rowsPerBatch 
+ row)
+                                               if mode == "center_swap" && 
batch == batchCount/2-1 {
+                                                       switch row {
+                                                       case rowsPerBatch / 2:
+                                                               key++
+                                                       case rowsPerBatch/2 + 1:
+                                                               key--
+                                                       }
+                                               }
+                                               if mode == "tail_swap" && batch 
== batchCount-1 {
+                                                       switch row {
+                                                       case rowsPerBatch - 2:
+                                                               key++
+                                                       case rowsPerBatch - 1:
+                                                               key--
+                                                       }
+                                               }
+                                               if (mode == "nearly_sorted" && 
batch == batchCount-1 && row == rowsPerBatch-1) ||
+                                                       (mode == "near_middle" 
&& batch == batchCount/2 && row == 0) ||
+                                                       (mode == "near_center" 
&& batch == batchCount/2-1 && row == rowsPerBatch/2) {
+                                                       key = 0
+                                               }
+                                       case "interleaved":
+                                               key = int64(row*batchCount + 
batch)
+                                       case "shuffled":
+                                               key = 
int64((row*37%rowsPerBatch)*batchCount + batch)
+                                       }
+                                       for col := range columnCount {
+                                               
builder.Field(col).(*array.Int64Builder).Append(key + int64(col))
+                                       }
+                               }
+                               batches[batch] = builder.NewRecordBatch()
+                               builder.Release()
+                       }
+                       b.Cleanup(func() {
+                               for _, batch := range batches {
+                                       batch.Release()
+                               }
+                       })
+                       for _, method := range []struct {
+                               name string
+                               fn   func(context.Context, []arrow.RecordBatch, 
rowComparator, int64, func(arrow.RecordBatch, error) bool) bool
+                       }{
+                               {"legacy", legacyYieldSortedPartition},
+                               {"fastpath", yieldSortedPartition},
+                       } {
+                               b.Run(method.name, func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       b.ReportMetric(batchCount*rowsPerBatch, 
"rows/op")
+                                       b.ResetTimer()
+                                       for range b.N {
+                                               var count int64
+                                               ok := method.fn(b.Context(), 
batches, comparator, outputRows, func(rec arrow.RecordBatch, err error) bool {
+                                                       if err != nil {
+                                                               b.Fatal(err)
+                                                       }
+                                                       count += rec.NumRows()
+                                                       rec.Release()
+
+                                                       return true
+                                               })
+                                               if !ok || count != 
batchCount*rowsPerBatch {
+                                                       b.Fatalf("got %d rows 
(ok=%v)", count, ok)
+                                               }
+                                       }
+                               })
+                       }
+               })
+       }
+}
+
+func TestSortedRefsAreIdentityChecksEveryPair(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       t.Cleanup(func() { mem.AssertSize(t, 0) })
+       schema, comparator := orderedPartitionTestSetup(t)
+       for _, inversion := range []int{-1, 1, 2, 3, 4, 5, 6, 7} {
+               t.Run(strconv.Itoa(inversion), func(t *testing.T) {
+                       var batches []arrow.RecordBatch
+                       for batch := range 4 {
+                               var rows []orderedPartitionTestRow
+                               for row := range 2 {
+                                       i := batch*2 + row
+                                       key := int64(i)
+                                       if i == inversion {
+                                               key = -1
+                                       }
+                                       rows = append(rows, 
orderedPartitionTestRow{key: key, valid: true})
+                               }
+                               batches = append(batches, 
makeOrderedPartitionTestBatch(mem, schema, rows))
+                               if batch == 1 {
+                                       batches = append(batches, 
makeOrderedPartitionTestBatch(mem, schema, nil))
+                               }
+                       }
+                       defer func() {
+                               for _, batch := range batches {
+                                       batch.Release()
+                               }
+                       }()
+                       var refs []rowRef
+                       for batch, rec := range batches {
+                               for row := range int(rec.NumRows()) {
+                                       refs = append(refs, newRowRef(batch, 
row))
+                               }
+                       }
+                       slices.SortStableFunc(refs, comparator.bind(batches))
+                       got := sortedRefsAreIdentity(refs, batches)
+                       if got != (inversion < 0) {
+                               t.Fatalf("inversion at %d: got sorted=%t", 
inversion, got)
+                       }
+               })
+       }
+}
+
+func TestSortedRefsAreIdentityEmptyBatches(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       t.Cleanup(func() { mem.AssertSize(t, 0) })
+       schema, comparator := orderedPartitionTestSetup(t)
+       empty := makeOrderedPartitionTestBatch(mem, schema, nil)
+       defer empty.Release()
+       batch := makeOrderedPartitionTestBatch(mem, schema, 
[]orderedPartitionTestRow{{key: 1, valid: true}})
+       defer batch.Release()
+       for _, batches := range [][]arrow.RecordBatch{{empty}, {empty, batch, 
empty}} {
+               var refs []rowRef
+               for batch, rec := range batches {
+                       for row := range int(rec.NumRows()) {
+                               refs = append(refs, newRowRef(batch, row))
+                       }
+               }
+               slices.SortStableFunc(refs, comparator.bind(batches))
+               if !sortedRefsAreIdentity(refs, batches) {
+                       t.Fatal("expected unchanged row order")
+               }
+       }
+}

Reply via email to