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 379dbf7d3 perf(table): match single-column equality deletes per 
dictionary entry (#2442)
379dbf7d3 is described below

commit 379dbf7d3d9ae16d592e0d7cef553b8a9f8e14de
Author: Harrison Crosse <[email protected]>
AuthorDate: Mon Nov 9 12:24:04 2026 -0500

    perf(table): match single-column equality deletes per dictionary entry 
(#2442)
    
    When a single string or binary equality key column arrives as a dictionary
    no larger than the batch, match each dictionary entry once and map rows
    through their indices instead of encoding a key per row.
    
    Signed-off-by: Harrison Crosse <[email protected]>
---
 table/equality_delete_bytes_fastpath_test.go | 134 ++++++++++++++++++++++++++-
 table/equality_delete_reader.go              |  46 ++++++++-
 2 files changed, 176 insertions(+), 4 deletions(-)

diff --git a/table/equality_delete_bytes_fastpath_test.go 
b/table/equality_delete_bytes_fastpath_test.go
index fdf27edd3..3cad54d36 100644
--- a/table/equality_delete_bytes_fastpath_test.go
+++ b/table/equality_delete_bytes_fastpath_test.go
@@ -19,6 +19,7 @@ package table
 import (
        "bytes"
        "fmt"
+       "strings"
        "testing"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -170,6 +171,100 @@ func 
TestProcessEqualityDeletesNestedStringDoesNotBuildUnusedIndex(t *testing.T)
        assert.Nil(t, deleteSet.singleBytes)
 }
 
+func TestProcessEqualityDeletesSingleBytesDictionary(t *testing.T) {
+       tests := []struct {
+               name       string
+               dictionary []string
+               indices    string
+               offset     int
+               key        string
+               wantFast   bool
+               wantIDs    []int64
+       }{
+               {
+                       name: "no_match", dictionary: []string{"apple", "bob", 
"cherry"}, indices: "[0, 1, 2, 0, 1, 2]",
+                       key: "\x01\x00\x00\x00\x03zzz", wantFast: true, 
wantIDs: []int64{1, 2, 3, 4, 5, 6},
+               },
+               {
+                       name: "null_index_without_null_entry", dictionary: 
[]string{"apple", "bob"}, indices: "[0, null, 1, null, 0, 1]",
+                       key: "\x00", wantFast: true, wantIDs: []int64{1, 3, 5, 
6},
+               },
+               {
+                       name: "empty_dictionary_null_key", dictionary: 
[]string{}, indices: "[null, null, null]",
+                       key: "\x00", wantFast: true, wantIDs: nil,
+               },
+               {
+                       name: "empty_dictionary_without_null_key", dictionary: 
[]string{}, indices: "[null, null, null]",
+                       key: "\x01\x00\x00\x00\x05apple", wantFast: true, 
wantIDs: []int64{1, 2, 3},
+               },
+               {
+                       name: "sliced", dictionary: []string{"apple", "bob", 
"cherry"}, indices: "[0, 1, 2, 0, 1, 2]", offset: 2,
+                       key: "\x01\x00\x00\x00\x05apple", wantFast: true, 
wantIDs: []int64{3, 5, 6},
+               },
+               {
+                       name: "dictionary_larger_than_batch", dictionary: 
[]string{"apple", "bob", "cherry", "date"}, indices: "[0, 3]",
+                       key: "\x01\x00\x00\x00\x05apple", wantFast: false, 
wantIDs: []int64{2},
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       mem := 
memory.NewCheckedAllocator(memory.DefaultAllocator)
+                       defer mem.AssertSize(t, 0)
+                       ctx := compute.WithAllocator(t.Context(), mem)
+
+                       valueBuilder := array.NewStringBuilder(mem)
+                       valueBuilder.AppendValues(tt.dictionary, nil)
+                       dictionary := valueBuilder.NewArray()
+                       valueBuilder.Release()
+                       defer dictionary.Release()
+                       indices, _, err := array.FromJSON(mem, 
arrow.PrimitiveTypes.Int16, strings.NewReader(tt.indices))
+                       require.NoError(t, err)
+                       defer indices.Release()
+                       dictType := &arrow.DictionaryType{IndexType: 
arrow.PrimitiveTypes.Int16, ValueType: arrow.BinaryTypes.String}
+                       values := array.NewDictionaryArray(dictType, indices, 
dictionary)
+                       defer values.Release()
+                       idBuilder := array.NewInt64Builder(mem)
+                       for id := range values.Len() {
+                               idBuilder.Append(int64(id + 1))
+                       }
+                       ids := idBuilder.NewArray()
+                       idBuilder.Release()
+                       defer ids.Release()
+
+                       schema := arrow.NewSchema([]arrow.Field{
+                               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+                               {Name: "value", Type: dictType, Nullable: true},
+                       }, nil)
+                       full := array.NewRecordBatch(schema, []arrow.Array{ids, 
values}, int64(values.Len()))
+                       defer full.Release()
+                       record := full.NewSlice(int64(tt.offset), 
full.NumRows())
+                       defer record.Release()
+
+                       deleteSet := &equalityDeleteSet{keys: 
set[string]{tt.key: {}}, fieldIDs: []int{2}, colNames: []string{"value"}}
+                       fileSchema := iceberg.NewSchema(0,
+                               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+                               iceberg.NestedField{ID: 2, Name: "value", Type: 
iceberg.PrimitiveTypes.String},
+                       )
+                       process, err := 
processEqualityDeletesColumnarForFile(ctx,
+                               []*equalityDeleteSet{deleteSet}, fileSchema, 
"data.parquet")
+                       require.NoError(t, err)
+
+                       record.Retain()
+                       got, err := process(record)
+                       require.NoError(t, err)
+                       defer got.Release()
+                       assert.Equal(t, tt.wantIDs, 
got.Column(0).(*array.Int64).Int64Values())
+                       if len(tt.wantIDs) == int(record.NumRows()) {
+                               assert.Same(t, record, got)
+                       }
+
+                       var mask equalityDeleteMask
+                       defer mask.release()
+                       assert.Equal(t, tt.wantFast, 
mask.applySingleBytes(record.Column(1), deleteSet.singleBytes, mem))
+               })
+       }
+}
+
 func buildBenchDeleteSetSingleString(numDeletes int, noMatch bool) 
*equalityDeleteSet {
        keys := make(set[string], numDeletes)
        var buf bytes.Buffer
@@ -192,7 +287,9 @@ func buildBenchDeleteSetSingleString(numDeletes int, 
noMatch bool) *equalityDele
        }
 }
 
-func BenchmarkProcessEqualityDeletesSingleString(b *testing.B) {
+func benchSingleStringEqualityDeletes(b *testing.B, buildRec 
func(memory.Allocator, int) arrow.RecordBatch) {
+       b.Helper()
+
        for _, noMatch := range []bool{false, true} {
                name := "matches"
                if noMatch {
@@ -205,7 +302,7 @@ func BenchmarkProcessEqualityDeletesSingleString(b 
*testing.B) {
                                        mode = "fast"
                                }
                                b.Run(mode, func(b *testing.B) {
-                                       benchEqDeletesForFile(b, 
buildBenchRecordString, func(n int) *equalityDeleteSet {
+                                       benchEqDeletesForFile(b, buildRec, 
func(n int) *equalityDeleteSet {
                                                deleteSet := 
buildBenchDeleteSetSingleString(n, noMatch)
                                                if !fast {
                                                        
deleteSet.singleBytesOnce.Do(func() {})
@@ -218,3 +315,36 @@ func BenchmarkProcessEqualityDeletesSingleString(b 
*testing.B) {
                })
        }
 }
+
+func BenchmarkProcessEqualityDeletesSingleString(b *testing.B) {
+       benchSingleStringEqualityDeletes(b, buildBenchRecordString)
+}
+
+// 1,000 distinct names keep the dictionary no larger than the smallest
+// benchmark batch, so the dictionary path always applies.
+func buildBenchRecordStringDictionary(mem memory.Allocator, numRows int) 
arrow.RecordBatch {
+       dictType := &arrow.DictionaryType{IndexType: 
arrow.PrimitiveTypes.Int32, ValueType: arrow.BinaryTypes.String}
+       schema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+               {Name: "name", Type: dictType},
+       }, nil)
+
+       bldr := array.NewRecordBuilder(mem, schema)
+       defer bldr.Release()
+
+       idBldr := bldr.Field(0).(*array.Int64Builder)
+       nameBldr := bldr.Field(1).(*array.BinaryDictionaryBuilder)
+
+       for i := range numRows {
+               idBldr.Append(int64(i))
+               if err := nameBldr.AppendString(fmt.Sprintf("user-%08d", 
i%1000)); err != nil {
+                       panic(err)
+               }
+       }
+
+       return bldr.NewRecordBatch()
+}
+
+func BenchmarkProcessEqualityDeletesSingleStringDictionary(b *testing.B) {
+       benchSingleStringEqualityDeletes(b, buildBenchRecordStringDictionary)
+}
diff --git a/table/equality_delete_reader.go b/table/equality_delete_reader.go
index f1d051df2..941e65abe 100644
--- a/table/equality_delete_reader.go
+++ b/table/equality_delete_reader.go
@@ -1592,8 +1592,8 @@ func applySingleBytesValues[Offset ~int32 | ~int64](
        }
 }
 
-// applySingleBytes avoids length-prefix serialization for flat string/binary
-// columns, including their large-offset Arrow variants.
+// applySingleBytes avoids length-prefix serialization for string/binary
+// columns, including their large-offset and dictionary Arrow variants.
 func (m *equalityDeleteMask) applySingleBytes(
        column arrow.Array,
        deleteSet *singleBytesEqualityDeleteSet,
@@ -1608,6 +1608,8 @@ func (m *equalityDeleteMask) applySingleBytes(
                applySingleBytesValues(m, column, values.ValueOffsets(), 
values.ValueBytes(), deleteSet, mem)
        case *array.LargeBinary:
                applySingleBytesValues(m, column, values.ValueOffsets(), 
values.ValueBytes(), deleteSet, mem)
+       case *array.Dictionary:
+               return m.applySingleBytesDictionary(values, deleteSet, mem)
        default:
                return false
        }
@@ -1615,6 +1617,46 @@ func (m *equalityDeleteMask) applySingleBytes(
        return true
 }
 
+// applySingleBytesDictionary returns false when the dictionary is larger than
+// the batch to avoid checking more dictionary entries than rows.
+func (m *equalityDeleteMask) applySingleBytesDictionary(
+       column *array.Dictionary,
+       deleteSet *singleBytesEqualityDeleteSet,
+       mem memory.Allocator,
+) bool {
+       dict := column.Dictionary()
+       numRows := column.Len()
+       if dict.Len() > numRows {
+               return false
+       }
+
+       var entryMask equalityDeleteMask
+       defer entryMask.release()
+       if !entryMask.applySingleBytes(dict, deleteSet, mem) {
+               return false
+       }
+       if entryMask.bits == nil && (!deleteSet.hasNull || column.NullN() == 0) 
{
+               return true
+       }
+
+       for row := range numRows {
+               if m.bits != nil && !bitutil.BitIsSet(m.bits, row) {
+                       continue
+               }
+               if column.IsNull(row) {
+                       if !deleteSet.hasNull {
+                               continue
+                       }
+               } else if entryMask.bits == nil || 
bitutil.BitIsSet(entryMask.bits, column.GetValueIndex(row)) {
+                       continue
+               }
+               m.ensure(mem, numRows)
+               bitutil.ClearBit(m.bits, row)
+       }
+
+       return true
+}
+
 // applySingleInt32 returns false for other physical Arrow types, so callers
 // can preserve the generic key encoder for schema evolution.
 func (m *equalityDeleteMask) applySingleInt32(

Reply via email to