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 94501ec07 perf(table): append remote Parquet splits without per-file 
buffers (#2253)
94501ec07 is described below

commit 94501ec074d50fbccc070b1b7d447008fe280fea
Author: Minh Vu <[email protected]>
AuthorDate: Wed Oct 21 22:51:45 2026 +0200

    perf(table): append remote Parquet splits without per-file buffers (#2253)
    
    * perf(table): append remote parquet splits directly
    
    Reuse the final scan task buffer across large Parquet files instead of
    allocating a split slice for each input file. Preserve split boundaries,
    pass-through tasks, and caller-owned task slices.
    
    Add multi-file regression coverage and batch planning benchmarks.
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): format remote split batch benchmark
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): satisfy nlreturn for remote Parquet splits
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/scan_splits.go            | 109 +++++++++++++++++++++++++---------------
 table/scan_splits_bench_test.go |  63 +++++++++++++++++++++++
 table/scan_splits_test.go       |  50 ++++++++++++++++++
 3 files changed, 181 insertions(+), 41 deletions(-)

diff --git a/table/scan_splits.go b/table/scan_splits.go
index 83de6abfe..0fe38a563 100644
--- a/table/scan_splits.go
+++ b/table/scan_splits.go
@@ -22,54 +22,48 @@ import (
        iceinternal "github.com/apache/iceberg-go/internal"
 )
 
-// splitParquetScanTask returns coalesced byte-range tasks when a complete,
-// large-file task can be split safely. The boolean is false when the original
-// task should be used unchanged. Split offsets are absolute byte positions in
-// the data file and each safe range ends at the next supplied offset, or at 
the
-// end of the file. The first range starts at byte zero so sparse split-offset
-// lists cannot leave leading row groups unassigned.
-//
-// Tasks that are already partial, small, non-Parquet, or missing usable split
-// offsets stay unchanged. Keeping the original task in those cases preserves
-// the existing behavior for remote tasks and older files.
-func splitParquetScanTask(task FileScanTask, targetSize int64) 
([]FileScanTask, bool) {
+// splittableParquetOffsets validates the source task before any output is
+// appended. In particular, malformed split offsets must not leave a partial
+// result behind when a different task in the batch has already been split.
+func splittableParquetOffsets(task FileScanTask, targetSize int64) ([]int64, 
int64, bool) {
        file := task.File
        if file == nil || file.FileFormat() != iceberg.ParquetFile || 
targetSize <= 0 {
-               return nil, false
+               return nil, 0, false
        }
 
        fileSize := file.FileSizeBytes()
        if fileSize <= targetSize || task.Start != 0 || task.Length != fileSize 
{
-               return nil, false
+               return nil, 0, false
        }
 
        offsets := iceinternal.BorrowedDataFileSplitOffsets(file)
        if len(offsets) < 2 {
-               return nil, false
+               return nil, 0, false
        }
-
        for i, offset := range offsets {
                if offset < 0 || offset >= fileSize || (i > 0 && offset <= 
offsets[i-1]) {
-                       return nil, false
+                       return nil, 0, false
                }
        }
 
-       // Each pair of adjacent output tasks covers more than targetSize bytes.
-       // Bound the initial capacity to avoid reserving one task per row group 
when
-       // most row groups will be coalesced. Compare before multiplying to 
avoid
-       // overflow for very large files.
-       capacity := len(offsets)
+       return offsets, fileSize, true
+}
+
+// parquetSplitCapacity bounds the reserved output size for coalesced groups.
+// Compare before multiplying to avoid overflow for very large files.
+func parquetSplitCapacity(offsetCount int, fileSize, targetSize int64) int {
+       capacity := offsetCount
        if groups := fileSize / targetSize; groups < int64(capacity/2) {
                capacity = int(2*groups + 1)
        }
-       result := make([]FileScanTask, 0, capacity)
-       appendTask := func(start, end int64) {
-               split := task
-               split.Start = start
-               split.Length = end - start
-               result = append(result, split)
-       }
 
+       return capacity
+}
+
+// appendParquetTaskSplits appends the ranges directly to the destination.
+// The offsets have already been checked as strictly increasing, non-negative,
+// and below the file size, so every emitted range has positive length.
+func appendParquetTaskSplits(result []FileScanTask, task FileScanTask, offsets 
[]int64, fileSize, targetSize int64) []FileScanTask {
        var taskStart, taskEnd int64
        for i := range offsets {
                chunkStart := int64(0)
@@ -81,36 +75,69 @@ func splitParquetScanTask(task FileScanTask, targetSize 
int64) ([]FileScanTask,
                if i+1 < len(offsets) {
                        chunkEnd = offsets[i+1]
                }
-               if chunkEnd <= chunkStart {
-                       return nil, false
-               }
 
                if taskEnd > taskStart && chunkEnd-taskStart > targetSize {
-                       appendTask(taskStart, taskEnd)
+                       split := task
+                       split.Start = taskStart
+                       split.Length = taskEnd - taskStart
+                       result = append(result, split)
                        taskStart = chunkStart
                }
                taskEnd = chunkEnd
        }
-       if taskEnd <= taskStart {
+
+       split := task
+       split.Start = taskStart
+       split.Length = taskEnd - taskStart
+
+       return append(result, split)
+}
+
+// splitParquetScanTask returns coalesced byte-range tasks when a complete,
+// large-file task can be split safely. The boolean is false when the original
+// task should be used unchanged. Split offsets are absolute byte positions in
+// the data file and each safe range ends at the next supplied offset, or at 
the
+// end of the file. The first range starts at byte zero so sparse split-offset
+// lists cannot leave leading row groups unassigned.
+//
+// Tasks that are already partial, small, non-Parquet, or missing usable split
+// offsets stay unchanged. Keeping the original task in those cases preserves
+// the existing behavior for remote tasks and older files.
+func splitParquetScanTask(task FileScanTask, targetSize int64) 
([]FileScanTask, bool) {
+       offsets, fileSize, ok := splittableParquetOffsets(task, targetSize)
+       if !ok {
                return nil, false
        }
-       appendTask(taskStart, taskEnd)
 
-       return result, true
+       result := make([]FileScanTask, 0, parquetSplitCapacity(len(offsets), 
fileSize, targetSize))
+
+       return appendParquetTaskSplits(result, task, offsets, fileSize, 
targetSize), true
 }
 
 // splitRemoteScanTasks allocates a new task slice only if a file needs 
splitting.
+// All split ranges are written directly into that slice instead of allocating
+// and copying one temporary slice for each large Parquet file.
 func splitRemoteScanTasks(tasks []FileScanTask, targetSize int64) 
[]FileScanTask {
        var result []FileScanTask
        for i, task := range tasks {
-               if splits, ok := splitParquetScanTask(task, targetSize); ok {
-                       if result == nil {
-                               result = append(make([]FileScanTask, 0, 
len(tasks)+len(splits)-1), tasks[:i]...)
+               offsets, fileSize, ok := splittableParquetOffsets(task, 
targetSize)
+               if !ok {
+                       if result != nil {
+                               result = append(result, task)
+                       }
+
+                       continue
+               }
+
+               if result == nil {
+                       capacity := len(tasks)
+                       extra := parquetSplitCapacity(len(offsets), fileSize, 
targetSize) - 1
+                       if extra <= int(^uint(0)>>1)-capacity {
+                               capacity += extra
                        }
-                       result = append(result, splits...)
-               } else if result != nil {
-                       result = append(result, task)
+                       result = append(make([]FileScanTask, 0, capacity), 
tasks[:i]...)
                }
+               result = appendParquetTaskSplits(result, task, offsets, 
fileSize, targetSize)
        }
        if result == nil {
                return tasks
diff --git a/table/scan_splits_bench_test.go b/table/scan_splits_bench_test.go
index a60c889a9..f708e197a 100644
--- a/table/scan_splits_bench_test.go
+++ b/table/scan_splits_bench_test.go
@@ -116,3 +116,66 @@ func BenchmarkSplitRemoteScanTasksSplitOffsets(b 
*testing.B) {
 // Embedding only the public DataFile interface intentionally hides
 // DataFileSplitOffsetsRef, forcing the defensive-copy fallback for comparison.
 type splitOffsetsPublicBenchmarkFile struct{ iceberg.DataFile }
+
+// BenchmarkSplitRemoteScanTasksBatch measures the split planner with many
+// Parquet files and shared row-group layouts. Input files are built before
+// timing, and the benchmark reports the complete output task slice.
+func BenchmarkSplitRemoteScanTasksBatch(b *testing.B) {
+       const rowGroupBytes int64 = 4096
+
+       for _, tc := range []struct {
+               files, groups, splittablePercent int
+       }{
+               {1024, 4, 100},
+               {1024, 32, 100},
+               {1024, 128, 100},
+               {8192, 4, 100},
+               {8192, 32, 100},
+               {8192, 32, 50},
+               {8192, 32, 0},
+       } {
+               b.Run(fmt.Sprintf("files=%d/groups=%d/splittable=%d", tc.files, 
tc.groups, tc.splittablePercent),
+                       func(b *testing.B) {
+                               offsets := make([]int64, tc.groups)
+                               for i := range offsets {
+                                       offsets[i] = int64(i) * rowGroupBytes
+                               }
+                               fileSize := int64(tc.groups) * rowGroupBytes
+                               targetSize := int64(8192)
+                               if tc.groups > 4 {
+                                       targetSize = 16384
+                               }
+
+                               tasks := make([]FileScanTask, tc.files)
+                               for i := range tasks {
+                                       builder, err := 
iceberg.NewDataFileBuilder(
+                                               *iceberg.UnpartitionedSpec,
+                                               iceberg.EntryContentData,
+                                               
fmt.Sprintf("mem://split-batch/data-%d.parquet", i),
+                                               iceberg.ParquetFile, nil, nil, 
nil, 1, fileSize,
+                                       )
+                                       if err != nil {
+                                               b.Fatal(err)
+                                       }
+                                       task := FileScanTask{File: 
builder.SplitOffsets(offsets).Build(), Length: fileSize}
+                                       if i%100 >= tc.splittablePercent {
+                                               task.Start = 1
+                                               task.Length--
+                                       }
+                                       tasks[i] = task
+                               }
+
+                               probe := splitRemoteScanTasks(tasks, targetSize)
+                               if len(probe) < len(tasks) {
+                                       b.Fatalf("split planner dropped tasks: 
got %d, want at least %d", len(probe), len(tasks))
+                               }
+
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for b.Loop() {
+                                       splitRemoteScanTasksBenchmarkSink = 
splitRemoteScanTasks(tasks, targetSize)
+                               }
+                               b.ReportMetric(float64(tc.files), "files/op")
+                       })
+       }
+}
diff --git a/table/scan_splits_test.go b/table/scan_splits_test.go
index b7d9937a9..f81c35322 100644
--- a/table/scan_splits_test.go
+++ b/table/scan_splits_test.go
@@ -525,3 +525,53 @@ func TestPrepareToReadClosesReaderOnSchemaError(t 
*testing.T) {
                })
        }
 }
+
+// TestSplitRemoteScanTasksManyFilesAndInvalidOffsets keeps unsafe tasks intact
+// even after a previous task has produced multiple split ranges.
+func TestSplitRemoteScanTasksManyFilesAndInvalidOffsets(t *testing.T) {
+       valid := splitTestDataFile(t, iceberg.ParquetFile, 100, []int64{8, 40, 
70})
+       invalid := splitTestDataFile(t, iceberg.ParquetFile, 100, []int64{8, 
40, 40})
+       firstRowID := int64(23)
+       tasks := []FileScanTask{
+               {File: invalid, Length: 100},
+               {File: valid, Length: 100, FirstRowID: &firstRowID},
+               {File: valid, Start: 40, Length: 60},
+               {File: invalid, Length: 100},
+               {File: valid, Length: 100, Residual: iceberg.AlwaysTrue{}},
+       }
+       before := append([]FileScanTask(nil), tasks...)
+
+       got := splitRemoteScanTasks(tasks, 50)
+
+       require.Len(t, got, 9)
+       assert.Equal(t, before, tasks, "planning must not modify caller-owned 
tasks")
+       assert.Equal(t, before[0], got[0])
+       assert.Equal(t, before[2], got[4])
+       assert.Equal(t, before[3], got[5])
+
+       for _, first := range []int{1, 6} {
+               for splitIndex, wantStart := range []int64{0, 40, 70} {
+                       split := got[first+splitIndex]
+                       assert.Equal(t, wantStart, split.Start)
+                       assert.Equal(t, []int64{40, 30, 30}[splitIndex], 
split.Length)
+                       assert.Equal(t, valid, split.File)
+               }
+       }
+       for _, split := range got[1:4] {
+               assert.Equal(t, &firstRowID, split.FirstRowID)
+       }
+       for _, split := range got[6:9] {
+               assert.Equal(t, iceberg.AlwaysTrue{}, split.Residual)
+       }
+}
+
+func TestSplitRemoteScanTasksAllInvalidOffsetsSharesInput(t *testing.T) {
+       invalid := splitTestDataFile(t, iceberg.ParquetFile, 100, []int64{8, 
40, 40})
+       tasks := []FileScanTask{
+               {File: invalid, Length: 100},
+               {File: invalid, Length: 100},
+       }
+       got := splitRemoteScanTasks(tasks, 50)
+       assert.Equal(t, tasks, got)
+       assert.Same(t, &tasks[0], &got[0], "unsplit batches must keep the input 
slice")
+}

Reply via email to