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 9f1963f59 perf(table): reuse sorted stats IDs in files metadata scans
(#2257)
9f1963f59 is described below
commit 9f1963f598b5a0517ce51dbe5c85e7cae5db08ac
Author: Minh Vu <[email protected]>
AuthorDate: Wed Oct 21 22:55:45 2026 +0200
perf(table): reuse sorted stats IDs in files metadata scans (#2257)
* perf(table): reuse sorted metadata statistic field IDs
Signed-off-by: Minh Vu <[email protected]>
* style(table): use integer range in stats keyset benchmark
Signed-off-by: Minh Vu <[email protected]>
* test(table): check populated files statistics columns
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/inspect_files.go | 66 ++++++++----
table/inspect_files_keyset_cache_test.go | 175 +++++++++++++++++++++++++++++++
2 files changed, 222 insertions(+), 19 deletions(-)
diff --git a/table/inspect_files.go b/table/inspect_files.go
index 42b7915fc..cd51f890e 100644
--- a/table/inspect_files.go
+++ b/table/inspect_files.go
@@ -516,7 +516,15 @@ type inspectContentFileBuilder struct {
referencedDataFile *array.StringBuilder
contentOffset *array.Int64Builder
contentSize *array.Int64Builder
- mapFieldIDs []int
+ // One sorted key-set per statistics column, scoped to this record
builder.
+ // Most files in a table use the same metric field IDs. A separate cache
+ // prevents unlike columns from evicting each other's key order.
+ columnSizeFieldIDs []int
+ valueCountFieldIDs []int
+ nullCountFieldIDs []int
+ nanCountFieldIDs []int
+ lowerBoundFieldIDs []int
+ upperBoundFieldIDs []int
}
type inspectPartitionBuilder struct {
@@ -648,23 +656,23 @@ func (b *inspectContentFileBuilder) append(file
iceberg.DataFile) error {
b.recordCount.Append(file.Count())
b.fileSize.Append(file.FileSizeBytes())
columnSizes, keyMetadata, splitOffsets, equalityFieldIDs :=
dataFileCollections(file)
- if err := appendInspectInt64Map(b.columnSizes, columnSizes,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectInt64Map(b.columnSizes, columnSizes,
&b.columnSizeFieldIDs); err != nil {
return err
}
valueCounts, nullCounts, nanCounts, lowerBounds, upperBounds :=
dataFileStats(file)
- if err := appendInspectInt64Map(b.valueCounts, valueCounts,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectInt64Map(b.valueCounts, valueCounts,
&b.valueCountFieldIDs); err != nil {
return err
}
- if err := appendInspectInt64Map(b.nullValueCounts, nullCounts,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectInt64Map(b.nullValueCounts, nullCounts,
&b.nullCountFieldIDs); err != nil {
return err
}
- if err := appendInspectInt64Map(b.nanValueCounts, nanCounts,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectInt64Map(b.nanValueCounts, nanCounts,
&b.nanCountFieldIDs); err != nil {
return err
}
- if err := appendInspectBinaryMap(b.lowerBounds, lowerBounds,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectBinaryMap(b.lowerBounds, lowerBounds,
&b.lowerBoundFieldIDs); err != nil {
return err
}
- if err := appendInspectBinaryMap(b.upperBounds, upperBounds,
&b.mapFieldIDs); err != nil {
+ if err := appendInspectBinaryMap(b.upperBounds, upperBounds,
&b.upperBoundFieldIDs); err != nil {
return err
}
appendInspectBytes(b.keyMetadata, keyMetadata)
@@ -944,6 +952,36 @@ func inspectInt32Value(value int, name string) (int32,
error) {
return int32(value), nil
}
+// cachedInspectFieldIDs returns the sorted map keys, reusing the previous
+// ordering when the next file has exactly the same key set. Length alone is
+// not sufficient: maps with different IDs can have the same number of keys.
+// Keeping only one sorted set makes memory bounded by the widest map seen.
+func cachedInspectFieldIDs[V any](values map[int]V, cached *[]int) []int {
+ ids := *cached
+ if len(ids) == len(values) {
+ sameKeys := true
+ for _, id := range ids {
+ if _, ok := values[id]; !ok {
+ sameKeys = false
+
+ break
+ }
+ }
+ if sameKeys {
+ return ids
+ }
+ }
+
+ ids = ids[:0]
+ for id := range values {
+ ids = append(ids, id)
+ }
+ sort.Ints(ids)
+ *cached = ids
+
+ return ids
+}
+
func appendInspectInt64Map(builder *array.MapBuilder, values map[int]int64,
scratch *[]int) error {
if values == nil {
builder.AppendNull()
@@ -959,12 +997,7 @@ func appendInspectInt64Map(builder *array.MapBuilder,
values map[int]int64, scra
if !ok {
return fmt.Errorf("map item builder has type %T, want int64",
builder.ItemBuilder())
}
- ids := (*scratch)[:0]
- for id := range values {
- ids = append(ids, id)
- }
- *scratch = ids
- sort.Ints(ids)
+ ids := cachedInspectFieldIDs(values, scratch)
for _, id := range ids {
key, err := inspectInt32Value(id, "field ID")
if err != nil {
@@ -992,12 +1025,7 @@ func appendInspectBinaryMap(builder *array.MapBuilder,
values map[int][]byte, sc
if !ok {
return fmt.Errorf("map item builder has type %T, want binary",
builder.ItemBuilder())
}
- ids := (*scratch)[:0]
- for id := range values {
- ids = append(ids, id)
- }
- *scratch = ids
- sort.Ints(ids)
+ ids := cachedInspectFieldIDs(values, scratch)
for _, id := range ids {
key, err := inspectInt32Value(id, "field ID")
if err != nil {
diff --git a/table/inspect_files_keyset_cache_test.go
b/table/inspect_files_keyset_cache_test.go
new file mode 100644
index 000000000..17cf986c6
--- /dev/null
+++ b/table/inspect_files_keyset_cache_test.go
@@ -0,0 +1,175 @@
+// 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"
+ "slices"
+ "sort"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/iceberg-go"
+)
+
+func TestCachedInspectFieldIDsRechecksKeys(t *testing.T) {
+ var ids []int
+ for _, values := range []map[int]int64{
+ {9: 90, 1: 10, 5: 50},
+ {9: 91, 1: 11, 5: 51}, // Same keys, different values.
+ {9: 92, 2: 22, 5: 52}, // Same length, different keys.
+ {},
+ {7: 70},
+ {3: 30, 1: 10},
+ {3: 31, 1: 11},
+ } {
+ expected := make([]int, 0, len(values))
+ for id := range values {
+ expected = append(expected, id)
+ }
+ sort.Ints(expected)
+ if got := cachedInspectFieldIDs(values, &ids);
!slices.Equal(got, expected) {
+ t.Fatalf("sorted keys = %v, want %v", got, expected)
+ }
+ }
+
+ binaryValues := map[int][]byte{12: {12}, 3: {3}, 8: {8}}
+ var binaryIDs []int
+ if got := cachedInspectFieldIDs(binaryValues, &binaryIDs);
!slices.Equal(got, []int{3, 8, 12}) {
+ t.Fatalf("binary map keys = %v", got)
+ }
+}
+
+func TestInspectFileAppenderRechecksChangedStatsMapKeys(t *testing.T) {
+ schema, err :=
SchemaToArrowSchema(DataFilesSchema(&iceberg.StructType{}), nil, true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
+ defer builder.Release()
+ appendFile := newInspectContentFileAppender(&iceberg.StructType{})
+
+ values := []map[int]int64{
+ {1: 11, 3: 33},
+ {2: 22, 3: 34},
+ {1: 14, 2: 24},
+ {},
+ nil,
+ {2: 26, 3: 36},
+ }
+ for i, counts := range values {
+ file, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec,
+ iceberg.EntryContentData,
+ fmt.Sprintf("file-%d.parquet", i),
+ iceberg.ParquetFile,
+ nil, nil, nil, 1, 1,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ bounds := make(map[int][]byte, len(counts))
+ for id := range counts {
+ bounds[id] = []byte{byte(id), byte(i)}
+ }
+ if counts == nil {
+ bounds = nil
+ }
+
file.ColumnSizes(counts).ValueCounts(counts).LowerBoundValues(bounds)
+ if err := appendFile(builder, file.Build()); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ rec := builder.NewRecordBatch()
+ defer rec.Release()
+ for _, colIndex := range []int{6, 7, 10} {
+ column := rec.Column(colIndex).(*array.Map)
+ keys := column.Keys().(*array.Int32)
+ for row, counts := range values {
+ if column.IsNull(row) != (counts == nil) {
+ t.Fatalf("column %d row %d null = %v",
colIndex, row, column.IsNull(row))
+ }
+ expected := make([]int32, 0, len(counts))
+ for id := range counts {
+ expected = append(expected, int32(id))
+ }
+ slices.Sort(expected)
+ start, end := column.ValueOffsets(row)
+ got := make([]int32, 0, end-start)
+ for idx := start; idx < end; idx++ {
+ got = append(got, keys.Value(int(idx)))
+ }
+ if !slices.Equal(got, expected) {
+ t.Fatalf("column %d row %d sorted field IDs =
%v, want %v", colIndex, row, got, expected)
+ }
+ }
+ }
+ // Verify the cached ordering does not cache map values.
+ column := rec.Column(7).(*array.Map)
+ items := column.Items().(*array.Int64)
+ start, end := column.ValueOffsets(1)
+ if end-start != 2 || items.Value(int(start)) != 22 ||
items.Value(int(start+1)) != 34 {
+ t.Fatalf("wrong values on changed key set: %v", items)
+ }
+}
+
+var benchmarkInspectFieldIDKeysetSink int
+
+func BenchmarkInspectFieldIDKeyset(b *testing.B) {
+ for _, width := range []int{8, 32, 128, 512} {
+ first := make(map[int]int64, width)
+ second := make(map[int]int64, width)
+ for id := range width {
+ first[id*2+1] = int64(id)
+ second[id*2+2] = int64(id)
+ }
+ for _, changing := range []bool{false, true} {
+ mode := "stable"
+ if changing {
+ mode = "changing"
+ }
+ for _, implementation := range []string{"baseline",
"cached"} {
+ b.Run(fmt.Sprintf("fields=%d/%s/%s", width,
mode, implementation), func(b *testing.B) {
+ var scratch []int
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := range b.N {
+ values := first
+ if changing && i%2 != 0 {
+ values = second
+ }
+ var ids []int
+ if implementation == "cached" {
+ ids =
cachedInspectFieldIDs(values, &scratch)
+ } else {
+ ids = scratch[:0]
+ for id := range values {
+ ids =
append(ids, id)
+ }
+ sort.Ints(ids)
+ scratch = ids
+ }
+
benchmarkInspectFieldIDKeysetSink = len(ids)
+ }
+ })
+ }
+ }
+ }
+}