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 2e69b8adb perf(internal): avoid quadratic packing with large lookback
(#2363)
2e69b8adb is described below
commit 2e69b8adb8f0db0ecdd7a48cf4280737f4704fe6
Author: Minh Vu <[email protected]>
AuthorDate: Thu Nov 5 21:12:15 2026 +0100
perf(internal): avoid quadratic packing with large lookback (#2363)
* perf(internal): avoid quadratic packing with large lookback
Signed-off-by: Minh Vu <[email protected]>
* test(internal): compare indexed packing with linear across weight
transitions
* fix(internal): correct reverse iterator test declaration
* test(internal): use integer range in packing test
---------
Signed-off-by: Minh Vu <[email protected]>
---
internal/packing_indexed.go | 26 ++--
internal/packing_large_lookback_test.go | 216 ++++++++++++++++++++++++++++++++
internal/utils.go | 4 +-
3 files changed, 237 insertions(+), 9 deletions(-)
diff --git a/internal/packing_indexed.go b/internal/packing_indexed.go
index 9f54a0faa..ea4567ce1 100644
--- a/internal/packing_indexed.go
+++ b/internal/packing_indexed.go
@@ -23,13 +23,14 @@ import (
)
// Large FIFO plans spend most of their time searching the open bins.
-// Index only when there are enough items to amortize the tree and ring buffer.
-// Smaller plans, largest-bin-first packing, and potentially overflowing
-// weights keep the existing linear implementation.
+// For small plans, index only after there are enough items to amortize it.
+// Large plans use the indexed first-fit path even with a very large lookback;
+// otherwise every new bin searches thousands of open bins. Division avoids
+// overflow when callers use a practically unbounded lookback.
func (s *SlicePacker[T]) shouldUseIndexedFIFO(itemCount int) bool {
return !s.LargestBinFirst &&
- s.Lookback >= 32 && s.Lookback <= 8192 &&
- itemCount >= 2*s.Lookback &&
+ s.Lookback >= 32 &&
+ (itemCount/s.Lookback >= 2 || itemCount >= 16_384) &&
s.TargetWeight >= 0 && s.TargetWeight <= math.MaxInt64/2
}
@@ -96,7 +97,8 @@ func indexedFIFOPackingIterator[T any](
input iter.Seq[T], targetWeight int64, lookback int, weightFunc func(T)
int64,
) iter.Seq[[]T] {
capacity := lookback + 1
- bins := make([]Bin[T], capacity)
+ // Small or easy-to-pack inputs do not need a lookback-sized ring.
+ bins := make([]Bin[T], 0, min(capacity, 32))
var weights packingWeightIndex
head, openBins := 0, 0
@@ -140,7 +142,11 @@ func indexedFIFOPackingIterator[T any](
}
slot := (head + openBins) % capacity
- bins[slot] = Bin[T]{targetWeight: targetWeight}
+ if slot == len(bins) {
+ bins = append(bins, Bin[T]{targetWeight:
targetWeight})
+ } else {
+ bins[slot] = Bin[T]{targetWeight: targetWeight}
+ }
bins[slot].Add(item, weight)
if indexed {
weights.update(slot, weight)
@@ -148,6 +154,12 @@ func indexedFIFOPackingIterator[T any](
openBins++
if safeToIndex && !indexed && openBins == 32 {
+ // Only allocate the full ring once indexing
becomes worthwhile.
+ if cap(bins) < capacity {
+ expanded := make([]Bin[T], capacity)
+ copy(expanded, bins)
+ bins = expanded
+ }
weights = newPackingWeightIndex(capacity)
for i := range openBins {
j := (head + i) % capacity
diff --git a/internal/packing_large_lookback_test.go
b/internal/packing_large_lookback_test.go
new file mode 100644
index 000000000..a4d9f40a7
--- /dev/null
+++ b/internal/packing_large_lookback_test.go
@@ -0,0 +1,216 @@
+// 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 (
+ "fmt"
+ "math"
+ "math/rand"
+ "reflect"
+ "slices"
+ "testing"
+)
+
+func TestLargeLookbackIndexedSelection(t *testing.T) {
+ tests := []struct {
+ name string
+ count int
+ lookback int
+ target int64
+ largest bool
+ want bool
+ }{
+ {"small existing indexed plan", 64, 32, 128, false, true},
+ {"old indexed boundary", 16_384, 8192, 128, false, true},
+ {"past old limit", 16_384, 8193, 128, false, true},
+ {"large lookback", 24_000, 10_000, 128, false, true},
+ {"effectively unbounded", 16_384, math.MaxInt, 128, false,
true},
+ {"below large plan cutoff", 16_383, math.MaxInt, 128, false,
false},
+ {"small input", 8000, 10_000, 128, false, false},
+ {"largest bin first", 24_000, 10_000, 128, true, false},
+ {"negative target", 24_000, 10_000, -1, false, false},
+ {"overflowing target", 24_000, 10_000, math.MaxInt64, false,
false},
+ {"zero lookback", 24_000, 0, 128, false, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ packer := SlicePacker[int]{
+ TargetWeight: tt.target,
+ Lookback: tt.lookback,
+ LargestBinFirst: tt.largest,
+ }
+ if got := packer.shouldUseIndexedFIFO(tt.count); got !=
tt.want {
+ t.Fatalf("shouldUseIndexedFIFO(%d) = %t, want
%t", tt.count, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestLargeLookbackPackingMatchesLinear(t *testing.T) {
+ tests := []struct {
+ name string
+ count int
+ lookback int
+ mode string
+ }{
+ {"old index limit", 16_384, 8193, "large"},
+ {"many open bins", 20_000, 10_000, "mixed"},
+ {"unbounded lookback", 16_384, math.MaxInt, "large"},
+ {"fall back after negative weight", 16_384, 8193, "negative"},
+ {"fall back after oversized weight", 16_384, 8193, "oversized"},
+ {"single bin, no eager ring", 16_384, math.MaxInt, "single"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ items := make([]int, tt.count)
+ weights := make([]int64, tt.count)
+ rng := rand.New(rand.NewSource(42))
+ for i := range items {
+ items[i] = i
+ switch tt.mode {
+ case "mixed":
+ weights[i] = int64(1 + rng.Intn(128))
+ case "single":
+ weights[i] = 0
+ default:
+ weights[i] = int64(65 + i%64)
+ }
+ }
+ if tt.mode == "negative" {
+ weights[tt.count/2] = -1
+ }
+ if tt.mode == "oversized" {
+ weights[tt.count/2] = math.MaxInt64
+ }
+ weight := func(index int) int64 { return weights[index]
}
+ packer := SlicePacker[int]{TargetWeight: 128, Lookback:
tt.lookback}
+ want := slices.Collect(PackingIterator(
+ slices.Values(items), 128, tt.lookback, weight,
false))
+ if got := packer.Pack(items, weight);
!reflect.DeepEqual(got, want) {
+ t.Fatalf("Pack differs from linear first-fit
(%d items, lookback %d)",
+ tt.count, tt.lookback)
+ }
+
+ reversed := func(yield func(int) bool) {
+ for _, item := range slices.Backward(items) {
+ if !yield(item) {
+ return
+ }
+ }
+ }
+ wantEnd := slices.Collect(PackingIterator(
+ reversed, 128, tt.lookback, weight, false))
+ slices.Reverse(wantEnd)
+ for _, bin := range wantEnd {
+ slices.Reverse(bin)
+ }
+ if got := packer.PackEnd(items, weight);
!reflect.DeepEqual(got, wantEnd) {
+ t.Fatalf("PackEnd differs from linear first-fit
(%d items, lookback %d)",
+ tt.count, tt.lookback)
+ }
+ })
+ }
+}
+
+var largeLookbackPackingBenchmarkSink [][]int64
+
+// This compares the original linear fallback with SlicePacker's selected
+// implementation on the same input. Both modes must return the same bins.
+func BenchmarkSlicePackerLargeLookback(b *testing.B) {
+ for _, tc := range []struct {
+ count int
+ lookback int
+ }{
+ {16_384, 8193},
+ {24_000, 10_000},
+ {24_000, 1_000_000},
+ } {
+ b.Run(fmt.Sprintf("files=%d/lookback=%d", tc.count,
tc.lookback), func(b *testing.B) {
+ items := make([]int64, tc.count)
+ for i := range items {
+ items[i] = int64(65 + i%64)
+ }
+ weight := func(item int64) int64 { return item }
+ packer := SlicePacker[int64]{TargetWeight: 128,
Lookback: tc.lookback}
+ for _, mode := range []string{"linear", "indexed"} {
+ b.Run(mode, func(b *testing.B) {
+ b.ReportAllocs()
+ b.ReportMetric(float64(tc.count),
"files/op")
+ b.ResetTimer()
+ for range b.N {
+ if mode == "linear" {
+
largeLookbackPackingBenchmarkSink = slices.Collect(PackingIterator(
+
slices.Values(items), 128, tc.lookback, weight, false))
+ } else {
+
largeLookbackPackingBenchmarkSink = packer.Pack(items, weight)
+ }
+ }
+ if
len(largeLookbackPackingBenchmarkSink) != tc.count {
+ b.Fatalf("expected %d bins, got
%d", tc.count, len(largeLookbackPackingBenchmarkSink))
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestIndexedPackingMatchesLinearAcrossWeightTransitions(t *testing.T) {
+ const count = 512
+ for _, lookback := range []int{32, 33, 64, 128} {
+ for seed := range int64(4) {
+ t.Run(fmt.Sprintf("lookback=%d/seed=%d", lookback,
seed), func(t *testing.T) {
+ rng := rand.New(rand.NewSource(seed))
+ items := make([]int, count)
+ weights := make([]int64, count)
+ for i := range items {
+ items[i] = i
+ if i < 160 {
+ weights[i] = int64(65 +
rng.Intn(64))
+ } else {
+ weights[i] =
int64(rng.Intn(129))
+ }
+ }
+ weights[255] = -1
+ weights[383] = math.MaxInt64
+ weight := func(i int) int64 { return weights[i]
}
+ packer := SlicePacker[int]{TargetWeight: 128,
Lookback: lookback}
+
+ want :=
slices.Collect(PackingIterator(slices.Values(items), 128, lookback, weight,
false))
+ if got := packer.Pack(items, weight);
!reflect.DeepEqual(got, want) {
+ t.Fatal("indexed packing differs from
linear first-fit")
+ }
+
+ reversed := func(yield func(int) bool) {
+ for _, item := range
slices.Backward(items) {
+ if !yield(item) {
+ return
+ }
+ }
+ }
+ wantEnd :=
slices.Collect(PackingIterator(reversed, 128, lookback, weight, false))
+ slices.Reverse(wantEnd)
+ for _, bin := range wantEnd {
+ slices.Reverse(bin)
+ }
+ if got := packer.PackEnd(items, weight);
!reflect.DeepEqual(got, wantEnd) {
+ t.Fatal("indexed reverse packing
differs from linear first-fit")
+ }
+ })
+ }
+ }
+}
diff --git a/internal/utils.go b/internal/utils.go
index c5de79771..842c98d3b 100644
--- a/internal/utils.go
+++ b/internal/utils.go
@@ -209,7 +209,7 @@ type SlicePacker[T any] struct {
func (s *SlicePacker[T]) Pack(items []T, weightFunc func(T) int64) [][]T {
if s.shouldUseIndexedFIFO(len(items)) {
return
slices.Collect(indexedFIFOPackingIterator(slices.Values(items),
- s.TargetWeight, s.Lookback, weightFunc))
+ s.TargetWeight, min(s.Lookback, len(items)),
weightFunc))
}
return slices.Collect(PackingIterator(slices.Values(items),
s.TargetWeight,
@@ -228,7 +228,7 @@ func (s *SlicePacker[T]) PackEnd(items []T, weightFunc
func(T) int64) [][]T {
var packed [][]T
if s.shouldUseIndexedFIFO(len(items)) {
packed = slices.Collect(indexedFIFOPackingIterator(reversed,
- s.TargetWeight, s.Lookback, weightFunc))
+ s.TargetWeight, min(s.Lookback, len(items)),
weightFunc))
} else {
packed = slices.Collect(PackingIterator(reversed,
s.TargetWeight, s.Lookback, weightFunc,
s.LargestBinFirst))