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 a879ce375 perf(table): avoid cloning parquet split offsets during
scans (#2118)
a879ce375 is described below
commit a879ce3755119aa64233a5fb1816c12045cbd78c
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 22:44:02 2026 +0200
perf(table): avoid cloning parquet split offsets during scans (#2118)
---
data_file_refs.go | 11 ++++
data_file_refs_test.go | 67 ++++++++++++++++++++++
internal/data_file_ref.go | 29 ++++++++++
internal/data_file_ref_test.go | 107 ++++++++++++++++++++++++++++++++++
table/arrow_scanner.go | 2 +-
table/arrow_scanner_bench_test.go | 65 ++++++++++++++++++---
table/data_file_stats_ref_test.go | 20 +++++++
table/internal/parquet_files.go | 8 ++-
table/scan_splits.go | 7 ++-
table/scan_splits_bench_test.go | 118 ++++++++++++++++++++++++++++++++++++++
10 files changed, 420 insertions(+), 14 deletions(-)
diff --git a/data_file_refs.go b/data_file_refs.go
index 723f400fa..96ec27f40 100644
--- a/data_file_refs.go
+++ b/data_file_refs.go
@@ -44,6 +44,17 @@ func (d *dataFile) DataFileStatsRef(_ internal.DataFileRef) (
return d.valCntMap, d.nullCntMap, d.nanCntMap, d.lowerBoundMap,
d.upperBoundMap
}
+// DataFileSplitOffsetsRef returns the data file's split offsets without
+// initializing unrelated column-statistics maps. The returned slice aliases
+// immutable DataFile state and must be treated as read-only.
+func (d *dataFile) DataFileSplitOffsetsRef(_ internal.DataFileRef) []int64 {
+ if d.Splits == nil {
+ return nil
+ }
+
+ return *d.Splits
+}
+
func (d *dataFile) DataFileCollectionsRef(_ internal.DataFileRef) (
map[int]int64, []byte, []int64, []int,
) {
diff --git a/data_file_refs_test.go b/data_file_refs_test.go
new file mode 100644
index 000000000..d2499a1e4
--- /dev/null
+++ b/data_file_refs_test.go
@@ -0,0 +1,67 @@
+// 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 iceberg
+
+import (
+ "testing"
+
+ "github.com/apache/iceberg-go/internal"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBorrowedSplitOffsetsDoNotMaterializeColumnStats(t *testing.T) {
+ builder, err := NewDataFileBuilder(
+ *UnpartitionedSpec,
+ EntryContentData,
+ "file.parquet",
+ ParquetFile,
+ nil,
+ nil,
+ nil,
+ 1,
+ 128,
+ )
+ require.NoError(t, err)
+
+ file := builder.
+ ColumnSizes(map[int]int64{1: 8}).
+ ValueCounts(map[int]int64{1: 1}).
+ NullValueCounts(map[int]int64{1: 0}).
+ NaNValueCounts(map[int]int64{1: 0}).
+ LowerBoundValues(map[int][]byte{1: {1}}).
+ UpperBoundValues(map[int][]byte{1: {2}}).
+ SplitOffsets([]int64{0, 64}).
+ Build().(*dataFile)
+
+ require.Nil(t, file.colSizeMap)
+ require.Nil(t, file.valCntMap)
+ require.Nil(t, file.nullCntMap)
+ require.Nil(t, file.nanCntMap)
+ require.Nil(t, file.lowerBoundMap)
+ require.Nil(t, file.upperBoundMap)
+
+ offsets := internal.BorrowedDataFileSplitOffsets(file)
+ require.Equal(t, []int64{0, 64}, offsets)
+
+ require.Nil(t, file.colSizeMap)
+ require.Nil(t, file.valCntMap)
+ require.Nil(t, file.nullCntMap)
+ require.Nil(t, file.nanCntMap)
+ require.Nil(t, file.lowerBoundMap)
+ require.Nil(t, file.upperBoundMap)
+}
diff --git a/internal/data_file_ref.go b/internal/data_file_ref.go
index bca42702a..61a418fad 100644
--- a/internal/data_file_ref.go
+++ b/internal/data_file_ref.go
@@ -17,6 +17,8 @@
package internal
+import "slices"
+
// DataFileRef authorizes zero-copy access to immutable DataFile state from
// trusted packages within this module. Go's internal-package rule prevents
// external callers from constructing this token.
@@ -80,6 +82,33 @@ type DataFileCollectionsRef interface {
)
}
+// DataFileSplitOffsets is the public getter needed by the internal
split-offset
+// helper. It intentionally excludes unrelated collection and statistics
getters.
+type DataFileSplitOffsets interface {
+ SplitOffsets() []int64
+}
+
+// DataFileSplitOffsetsRef exposes only split offsets so callers that do not
+// need column statistics do not trigger their lazy materialization.
+type DataFileSplitOffsetsRef interface {
+ DataFileSplitOffsetsRef(DataFileRef) []int64
+}
+
+// BorrowedDataFileSplitOffsets returns split offsets without copying for the
+// built-in data file and falls back to the public getter for external
+// implementations. The returned slice is read-only and must not be mutated or
+// retained beyond the current planning or read operation.
+func BorrowedDataFileSplitOffsets(file DataFileSplitOffsets) []int64 {
+ var splitOffsets []int64
+ if ref, ok := file.(DataFileSplitOffsetsRef); ok {
+ splitOffsets = ref.DataFileSplitOffsetsRef(DataFileRef{})
+ } else {
+ splitOffsets = file.SplitOffsets()
+ }
+
+ return slices.Clip(splitOffsets)
+}
+
func BorrowedDataFileCollections(file DataFileCollections) (
columnSizes map[int]int64,
keyMetadata []byte,
diff --git a/internal/data_file_ref_test.go b/internal/data_file_ref_test.go
new file mode 100644
index 000000000..c174a7227
--- /dev/null
+++ b/internal/data_file_ref_test.go
@@ -0,0 +1,107 @@
+// 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 internal
+
+import (
+ "slices"
+ "testing"
+)
+
+type splitOffsetsTestFile struct {
+ offsets []int64
+ splitOffsetsCalls int
+}
+
+func (f *splitOffsetsTestFile) SplitOffsets() []int64 {
+ f.splitOffsetsCalls++
+
+ return f.offsets
+}
+
+type splitOffsetsReferenceTestFile struct {
+ splitOffsetsTestFile
+ borrowedOffsets []int64
+ refCalls int
+}
+
+func (f *splitOffsetsReferenceTestFile) DataFileSplitOffsetsRef(DataFileRef)
[]int64 {
+ f.refCalls++
+
+ return f.borrowedOffsets
+}
+
+func splitOffsetsTestCases() []struct {
+ name string
+ offsets []int64
+} {
+ populated := make([]int64, 3, 8)
+ copy(populated, []int64{12, 40, 96})
+
+ return []struct {
+ name string
+ offsets []int64
+ }{
+ {name: "nil"},
+ {name: "empty", offsets: []int64{}},
+ {name: "populated with spare capacity", offsets: populated},
+ }
+}
+
+func TestBorrowedDataFileSplitOffsetsUsesPublicFallbackOnly(t *testing.T) {
+ for _, tt := range splitOffsetsTestCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ file := &splitOffsetsTestFile{offsets: tt.offsets}
+
+ got := BorrowedDataFileSplitOffsets(file)
+ if !slices.Equal(got, tt.offsets) || (got == nil) !=
(tt.offsets == nil) {
+ t.Fatalf("BorrowedDataFileSplitOffsets() = %v,
want %v", got, tt.offsets)
+ }
+ if cap(got) != len(got) {
+ t.Fatalf("BorrowedDataFileSplitOffsets()
capacity = %d, want %d", cap(got), len(got))
+ }
+ if file.splitOffsetsCalls != 1 {
+ t.Fatalf("SplitOffsets() called %d times, want
1", file.splitOffsetsCalls)
+ }
+ })
+ }
+}
+
+func TestBorrowedDataFileSplitOffsetsUsesBorrowedReference(t *testing.T) {
+ for _, tt := range splitOffsetsTestCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ file := &splitOffsetsReferenceTestFile{borrowedOffsets:
tt.offsets}
+
+ got := BorrowedDataFileSplitOffsets(file)
+ if !slices.Equal(got, tt.offsets) || (got == nil) !=
(tt.offsets == nil) {
+ t.Fatalf("BorrowedDataFileSplitOffsets() = %v,
want %v", got, tt.offsets)
+ }
+ if len(got) > 0 && &got[0] != &tt.offsets[0] {
+ t.Fatal("BorrowedDataFileSplitOffsets() copied
the borrowed offsets")
+ }
+ if cap(got) != len(got) {
+ t.Fatalf("BorrowedDataFileSplitOffsets()
capacity = %d, want %d", cap(got), len(got))
+ }
+ if file.refCalls != 1 {
+ t.Fatalf("DataFileSplitOffsetsRef() called %d
times, want 1", file.refCalls)
+ }
+ if file.splitOffsetsCalls != 0 {
+ t.Fatal("public getter was called for borrowed
offsets")
+ }
+ })
+ }
+}
diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index 92364b795..cfa5cf4ba 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -2518,7 +2518,7 @@ func (as *arrowScan) processRecordsWithPlans(
(task.Value.Length != 0 && task.Value.Length !=
task.Value.File.FileSizeBytes()) {
tester.RangeSet = true
tester.Start, tester.Length =
adjustParquetTaskRange(task.Value, rdr.SourceFileSize())
- tester.PlanningSplitOffsets =
task.Value.File.SplitOffsets()
+ tester.PlanningSplitOffsets =
iceinternal.BorrowedDataFileSplitOffsets(task.Value.File)
}
if posSource != nil {
tester.Survivors = &posSource.spans
diff --git a/table/arrow_scanner_bench_test.go
b/table/arrow_scanner_bench_test.go
index b5c5dcc20..e5aec94bc 100644
--- a/table/arrow_scanner_bench_test.go
+++ b/table/arrow_scanner_bench_test.go
@@ -197,12 +197,13 @@ func BenchmarkArrowScanManyFilesAndBatches(b *testing.B) {
}
func benchmarkSplitParquetScan(b *testing.B) (Metadata, iceio.IO,
[]FileScanTask, []FileScanTask, int64) {
- b.Helper()
+ return benchmarkSplitParquetScanWithRowGroups(b, 8, 32768)
+}
- const (
- rowGroups = 8
- rowsPerGroup = 32768
- )
+func benchmarkSplitParquetScanWithRowGroups(
+ b *testing.B, rowGroups, rowsPerGroup int,
+) (Metadata, iceio.IO, []FileScanTask, []FileScanTask, int64) {
+ b.Helper()
rowCount := int64(rowGroups * rowsPerGroup)
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
@@ -233,9 +234,9 @@ func benchmarkSplitParquetScan(b *testing.B) (Metadata,
iceio.IO, []FileScanTask
var buf bytes.Buffer
writerProps := parquet.NewWriterProperties(
parquet.WithStats(true),
- parquet.WithMaxRowGroupLength(rowsPerGroup),
+ parquet.WithMaxRowGroupLength(int64(rowsPerGroup)),
)
- if err := pqarrow.WriteTable(table, &buf, rowsPerGroup, writerProps,
pqarrow.DefaultWriterProps()); err != nil {
+ if err := pqarrow.WriteTable(table, &buf, int64(rowsPerGroup),
writerProps, pqarrow.DefaultWriterProps()); err != nil {
b.Fatal(err)
}
@@ -276,8 +277,15 @@ func benchmarkSplitParquetScan(b *testing.B) (Metadata,
iceio.IO, []FileScanTask
dataFile := dataFileBuilder.SplitOffsets(offsets).Build()
fullTask := FileScanTask{File: dataFile, Start: 0, Length:
int64(buf.Len())}
splitTasks, split := splitParquetScanTask(fullTask, 1)
+ if rowGroups == 1 && !split {
+ // A one-row-group file has fewer than two split offsets and
cannot be split.
+ // Make the task partial so the benchmark still exercises range
planning.
+ partialTask := fullTask
+ partialTask.Length--
+ splitTasks, split = []FileScanTask{partialTask}, true
+ }
if !split || len(splitTasks) != rowGroups {
- b.Fatalf("expected one split task per row group, got %d",
len(splitTasks))
+ b.Fatalf("expected %d benchmark range tasks, got %d",
rowGroups, len(splitTasks))
}
metadata, err := NewMetadata(schema, iceberg.UnpartitionedSpec,
UnsortedSortOrder,
@@ -334,6 +342,47 @@ func BenchmarkArrowScanLargeParquetFileSplitTasks(b
*testing.B) {
}
}
+func BenchmarkArrowScanPlanningSplitOffsets(b *testing.B) {
+ for _, rowGroups := range []int{1, 32, 128, 512} {
+ b.Run(fmt.Sprintf("offsets=%d", rowGroups), func(b *testing.B) {
+ metadata, fs, _, tasks, wantRows :=
benchmarkSplitParquetScanWithRowGroups(
+ b, rowGroups, 512/rowGroups)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ scan := &arrowScan{
+ fs: fs,
+ metadata: metadata,
+ scanSchema:
metadata.CurrentSchema(),
+ projectedSchema:
metadata.CurrentSchema(),
+ boundRowFilter: iceberg.AlwaysTrue{},
+ rowLimit: -1,
+ concurrency: 8,
+ }
+ _, records, err := scan.GetRecords(b.Context(),
tasks)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ var rows int64
+ for record, err := range records {
+ if err != nil {
+ b.Fatal(err)
+ }
+ rows += record.NumRows()
+ record.Release()
+ }
+ if rows != wantRows {
+ b.Fatalf("unexpected row count: got %d,
want %d", rows, wantRows)
+ }
+ }
+ b.ReportMetric(float64(len(tasks)), "tasks/op")
+ b.ReportMetric(float64(rowGroups), "split_offsets/task")
+ })
+ }
+}
+
func benchmarkComplexBoundFilter(b *testing.B, schema *iceberg.Schema)
iceberg.BooleanExpression {
b.Helper()
diff --git a/table/data_file_stats_ref_test.go
b/table/data_file_stats_ref_test.go
index e5e1dee42..937e85491 100644
--- a/table/data_file_stats_ref_test.go
+++ b/table/data_file_stats_ref_test.go
@@ -183,6 +183,26 @@ func TestDataFileCollectionsUsesBorrowedView(t *testing.T)
{
assert.Equal(t, map[int]int64{1: 10}, measuredColumnSizes)
}
+func TestBorrowedDataFileSplitOffsetsUsesBuiltInBackingSlice(t *testing.T) {
+ file := testDataFileWithStats(t)
+ require.Implements(t, (*internal.DataFileSplitOffsetsRef)(nil), file)
+
+ first := internal.BorrowedDataFileSplitOffsets(file)
+ second := internal.BorrowedDataFileSplitOffsets(file)
+ public := file.SplitOffsets()
+ require.NotEmpty(t, first)
+ require.Equal(t, first, second)
+ require.Equal(t, first, public)
+ require.Equal(t, len(first), cap(first))
+
+ if &first[0] != &second[0] {
+ t.Fatal("borrowed split offsets do not share the built-in data
file backing slice")
+ }
+ if &first[0] == &public[0] {
+ t.Fatal("public SplitOffsets unexpectedly exposed the built-in
backing slice")
+ }
+}
+
func TestDataFileEqualityFieldIDsClonesBorrowedView(t *testing.T) {
file := testDataFileWithStats(t)
require.Implements(t, (*internal.DataFileCollectionsRef)(nil), file)
diff --git a/table/internal/parquet_files.go b/table/internal/parquet_files.go
index 9805878dd..e023739ce 100644
--- a/table/internal/parquet_files.go
+++ b/table/internal/parquet_files.go
@@ -1994,9 +1994,11 @@ type ParquetRowGroupTester struct {
// Parquet row-group offset falls in [Start, Start+Length).
Start, Length int64
// PlanningSplitOffsets contains the split offsets used when the scan
task
- // was planned. When it contains one valid offset per row group, range
- // selection uses these offsets instead of re-deriving them from the
opened
- // Parquet footer. Sparse split-offset lists fall back to the footer
offsets.
+ // was planned. It may be a borrowed view of DataFile state and must be
+ // treated as read-only for the lifetime of this tester. When it
contains one
+ // valid offset per row group, range selection uses these offsets
instead of
+ // re-deriving them from the opened Parquet footer. Sparse split-offset
lists
+ // fall back to the footer offsets.
PlanningSplitOffsets []int64
// Survivors, if non-nil, is reset and then filled with one span per
row group
// that survives pruning, in file order, with positions relative to the
full
diff --git a/table/scan_splits.go b/table/scan_splits.go
index a07037e03..83de6abfe 100644
--- a/table/scan_splits.go
+++ b/table/scan_splits.go
@@ -17,7 +17,10 @@
package table
-import "github.com/apache/iceberg-go"
+import (
+ "github.com/apache/iceberg-go"
+ 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
@@ -40,7 +43,7 @@ func splitParquetScanTask(task FileScanTask, targetSize
int64) ([]FileScanTask,
return nil, false
}
- offsets := file.SplitOffsets()
+ offsets := iceinternal.BorrowedDataFileSplitOffsets(file)
if len(offsets) < 2 {
return nil, false
}
diff --git a/table/scan_splits_bench_test.go b/table/scan_splits_bench_test.go
new file mode 100644
index 000000000..a60c889a9
--- /dev/null
+++ b/table/scan_splits_bench_test.go
@@ -0,0 +1,118 @@
+// 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"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+)
+
+var splitRemoteScanTasksBenchmarkSink []FileScanTask
+
+func BenchmarkSplitRemoteScanTasksSplitOffsets(b *testing.B) {
+ const filesPerOp = 1024
+
+ for _, offsetCount := range []int{1, 32, 128, 512} {
+ for _, publicGetter := range []bool{false, true} {
+ b.Run(fmt.Sprintf("offsets=%d/public=%t", offsetCount,
publicGetter), func(b *testing.B) {
+ fileSize := int64(offsetCount+1) * 64
+ offsets := make([]int64, offsetCount)
+ for i := range offsets {
+ offsets[i] = int64(i+1) * 64
+ }
+
+ makeFile := func(i int) iceberg.DataFile {
+ dataFileBuilder, err :=
iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec,
+ iceberg.EntryContentData,
+
fmt.Sprintf("mem://benchmark/split-offsets-%d.parquet", i),
+ iceberg.ParquetFile,
+ nil,
+ nil,
+ nil,
+ 1,
+ fileSize,
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ file := dataFileBuilder.
+ ColumnSizes(map[int]int64{1:
fileSize}).
+ ValueCounts(map[int]int64{1:
1}).
+
NullValueCounts(map[int]int64{1: 0}).
+ NaNValueCounts(map[int]int64{1:
0}).
+
LowerBoundValues(map[int][]byte{1: {1}}).
+
UpperBoundValues(map[int][]byte{1: {2}}).
+ SplitOffsets(offsets).
+ Build()
+ if publicGetter {
+ file =
splitOffsetsPublicBenchmarkFile{DataFile: file}
+ }
+
+ return file
+ }
+ makeTasks := func() []FileScanTask {
+ tasks := make([]FileScanTask,
filesPerOp)
+ for i := range tasks {
+ tasks[i] = FileScanTask{File:
makeFile(i), Start: 0, Length: fileSize}
+ }
+
+ return tasks
+ }
+
+ probe := makeTasks()
+ wantTasksPerFile := 1
+ if offsetCount > 1 {
+ splits, ok :=
splitParquetScanTask(probe[0], fileSize/2)
+ if !ok {
+ b.Fatal("expected split offsets
to produce scan tasks")
+ }
+ wantTasksPerFile = len(splits)
+ }
+ wantTasks := filesPerOp * wantTasksPerFile
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ // Rebuild distinct files outside the
timer so each timed call measures
+ // first access to per-file metadata,
matching scan planning.
+ b.StopTimer()
+ tasks := makeTasks()
+ b.StartTimer()
+
+ result := splitRemoteScanTasks(tasks,
fileSize/2)
+
+ b.StopTimer()
+ if len(result) != wantTasks {
+
b.Fatalf("splitRemoteScanTasks() returned %d tasks, want %d", len(result),
wantTasks)
+ }
+ splitRemoteScanTasksBenchmarkSink =
result
+ b.StartTimer()
+ }
+ b.ReportMetric(filesPerOp, "files/op")
+ b.ReportMetric(float64(offsetCount),
"split_offsets/file")
+ })
+ }
+ }
+}
+
+// Embedding only the public DataFile interface intentionally hides
+// DataFileSplitOffsetsRef, forcing the defensive-copy fallback for comparison.
+type splitOffsetsPublicBenchmarkFile struct{ iceberg.DataFile }