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 8ce33af06 perf(table): avoid rebuilding unchanged partition residual 
expressions (#2201)
8ce33af06 is described below

commit 8ce33af06edd4538aaa94d5a65fc1b562946a9a9
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 19 20:07:42 2026 +0200

    perf(table): avoid rebuilding unchanged partition residual expressions 
(#2201)
    
    * perf(table): reuse unchanged partition residual expression subtrees
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): cover unchanged NOT and OR residual allocation paths
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): benchmark mixed partition residual evaluations
    
    * test(table): verify residual reuse across alternating partitions
    
    * style(table): satisfy nlreturn in residual fast paths
    
    * style(table): separate residual benchmark return
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/partition_residual.go            |  18 ++++-
 table/partition_residual_bench_test.go |  89 ++++++++++++++++++++++
 table/partition_residual_reuse_test.go | 130 +++++++++++++++++++++++++++++++++
 3 files changed, 234 insertions(+), 3 deletions(-)

diff --git a/table/partition_residual.go b/table/partition_residual.go
index e47beddeb..690925e01 100644
--- a/table/partition_residual.go
+++ b/table/partition_residual.go
@@ -301,7 +301,11 @@ func (n *partitionResidualNode) residual(
                        return nil, false, err
                }
 
-               return iceberg.NewNot(child), changed, nil
+               if !changed {
+                       return n.expr, false, nil
+               }
+
+               return iceberg.NewNot(child), true, nil
        case partitionResidualAnd:
                left, leftChanged, err := n.left.residual(partition)
                if err != nil {
@@ -316,7 +320,11 @@ func (n *partitionResidualNode) residual(
                        return nil, false, err
                }
 
-               return iceberg.NewAnd(left, right), leftChanged || 
rightChanged, nil
+               if !leftChanged && !rightChanged {
+                       return n.expr, false, nil
+               }
+
+               return iceberg.NewAnd(left, right), true, nil
        case partitionResidualOr:
                left, leftChanged, err := n.left.residual(partition)
                if err != nil {
@@ -331,7 +339,11 @@ func (n *partitionResidualNode) residual(
                        return nil, false, err
                }
 
-               return iceberg.NewOr(left, right), leftChanged || rightChanged, 
nil
+               if !leftChanged && !rightChanged {
+                       return n.expr, false, nil
+               }
+
+               return iceberg.NewOr(left, right), true, nil
        }
 
        return n.expr, false, nil
diff --git a/table/partition_residual_bench_test.go 
b/table/partition_residual_bench_test.go
index f7ae51bad..c082e1173 100644
--- a/table/partition_residual_bench_test.go
+++ b/table/partition_residual_bench_test.go
@@ -18,6 +18,7 @@
 package table
 
 import (
+       "fmt"
        "testing"
 
        "github.com/apache/iceberg-go"
@@ -104,3 +105,91 @@ func BenchmarkPartitionResidualPlanning(b *testing.B) {
                })
        }
 }
+
+// BenchmarkPartitionResidualUnchangedSubtrees measures per-file residual
+// evaluation when either no predicate simplifies or just one predicate does.
+// The balanced tree keeps the unaffected branches representative of filters
+// containing many conditions.
+func BenchmarkPartitionResidualUnchangedSubtrees(b *testing.B) {
+       schema := partitionResidualTestSchema()
+       spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "tenant_id", 
Transform: iceberg.IdentityTransform{},
+       })
+
+       for _, predicateCount := range []int{8, 32} {
+               predicates := make([]iceberg.BooleanExpression, 0, 
predicateCount)
+               predicates = append(predicates, 
iceberg.EqualTo(iceberg.Reference("tenant_id"), "acme"))
+               for i := 1; i < predicateCount; i++ {
+                       predicates = append(predicates, 
iceberg.GreaterThan(iceberg.Reference("amount"), int64(i)))
+               }
+
+               bound, err := iceberg.BindExpr(schema, 
benchmarkBalancedPartitionResidualAnd(predicates), true)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               evaluator, err := newPartitionResidualEvaluator(schema, &spec, 
bound, true)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               if evaluator == nil {
+                       b.Fatal("expected partition residual evaluator")
+               }
+
+               for _, tc := range []struct {
+                       name    string
+                       value   any
+                       present bool
+                       mixed   bool
+               }{
+                       {name: "missing"},
+                       {name: "null", present: true},
+                       {name: "one-simplified", value: "acme", present: true},
+                       {name: "mixed", mixed: true},
+               } {
+                       b.Run(fmt.Sprintf("predicates=%d/%s/files=4096", 
predicateCount, tc.name), func(b *testing.B) {
+                               partitions := make([]map[int]any, 4096)
+                               for i := range partitions {
+                                       partitions[i] = make(map[int]any, 1)
+                                       if tc.mixed {
+                                               switch i % 4 {
+                                               case 1:
+                                                       partitions[i][1000] = 
nil
+                                               case 2:
+                                                       partitions[i][1000] = 
"acme"
+                                               case 3:
+                                                       partitions[i][1000] = 
"other"
+                                               }
+                                       } else if tc.present {
+                                               partitions[i][1000] = tc.value
+                                       }
+                               }
+
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for b.Loop() {
+                                       for _, partition := range partitions {
+                                               residual, _, err := 
evaluator.residual(partition)
+                                               if err != nil {
+                                                       b.Fatal(err)
+                                               }
+                                               partitionResidualBenchmarkSink 
= residual
+                                       }
+                               }
+                               b.StopTimer()
+                               b.ReportMetric(float64(len(partitions)), 
"files/op")
+                       })
+               }
+       }
+}
+
+func benchmarkBalancedPartitionResidualAnd(predicates 
[]iceberg.BooleanExpression) iceberg.BooleanExpression {
+       if len(predicates) == 1 {
+               return predicates[0]
+       }
+       mid := len(predicates) / 2
+
+       return iceberg.NewAnd(
+               benchmarkBalancedPartitionResidualAnd(predicates[:mid]),
+               benchmarkBalancedPartitionResidualAnd(predicates[mid:]),
+       )
+}
diff --git a/table/partition_residual_reuse_test.go 
b/table/partition_residual_reuse_test.go
new file mode 100644
index 000000000..914eac4ea
--- /dev/null
+++ b/table/partition_residual_reuse_test.go
@@ -0,0 +1,130 @@
+// 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 (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/require"
+)
+
+func TestPartitionResidualEvaluatorReusesUnchangedSubtrees(t *testing.T) {
+       schema := partitionResidualTestSchema()
+       spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "tenant_id", 
Transform: iceberg.IdentityTransform{},
+       })
+
+       tenant := iceberg.EqualTo(iceberg.Reference("tenant_id"), "acme")
+       amount := iceberg.Reference("amount")
+       opaque := iceberg.NewAnd(
+               iceberg.NewNot(iceberg.GreaterThan(amount, int64(100))),
+               iceberg.NewOr(
+                       iceberg.LessThan(amount, int64(20)),
+                       iceberg.GreaterThan(amount, int64(200)),
+               ),
+       )
+
+       tests := []struct {
+               name           string
+               filter         iceberg.BooleanExpression
+               partitionValue string
+               want           iceberg.BooleanExpression
+       }{
+               {name: "and", filter: iceberg.NewAnd(tenant, opaque), 
partitionValue: "acme", want: opaque},
+               {name: "and-right", filter: iceberg.NewAnd(opaque, tenant), 
partitionValue: "acme", want: opaque},
+               {name: "or", filter: iceberg.NewOr(tenant, opaque), 
partitionValue: "other", want: opaque},
+               {name: "or-right", filter: iceberg.NewOr(opaque, tenant), 
partitionValue: "other", want: opaque},
+               {name: "not", filter: iceberg.NewNot(iceberg.NewOr(tenant, 
opaque)), partitionValue: "other", want: iceberg.NewNot(opaque)},
+               {name: "not-right", filter: 
iceberg.NewNot(iceberg.NewOr(opaque, tenant)), partitionValue: "other", want: 
iceberg.NewNot(opaque)},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       evaluator := boundPartitionResidualEvaluator(t, schema, 
spec, tt.filter)
+                       want, err := iceberg.BindExpr(schema, tt.want, true)
+                       require.NoError(t, err)
+
+                       got, changed, err := 
evaluator.residual(map[int]any{1000: tt.partitionValue})
+                       require.NoError(t, err)
+                       require.True(t, changed)
+                       require.NotNil(t, got)
+                       require.True(t, got.Equals(want), "expected %s, got 
%s", want, got)
+
+                       for _, partition := range []map[int]any{{}, {1000: 
nil}} {
+                               got, changed, err = 
evaluator.residual(partition)
+                               require.NoError(t, err)
+                               require.False(t, changed)
+                               require.Nil(t, got)
+                       }
+                       got, changed, err = 
evaluator.residual(map[int]any{1000: tt.partitionValue})
+                       require.NoError(t, err)
+                       require.True(t, changed)
+                       require.True(t, got.Equals(want), "interleaved 
unchanged files must not affect later residuals")
+               })
+       }
+}
+
+func TestPartitionResidualEvaluatorUnchangedSubtreeAllocations(t *testing.T) {
+       schema := partitionResidualTestSchema()
+       spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "tenant_id", 
Transform: iceberg.IdentityTransform{},
+       })
+       tenant := iceberg.EqualTo(iceberg.Reference("tenant_id"), "acme")
+       opaque := iceberg.NewOr(
+               iceberg.GreaterThan(iceberg.Reference("amount"), int64(100)),
+               iceberg.NewNot(iceberg.LessThan(iceberg.Reference("amount"), 
int64(20))),
+       )
+
+       for _, tt := range []struct {
+               name   string
+               filter iceberg.BooleanExpression
+       }{
+               {name: "and", filter: iceberg.NewAnd(tenant, opaque)},
+               {name: "or", filter: iceberg.NewOr(tenant, opaque)},
+               {name: "not", filter: iceberg.NewNot(iceberg.NewOr(tenant, 
opaque))},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       evaluator := boundPartitionResidualEvaluator(t, schema, 
spec, tt.filter)
+                       for _, tc := range []struct {
+                               name      string
+                               partition map[int]any
+                       }{
+                               {name: "missing", partition: map[int]any{}},
+                               {name: "null", partition: map[int]any{1000: 
nil}},
+                       } {
+                               t.Run(tc.name, func(t *testing.T) {
+                                       // The node must retain the expression 
it built at
+                                       // evaluator creation instead of 
allocating a new tree.
+                                       nodeExpr, changed, err := 
evaluator.root.residual(
+                                               
borrowedPartitionRecord{partition: tc.partition, partitionType: 
evaluator.partitionType})
+                                       require.NoError(t, err)
+                                       require.False(t, changed)
+                                       require.True(t, 
nodeExpr.Equals(evaluator.root.expr))
+
+                                       allocs := testing.AllocsPerRun(100, 
func() {
+                                               residual, changed, err := 
evaluator.residual(tc.partition)
+                                               if err != nil || changed || 
residual != nil {
+                                                       panic("unchanged 
partition residual must be nil without an error")
+                                               }
+                                       })
+                                       require.Zero(t, allocs)
+                               })
+                       }
+               })
+       }
+}

Reply via email to