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 2ecacb874 perf(table): build deletion-vector masks per affected batch 
(#2211)
2ecacb874 is described below

commit 2ecacb8742479f96c215883e0b3d555cae412ffe
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 19 21:59:31 2026 +0200

    perf(table): build deletion-vector masks per affected batch (#2211)
    
    * perf(table): filter deletion vectors with batch-local keep masks
    
    * style: satisfy lint in sparse deletion-vector masks
    
    * style: satisfy lint in sparse deletion-vector masks
    
    * style: satisfy lint in sparse deletion-vector masks
    
    * style: finish nlreturn cleanup in sparse DV scan
    
    * style: finish nlreturn cleanup in sparse DV scan
---
 table/arrow_scanner.go                      |  89 ++++++++++++------------
 table/dv/roaring_bitmap.go                  |  54 +++++++++++++++
 table/dv/roaring_bitmap_range_bench_test.go |  54 +++++++++++++++
 table/dv/roaring_bitmap_range_test.go       | 102 ++++++++++++++++++++++++++++
 table/dv_scan_sparse_mask_test.go           | 102 ++++++++++++++++++++++++++++
 5 files changed, 357 insertions(+), 44 deletions(-)

diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index 98556f2f4..b417dad6e 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -1335,66 +1335,55 @@ func processPositionalDeletes(ctx context.Context, 
deletes set[int64], cursor *r
        }
 }
 
-func deletionVectorKeepsRow(keepBits []byte, rowCount, pos int64) bool {
-       return pos >= rowCount || keepBits[pos>>3]&(1<<(uint(pos)&7)) != 0
+func deletionVectorKeepsRow(bitmap *dv.RoaringPositionBitmap, rowCount, pos 
int64) bool {
+       return pos >= rowCount || pos < 0 || !bitmap.Contains(uint64(pos))
 }
 
-// filterByDeletionVector returns a pipeline step that drops rows present in
-// the bitmap by precomputing a bit-packed keep-mask covering the whole file
-// once and slicing the relevant range per batch into 
compute.FilterRecordBatch.
-//
-// The mask layout matches Arrow's Boolean buffer convention (LSB-first per
-// byte, little-endian word order), so dv.KeepMaskBytes -> 
memory.NewBufferBytes
-// -> array.NewBoolean is a zero-copy wrap. The keepBits slice is Go-allocated
-// (GC-friendly) and shared across every per-batch Boolean array; each
-// array.NewBoolean / array.NewSlice pair is released after the batch.
-//
-// rowCount bounds the mask to the data file's row count. cursor maps each 
batch
-// row to its original file position: when no row group was pruned the 
positions
-// are contiguous and the mask is sliced zero-copy; when pruning skipped groups
-// the emitted rows are non-contiguous, so the keep bits are gathered per row 
at
-// the cursor's positions.
+// filterByDeletionVector drops deleted rows without materializing a keep-mask
+// for the whole file. Contiguous batches use a bit-packed mask for only their
+// affected positions; clean batches are returned unchanged. Pruned row groups
+// use the position cursor and build a mask only after finding a deleted row.
+// Positions beyond a stale manifest row count are retained.
 func filterByDeletionVector(ctx context.Context, bitmap 
*dv.RoaringPositionBitmap, rowCount int64, cursor *rowPositionCursor) 
recProcessFn {
        nextIdx := int64(0)
-       keepBits := bitmap.KeepMaskBytes(rowCount)
-       buf := memory.NewBufferBytes(keepBits)
        mem := compute.GetAllocator(ctx)
 
        return func(r arrow.RecordBatch) (arrow.RecordBatch, error) {
                defer r.Release()
 
                nrows := r.NumRows()
-
                if !cursor.src.pruned() {
                        currentIdx := nextIdx
                        nextIdx += nrows
 
-                       if nextIdx <= rowCount {
-                               // Wrap (and slice) the shared keep-mask buffer 
for this batch.
-                               // array.NewSlice on a Boolean array tracks the 
bit-level offset,
-                               // so we don't need byte-aligned slicing — 
currentIdx can land
-                               // anywhere within a byte.
-                               full := array.NewBoolean(int(rowCount), buf, 
nil, 0)
-                               defer full.Release()
-                               sliced := array.NewSlice(full, currentIdx, 
nextIdx).(*array.Boolean)
-                               defer sliced.Release()
-
-                               return compute.FilterRecordBatch(ctx, r, 
sliced, compute.DefaultFilterOptions())
-                       }
                        if currentIdx >= rowCount {
                                r.Retain()
 
                                return r, nil
                        }
 
-                       // A stale manifest count can be smaller than the rows 
emitted by
-                       // the file. Preserve rows beyond the mask rather than 
slicing past
-                       // its bounds and panicking.
+                       maskEnd := min(nextIdx, rowCount)
+                       keepBits := bitmap.KeepMaskForRange(uint64(currentIdx), 
uint64(maskEnd))
+                       if keepBits == nil {
+                               r.Retain()
+
+                               return r, nil
+                       }
+
+                       if nextIdx <= rowCount {
+                               mask := array.NewBoolean(int(nrows), 
memory.NewBufferBytes(keepBits), nil, 0)
+                               defer mask.Release()
+
+                               return compute.FilterRecordBatch(ctx, r, mask, 
compute.DefaultFilterOptions())
+                       }
+
+                       // A stale row count can end inside this batch. Leave 
the
+                       // out-of-range suffix untouched rather than slicing 
past it.
                        bldr := array.NewBooleanBuilder(mem)
                        defer bldr.Release()
                        bldr.Reserve(int(nrows))
                        for pos := currentIdx; pos < nextIdx; pos++ {
-                               bldr.Append(deletionVectorKeepsRow(keepBits, 
rowCount, pos))
+                               bldr.Append(deletionVectorKeepsRow(bitmap, 
rowCount, pos))
                        }
                        mask := bldr.NewBooleanArray()
                        defer mask.Release()
@@ -1402,16 +1391,28 @@ func filterByDeletionVector(ctx context.Context, bitmap 
*dv.RoaringPositionBitma
                        return compute.FilterRecordBatch(ctx, r, mask, 
compute.DefaultFilterOptions())
                }
 
-               bldr := array.NewBooleanBuilder(mem)
-               defer bldr.Release()
-               bldr.Reserve(int(nrows))
-               for range nrows {
+               var bldr *array.BooleanBuilder
+               for i := range nrows {
                        pos := cursor.next()
-                       // keepBits is sized for rowCount; a pos past it means 
row-group
-                       // metadata and the manifest's File.Count() disagree. 
Keep the row
-                       // rather than indexing out of bounds.
-                       bldr.Append(deletionVectorKeepsRow(keepBits, rowCount, 
pos))
+                       keep := deletionVectorKeepsRow(bitmap, rowCount, pos)
+                       if bldr == nil {
+                               if keep {
+                                       continue
+                               }
+                               bldr = array.NewBooleanBuilder(mem)
+                               bldr.Reserve(int(nrows))
+                               for range i {
+                                       bldr.Append(true)
+                               }
+                       }
+                       bldr.Append(keep)
+               }
+               if bldr == nil {
+                       r.Retain()
+
+                       return r, nil
                }
+               defer bldr.Release()
                mask := bldr.NewBooleanArray()
                defer mask.Release()
 
diff --git a/table/dv/roaring_bitmap.go b/table/dv/roaring_bitmap.go
index 0a72d4085..5f5a81909 100644
--- a/table/dv/roaring_bitmap.go
+++ b/table/dv/roaring_bitmap.go
@@ -348,3 +348,57 @@ func (b *RoaringPositionBitmap) KeepMaskBytes(length 
int64) []byte {
 
        return out
 }
+
+// KeepMaskForRange returns a bit-packed Arrow Boolean keep-mask for [start, 
end).
+// A nil result means the range contains no deletes. Only an affected range
+// allocates a mask, so a scan does not need one bit for every row in the file.
+// The mask is indexed relative to start, including across 32-bit buckets.
+func (b *RoaringPositionBitmap) KeepMaskForRange(start, end uint64) []byte {
+       if start >= end {
+               return nil
+       }
+
+       var out []byte
+       for key, bm := range b.bitmaps {
+               base := uint64(key) << 32
+               if end <= base {
+                       continue
+               }
+
+               lo := uint64(0)
+               if start > base {
+                       lo = start - base
+                       if lo >= 1<<32 {
+                               continue
+                       }
+               }
+               hi := min(end-base, uint64(1)<<32)
+               if lo >= hi || bm.CardinalityInRange(lo, hi) == 0 {
+                       continue
+               }
+
+               if out == nil {
+                       out = make([]byte, 
bitutil.BytesForBits(int64(end-start)))
+                       memory.Set(out, 0xFF)
+               }
+
+               it := bm.Iterator()
+               it.AdvanceIfNeeded(uint32(lo))
+               for it.HasNext() {
+                       pos := uint64(it.Next())
+                       if pos >= hi {
+                               break
+                       }
+                       offset := base + pos - start
+                       out[offset>>3] &^= byte(1 << (offset & 7))
+               }
+       }
+
+       if out != nil {
+               if pad := int64(len(out))*8 - int64(end-start); pad > 0 {
+                       bitutil.SetBitsTo(out, int64(end-start), pad, false)
+               }
+       }
+
+       return out
+}
diff --git a/table/dv/roaring_bitmap_range_bench_test.go 
b/table/dv/roaring_bitmap_range_bench_test.go
new file mode 100644
index 000000000..f63cfe42e
--- /dev/null
+++ b/table/dv/roaring_bitmap_range_bench_test.go
@@ -0,0 +1,54 @@
+// 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 dv
+
+import "testing"
+
+var keepMaskBenchmarkSink []byte
+
+func BenchmarkKeepMaskBatchRange(b *testing.B) {
+       const (
+               fileRows  int64  = 10_000_000
+               start     uint64 = 4_000_000
+               batchSize uint64 = 65_536
+       )
+       bitmap := NewRoaringPositionBitmap()
+       for i := range uint64(16) {
+               bitmap.Set(start + 1 + i*2_048)
+       }
+       bitmap.Set(uint64(fileRows - 1))
+
+       b.Run("full_file", func(b *testing.B) {
+               b.ReportAllocs()
+               for b.Loop() {
+                       keepMaskBenchmarkSink = bitmap.KeepMaskBytes(fileRows)
+               }
+       })
+       b.Run("affected_batch", func(b *testing.B) {
+               b.ReportAllocs()
+               for b.Loop() {
+                       keepMaskBenchmarkSink = bitmap.KeepMaskForRange(start, 
start+batchSize)
+               }
+       })
+       b.Run("clean_batch", func(b *testing.B) {
+               b.ReportAllocs()
+               for b.Loop() {
+                       keepMaskBenchmarkSink = bitmap.KeepMaskForRange(0, 
batchSize)
+               }
+       })
+}
diff --git a/table/dv/roaring_bitmap_range_test.go 
b/table/dv/roaring_bitmap_range_test.go
new file mode 100644
index 000000000..bdb8171b0
--- /dev/null
+++ b/table/dv/roaring_bitmap_range_test.go
@@ -0,0 +1,102 @@
+// 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 dv
+
+import (
+       "math/rand"
+       "testing"
+)
+
+func TestKeepMaskForRange(t *testing.T) {
+       const boundary uint64 = 1 << 32
+
+       for _, tc := range []struct {
+               name      string
+               start     uint64
+               length    uint64
+               positions []uint64
+       }{
+               {name: "empty range", start: 100, positions: []uint64{100}},
+               {name: "clean range", start: 100, length: 9, positions: 
[]uint64{99, 109}},
+               {name: "unaligned offset", start: 5, length: 9, positions: 
[]uint64{5, 7, 11, 13}},
+               {name: "one byte", start: 10, length: 8, positions: 
[]uint64{10, 17}},
+               {name: "across buckets", start: boundary - 3, length: 9, 
positions: []uint64{boundary - 1, boundary, boundary + 3}},
+               {name: "high bucket", start: (1 << 63) + 11, length: 11, 
positions: []uint64{(1 << 63) + 12, (1 << 63) + 20}},
+               {name: "dense", start: 101, length: 25},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       bitmap := NewRoaringPositionBitmap()
+                       for _, pos := range tc.positions {
+                               bitmap.Set(pos)
+                       }
+                       if tc.name == "dense" {
+                               bitmap.SetRange(100, 128)
+                               bitmap.RunLengthEncode()
+                       }
+
+                       assertKeepMaskForRange(t, bitmap, tc.start, tc.length)
+               })
+       }
+}
+
+func TestKeepMaskForRangeRandomized(t *testing.T) {
+       random := rand.New(rand.NewSource(27))
+       for _, base := range []uint64{0, 31, (1 << 32) - 50, 1 << 32, (3 << 32) 
+ 50} {
+               for _, length := range []uint64{0, 1, 7, 8, 9, 63, 64, 127} {
+                       bitmap := NewRoaringPositionBitmap()
+                       for range 80 {
+                               bitmap.Set(base + uint64(random.Intn(150)))
+                       }
+                       assertKeepMaskForRange(t, bitmap, base, length)
+               }
+       }
+}
+
+func assertKeepMaskForRange(t *testing.T, bitmap *RoaringPositionBitmap, 
start, length uint64) {
+       t.Helper()
+       got := bitmap.KeepMaskForRange(start, start+length)
+       hasDelete := false
+       for i := range length {
+               if bitmap.Contains(start + i) {
+                       hasDelete = true
+
+                       break
+               }
+       }
+       if !hasDelete {
+               if got != nil {
+                       t.Fatalf("clean range [%d, %d): expected nil mask, got 
%v", start, start+length, got)
+               }
+
+               return
+       }
+       if want := int((length + 7) / 8); len(got) != want {
+               t.Fatalf("mask length = %d, want %d", len(got), want)
+       }
+       for i := range length {
+               keep := got[i>>3]&(1<<(i&7)) != 0
+               if want := !bitmap.Contains(start + i); keep != want {
+                       t.Errorf("position %d: keep = %t, want %t", start+i, 
keep, want)
+               }
+       }
+       for i := length; i < uint64(len(got)*8); i++ {
+               if got[i>>3]&(1<<(i&7)) != 0 {
+                       t.Errorf("padding bit %d not cleared", i)
+               }
+       }
+}
diff --git a/table/dv_scan_sparse_mask_test.go 
b/table/dv_scan_sparse_mask_test.go
new file mode 100644
index 000000000..d140dae0e
--- /dev/null
+++ b/table/dv_scan_sparse_mask_test.go
@@ -0,0 +1,102 @@
+// 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 (
+       "context"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go/table/dv"
+       "github.com/apache/iceberg-go/table/internal"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestDeletionVectorHugeFileCleanBatch(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       bitmap := dv.NewRoaringPositionBitmap()
+       bitmap.Set(1 << 39)
+       ctx := compute.WithAllocator(context.Background(), mem)
+       filter := filterByDeletionVector(ctx, bitmap, 1<<40, 
(&rowPositionSource{}).cursor())
+
+       in := checkedInt64RecordBatch(mem, 0, 1, 2)
+       out, err := filter(in)
+       require.NoError(t, err)
+       assert.Same(t, in, out, "a clean batch should pass through")
+       out.Release()
+}
+
+func TestDeletionVectorSkipsCleanBatches(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       bitmap := dv.NewRoaringPositionBitmap()
+       bitmap.Set(4)
+       ctx := compute.WithAllocator(context.Background(), mem)
+       filter := filterByDeletionVector(ctx, bitmap, 9, 
(&rowPositionSource{}).cursor())
+
+       for i, test := range []struct {
+               values []int64
+               want   []int64
+               clean  bool
+       }{
+               {values: []int64{0, 1, 2}, want: []int64{0, 1, 2}, clean: true},
+               {values: []int64{3, 4, 5}, want: []int64{3, 5}},
+               {values: []int64{6, 7, 8}, want: []int64{6, 7, 8}, clean: true},
+       } {
+               in := checkedInt64RecordBatch(mem, test.values...)
+               out, err := filter(in)
+               require.NoError(t, err, "batch %d", i)
+               if test.clean {
+                       assert.Same(t, in, out, "batch %d", i)
+               }
+               assert.Equal(t, test.want, 
out.Column(0).(*array.Int64).Int64Values())
+               out.Release()
+       }
+}
+
+func TestDeletionVectorPrunedBatchSkipsCleanRows(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       bitmap := dv.NewRoaringPositionBitmap()
+       bitmap.Set(53)
+       ctx := compute.WithAllocator(context.Background(), mem)
+       source := &rowPositionSource{spans: []internal.RowGroupSpan{
+               {FirstRowPos: 40, NumRows: 3},
+               {FirstRowPos: 52, NumRows: 3},
+       }}
+       filter := filterByDeletionVector(ctx, bitmap, 100, source.cursor())
+
+       first := checkedInt64RecordBatch(mem, 40, 41, 42)
+       firstOut, err := filter(first)
+       require.NoError(t, err)
+       assert.Same(t, first, firstOut)
+       firstOut.Release()
+
+       second := checkedInt64RecordBatch(mem, 52, 53, 54)
+       secondOut, err := filter(second)
+       require.NoError(t, err)
+       assert.Equal(t, []int64{52, 54}, 
secondOut.Column(0).(*array.Int64).Int64Values())
+       secondOut.Release()
+}

Reply via email to