JeonDaehong opened a new issue, #18026:
URL: https://github.com/apache/iceberg/issues/18026

   ### Feature Request / Improvement
   
   
   ### Summary
   
   
   `ColumnarBatchUtil.buildRowIdMapping` and `buildIsDeleted` call 
`PositionDeleteIndex.isDeleted(pos)` once for every row in a batch. Positions 
within a batch are a contiguous ascending range and the DV-backed index is a 
Roaring bitmap, so the same information can be obtained with a single range 
traversal instead of `batchSize` independent probes.
   
   On a V3 table read with a narrow projection, this loop accounts for **43–58% 
of scan CPU**, depending on delete density — a figure I have since reproduced 
on three CPU microarchitectures (Zen 3, Sapphire Rapids, Graviton3), on local 
disk and on S3, with a warm and a dropped page cache, and at task parallelism 1 
through 16. It moves by at most a few points across all of those, including 
with a heavy aggregation in the same job. Two things do move it: on a small 
distributed cluster it comes out at **42%** (the top of the range is a 
single-JVM figure — executor startup and serialization enlarge the 
denominator), and on a table split into many small files it drops by about **11 
points** (per-file CPU — footer parsing and one DV load per file — also 
enlarges the denominator). I implemented the change against 
`apache-iceberg-1.11.0` and measured it end to end across delete densities from 
0.5% to 50%: the delete-check CPU drops by **2.6x–9.3x** for 
`buildRowIdMapping` and **1
 4.9x–18.9x** for `buildIsDeleted`, and the scan subtree as a whole drops by 
**12–45%**. Tables with equality deletes keep the existing loop and show no 
regression.
   
   There is also something users can do today, without waiting for this: 
**sorting the table by the column the deletes target makes the delete check 
2.8x cheaper on its own**, because the Roaring bitmap switches from array to 
run containers. That only works when the deletes concentrate on relatively few 
distinct key values — I measured it fading to 1.1x and then to nothing as the 
sort key's cardinality rises. The two remedies overlap but do not replace each 
other: sorting alone 2.8x, this patch alone 7.8x, both together 12.4x.
   
   How much of that reaches end-to-end query time depends on how much work the 
rest of the scan does. The threshold is not a column count — it is the share 
the delete check holds in scan CPU. Above roughly 20% the change is worth 
**13–19% of end-to-end query time**; below it the difference falls inside my 
measurement noise, even though the delete-check CPU is still reduced 3.0x–6.7x 
there. The change removes the same absolute CPU either way.
   
   As a unit that transfers to other schemas: **one delete check costs about as 
much as decoding 1.6–3.2 fixed-width integer columns**, while a single 32-char 
string column costs 8–10 integer columns. On my table a projection of ten 
integer columns still shows the effect and a projection of three md5 strings 
does not — so a column count is the wrong thing to quote. One caveat on reading 
any CPU figure below as latency: across 24 configurations only about half of a 
profiler-measured CPU saving arrives as wall clock (regression slope 0.53).
   
   `RoaringBitmap` already provides the range APIs needed (`forEachInRange`, 
`forAllInRange`, `rangeCardinality`), and they are present in the version 
Iceberg pins (`1.6.14`), including the shaded copy in `iceberg-spark-runtime`.
   
   I have a working branch with the change, updated tests, and the measurements 
below. Happy to open a PR if the direction sounds reasonable.
   
   
   ### Where
   
   
   
`spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchUtil.java`
 (same shape in v3.5/v4.1/v4.2):
   
   ```java
   // :57 buildRowIdMapping
   PositionDeleteIndex deletedPositions = deletes.deletedRowPositions();   // 
:66
   for (int rowId = 0; rowId < batchSize; rowId++) {                       // 
:72
     long pos = rowStartPosInBatch + rowId;
     ...
     if (isDeleted(pos, row, deletedPositions, eqDeleteFilter)) { ... }
   }
   
   // :110 buildIsDeleted — same loop shape at :125
   
   // :137
   private static boolean isDeleted(...) {
     if (deletedPositions != null && deletedPositions.isDeleted(pos)) {     // 
:143
   ```
   
   
   ### Why it costs what it does
   
   
   `isDeleted(pos)` resolves to `BitmapPositionDeleteIndex.isDeleted` → 
`RoaringPositionBitmap.contains` → `RoaringBitmap.contains`, and each call 
independently:
   
   1. extracts the high/low key and bounds-checks the `RoaringBitmap[]` 
(`RoaringPositionBitmap.contains`),
   2. binary searches the top-level container array (`RoaringArray.getIndex` → 
`binarySearch`),
   3. searches within the container.
   
   None of that is amortized across the batch even though every call lands in 
the same one or two containers.
   
   The cost also depends heavily on which container the deletes materialized 
into, which is a function of delete density — `ArrayContainer` below 4096 
entries per 65536-position chunk, `BitmapContainer` above it:
   
   | chunk density | cardinality | container | per-row `contains` |
   |---|---|---|---|
   | 0.5% | 327 | array | 13.0 ns |
   | 5% | 3,276 | array | 16.0 ns |
   | 12% | 7,864 | bitmap | 4.0 ns |
   | contiguous 30% | 19,661 | run | 4.6 ns |
   
   So the sparse-delete case that CDC workloads spend most of their time in is 
also the most expensive per row, because it lands on array containers and pays 
a binary search per probe.
   
   
   <details>
   <summary><b>Evidence — cost as a function of delete density</b> (26 
lines)</summary>
   
   Spark 4.0.4, Iceberg 1.11.0, JDK 17, `format-version=3`, MoR. 8M rows across 
4 files (~30 Roaring chunks per file). async-profiler, `ctimer`. Scan 
materialized through the `noop` sink; the vectorized reader was confirmed 
active in every run. Each configuration profiled **3 times in separate JVMs**; 
the run-to-run spread of this measurement is 9.7% median (see *Caveats*), so 
ranges are reported and differences inside that band are not claimed.
   
   `buildRowIdMapping` probes `batchSize` times per batch regardless of 
density, and the total scanned row count is fixed, so the sample count is 
directly proportional to per-probe cost.
   
   | delete density | dominant container | delete-check CPU samples | share of 
scan subtree |
   |---|---|---|---|
   | 0% (control) | — | **0** | 0.00% |
   | 0.5% | array | 599 (576–633) | 44.2% |
   | 5% | array | 746 (700–798) | 47.5% |
   | **6.1%** | array | **872 (838–909)** | **53.2%** |
   | 6.25% | mixed | 654 (617–702) | 46.2% |
   | 7.0% | bitmap | 394 (381–406) | 33.6% |
   | 12% | bitmap | 422 (410–447) | 36.3% |
   | 50% | bitmap | 496 (454–529) | 39.6% |
   
   The control table with no deletes shows exactly zero samples attributed to 
the delete path, so the attribution has no false positives.
   
   Two things worth noting:
   
   - The worst case is **just below the array→bitmap boundary** (~6.1% deletes 
per chunk), not at high delete rates. Deleting *more* rows can make the per-row 
check cheaper.
   - With a wide projection (20 columns) the same table shows 3.75%, because 
the check is per row regardless of how many columns are read. **The 40–53% 
figures are specific to narrow projections.** See *Evidence — projection width* 
below for the patched numbers at 20 columns.
   
   Wall clock, 1 column, median of 12 iterations: a table with 0.5% deletes 
reads **13.0% slower** than the same table with no deletes at all — despite 
having 0.5% fewer rows to emit.
   </details>
   
   <details>
   <summary><b>Evidence — with the change applied</b> (51 lines)</summary>
   
   I cloned `apache-iceberg-1.11.0` (commit `6976e02`), implemented the change, 
and re-measured the **same tables** with only the jar swapped. Both jars were 
built from the same tree — the baseline is the same source with the patch 
stashed — so the only difference between them is the diff.
   
   Correctness was checked first: `count(*)`, `sum(id)` and `min/max(id)` over 
7 tables, identical for both jars. `sum(id)` is sensitive to *which* rows 
survive, not just how many.
   
   | configuration | container | current | patched | reduction | share of scan 
subtree | scan subtree |
   |---|---|---|---|---|---|---|
   | 0.5% deletes | array | 674 (588–748) | **79 (67–97)** | **8.6x** | 43.2% → 
7.6% | −33.6% |
   | **6.1% deletes** | array | **873 (704–959)** | **94 (68–117)** | **9.3x** 
| **49.4% → 9.6%** | **−44.8%** |
   | 7.0% deletes | bitmap | 420 (378–474) | 113 (109–115) | 3.7x | 33.4% → 
11.5% | −22.0% |
   | 0.5%, unclustered | array | 621 (568–683) | 73 (64–78) | 8.5x | 44.1% → 
7.4% | −29.9% |
   | 0.5%, clustered | run | 185 (174–196) | 61 (43–79) | 3.1x | 19.1% → 7.0% | 
−12.4% |
   
   3 repetitions per arm, arms interleaved (baseline r1, patched r1, baseline 
r2, …) so machine drift is shared rather than attributed to the jar. The ranges 
of the two arms do not overlap in any configuration; comparing the best 
baseline against the worst patched run still leaves 2.2x–7.3x.
   
   The gain tracks the baseline cost: it is largest on array containers, which 
is exactly where the removed work — a binary search per row — was most 
expensive.
   
   Those densities all sit near the container boundary, so I later filled in 
the high-delete range as well (1 column, 4 repetitions, both densities on 
bitmap containers so container type is held fixed):
   
   | delete density | container | current | patched | reduction |
   |---|---|---|---|---|
   | 8.0% | bitmap | 499 (489–552) | 142 (137–146) | **3.5x** |
   | 50.0% | bitmap | 584 (556–612) | 225 (200–270) | **2.6x** |
   
   So the change helps across the whole density range, and **2.6x is the 
floor**, reached where deletes are so dense that there are no gaps left to skip 
— at 50% deletes the average run of live rows is one row, so the bulk range API 
loses its main advantage and what remains is the removal of the per-row lookup 
and branch. The largest gain is at 6.1%, which is also where the current code 
is worst.
   
   Hot frames before and after (0.5% deletes, 1 column, sample counts):
   
   ```
   current                                     patched
     RoaringBitmap.contains              59      RoaringBitmap.forEachInRange   
        13
     Util.unsignedBinarySearch           49      RoaringBitmap.forAllInRange    
        13
     Util.hybridUnsignedBinarySearch     45      ArrayContainer.forAllInRange   
        11
     ArrayContainer.contains             34      
IntConsumerRelativeRangeAdapter        11
     RoaringArray.binarySearch           17      
Util.hybridUnsignedBinarySearch         1
     RoaringArray.getContainerIndex      17      Util.unsignedBinarySearch      
         1
   ```
   
   Binary-search samples: **94 → 2**.
   
   Wall clock, 1 column, median over 30 iterations × 3 runs:
   
   | configuration | current | patched |
   |---|---|---|
   | 0.5% deletes | 0.207 s | 0.180 s (−12.8%) |
   | **6.1% deletes** | 0.221 s | **0.153 s (−30.8%)** |
   | 7.0% deletes | 0.171 s | 0.150 s (−12.8%) |
   
   These scans are short (0.15–0.22 s) so wall clock is noisier than the sample 
counts; I treat it as a direction check rather than the primary number.
   </details>
   
   <details>
   <summary><b>Evidence — projection width</b> (60 lines)</summary>
   
   The numbers above are all `select` of a single column. Because the delete 
check runs once per row regardless of projection width, widening the projection 
leaves the delete-check cost alone and inflates everything around it. I re-ran 
the same three tables projecting 20 columns, 6 repetitions per arm, with the 
arm order flipped halfway (rounds 1–3 baseline first, rounds 4–6 patched first):
   
   | configuration | delete-check share, 1 col | delete-check share, 20 cols | 
reduction, 1 col | reduction, 20 cols |
   |---|---|---|---|---|
   | 0.5% deletes | 43.2% | **3.2%** | 8.6x | **6.8x** |
   | **6.1% deletes** | 49.4% | **4.8%** | 9.3x | **6.3x** |
   | 7.0% deletes | 33.4% | 2.7% | 3.7x | 3.5x |
   
   The arm ranges are disjoint in all three configurations, so the reduction 
itself still holds at 20 columns. The mechanism shows up directly in the 
absolute sample counts: widening the projection 20x leaves the baseline 
delete-check samples essentially unchanged (674→625, 873→902, 420→516) while 
the scan subtree grows 1,558→19,332 — the share falls because the denominator 
grew, not because the check got cheaper.
   
   I then filled in the widths between, 6 repetitions per arm at each (108 
profiles). End-to-end wall clock, paired within each round (negative = patched 
faster; **bold** = outside the 9.7% noise band with all 6 pairs agreeing in 
sign, sign test p = 0.03):
   
   | projected columns | 0.5% deletes | 6.1% deletes | 7.0% deletes |
   |---|---|---|---|
   | 1 | **−19.5%** | **−18.8%** | **−12.8%** |
   | 3 | **−18.5%** | **−13.5%** | −15.6% (5/6, p=0.22) |
   | 5 | **−13.2%** | **−15.2%** | −6.7% |
   | 10 | −2.3% | −6.7% | +4.7% |
   | 20 | −8.4% | +3.5% | +4.9% |
   
   So the crossover is between 5 and 10 columns on this table. But the useful 
statement is not a column count — it is what those columns cost to decode. Scan 
samples added per extra column, at 6.1% deletes:
   
   | range | columns added | scan samples per column |
   |---|---|---|
   | 1 → 3 | 2 ints | 280 |
   | 3 → 5 | 2 ints | 100 |
   | **5 → 10** | 2 doubles + **3 md5 strings** | **1,958** |
   | 10 → 20 | mixed | 660 |
   
   The first five columns of this table are integers; the first string column 
is the eighth — so column count and decoding cost are completely confounded in 
the table above.
   
   To separate them I ran a third experiment that puts the two in opposition: 
ten integer columns (many columns, cheap) against one and three md5 string 
columns (few columns, expensive), 6 repetitions per arm across the same three 
densities (108 profiles). Delete-check share of scan CPU:
   
   | projection | columns | 0.5% deletes | 6.1% deletes | 7.0% deletes |
   |---|---|---|---|---|
   | 1 int (`id`) | 1 | 43.2% | 49.4% | 33.4% |
   | 1 md5 string | 1 | 17.4% | 22.2% | 12.1% |
   | 3 ints | 3 | 35.5% | 42.8% | 27.6% |
   | 3 md5 strings | 3 | 7.5% | 10.6% | 5.5% |
   | **10 ints** | 10 | **15.9%** | **21.6%** | **12.8%** |
   | 10 mixed (3 strings) | 10 | 5.9% | 7.7% | 4.3% |
   
   Ten integer columns hold a **larger** share than three string columns in all 
three densities — the opposite of what a column-count model predicts, and a 
single integer column holds twice the share of a single string column. Solving 
for per-column decode cost across these configurations gives 288–299 samples 
for an integer column against 2,395–2,966 for a 32-char md5 column (8–10x); the 
integer cost and the fixed overhead come out stable across delete densities 
(288–299 and 537–598), which is what they should do, since decoding does not 
depend on how much is deleted.
   
   So the portable form of the threshold is **one delete check ≈ decoding 
1.6–3.2 integer columns ≈ 0.16–0.39 of one md5 string column**, and "five 
columns" is an artifact of this table's column order.
   
   One caution on reading the sample counts as latency. Across all 24 
configurations measured here, regressing the measured wall-clock reduction on 
the sample-based scan reduction through the origin gives a slope of 0.53 (R² = 
0.72): **only about half of a profiler-measured CPU saving arrives as wall 
clock.** (Taking the ratio only where wall clock clears the noise band gives 
1.73x, but that selects for large effects; the regression is the honest 
summary.)
   
   I have since established most of the mechanism, and it matters for how you 
read every CPU number here. Splitting one run's process CPU by frame shows that 
**JIT compilation is 41.6% of it** — these scans are short-lived JVMs, so a 
large fixed cost sits outside the scan subtree and dilutes any saving inside 
it. Amortising it by raising the iteration count tenfold moves the dilution 
factor to 0.447, close to the measured 0.53. **That is a property of a 
short-lived benchmark JVM, not of the workload**, which means the wall-clock 
figures in this issue are conservative with respect to a long-lived executor. 
Consistent with that, the one wall-clock result measured on a real cluster — 
where executors live for the whole job — is a clean **−9.5%** with all six 
paired rounds agreeing.
   
   Combining the two — the change removes ~87% of the delete check, and about 
half of that reaches latency — predicts that the effect clears my 9.7% noise 
band once the delete check is above **~20% of scan CPU**. The ten-integer 
projection sits at 21.5% share and measured −9.1%, right on the boundary. Every 
scan-subtree percentage quoted in this issue is subject to this correction.
   
   I also swept task parallelism to check how much that 0.53 depends on it, 
since the threshold is derived from it. Fitting the correction factor against 
`local[N]` for N = 1, 2, 4, 16 (96 profiles, one 16-vCPU instance, split size 
pinned so task count scales) gives **1.30 × N^0.14** (R² = 0.95): 1.29x at N=1, 
1.63x at N=4, 1.90x at N=16. So the factor does grow with parallelism — the 
tail-task explanation is directionally supported — but only by 1.5x across a 
16x range, which moves the threshold from about 18% to about 21%. The 
break-even is **around a fifth of scan CPU** and does not change order of 
magnitude with scale. Note that `local[N]` is a thread pool in one JVM; this 
says nothing about shuffle or executor scheduling in a real cluster.
   
   **Wall clock at 20 columns is not interpretable, in either direction.** The 
three configurations came out +8.7% / −3.9% / −3.2% (patched vs current), all 
inside the 9.7% run-to-run band and not even agreeing on sign. Pairing runs 
within a round, the patched arm was slower in 13 of 18 pairs (sign test p ≈ 
0.10, not significant); before flipping the arm order it was 8 of 9, so part of 
that was an order effect rather than the jar. I make no wall-clock claim at 
this projection width.
   
   So the honest end-to-end statement is: **the change reduces delete-check CPU 
by 3.0x–9.3x across every projection I measured, and that turns into a visible 
query-latency win while the delete check is above roughly a fifth of scan 
CPU.** On this schema that means projections of up to about ten fixed-width 
columns, or fewer than one md5 string column. Below that threshold the CPU 
saving is real but I cannot measure it in query time.
   </details>
   
   <details>
   <summary><b>Evidence — does this hold outside my machine?</b> (60 
lines)</summary>
   
   Everything above was measured on one developer machine. Since the 
delete-check share is the number
   the rest of the argument rests on, I re-ran the same two jars 
(byte-identical, verified by md5) on
   the same table bytes in three more environments. Delete-check share of scan 
CPU, narrow projection:
   
   | delete density | Zen 3 / WSL2 | Sapphire Rapids | Graviton3 | S3 instead 
of local disk | page cache dropped |
   |---|---|---|---|---|---|
   | 0.5% | 43.2% | 49.3% | 48.4% | — | — |
   | **6.1%** | **49.4%** | **56.2%** | **54.3%** | **58.0%** (vs 56.2% on EBS) 
| **52.0%** (vs 51.0% warm) |
   | 7.0% | 33.4% | 39.3% | 35.6% | — | — |
   
   The share moves by at most a few points, and where it moves it moves *up* 
off my machine, so the
   numbers quoted above are the conservative end.
   
   Two of those results were surprises worth stating, because both were 
predictions I wrote down
   first and got wrong:
   
   - **Dropping the page cache does not lower the share, and neither does 
reading from S3.**
     `ctimer` samples CPU time, so I/O *wait* never enters the denominator; and 
the S3 client's own
     CPU (HTTP, TLS, checksums) turns out to be only ~1.4 percentage points of 
total CPU here, far
     too small to move it. Storage changes what fraction of wall clock is CPU, 
not what fraction of
     CPU is the delete check.
   - **The patch helps *more* on newer cores**, not less: 11.3x–17.9x on the 
cloud instances against
     8.6x–9.3x on mine, because the per-row probe gets relatively more 
expensive while the bulk range
     traversal gets cheaper. I first attributed that to branch prediction. 
Hardware counters do not
     support it (see below), so I state it as an observation, not a mechanism.
   
   **One thing does move the share, and it is not storage — it is file count.** 
I split the same
   32M rows into 4 files and into 488 files, choosing row counts so that each 
file is a whole number
   of 65,536-position chunks. That makes the two tables produce 
**byte-identical Roaring containers**
   (488 containers, `array:460 + bitmap:28`, 3,901,922 bytes, same deleted 
rows) — the only
   difference is whether those containers sit in 4 puffin files or 488.
   
   | | delete-check share, EBS | delete-check share, S3 | wall clock |
   |---|---|---|---|
   | 4 files | 60.7% | 61.1% | 0.91 s |
   | 488 files | **49.7%** | **49.0%** | 1.31 s |
   
   The share falls by about 11 points, by the same amount on both storages. 
Splitting the profile by
   frame shows why: the per-row batch loop does not move (1,556 → 1,356 samples 
— same rows, same
   containers), while **DV load/deserialization grows 2.5x and non-DV scan work 
grows 45%**. Those
   are per-file fixed costs — footer parsing, one DV read per file — and they 
enlarge the denominator.
   Two consequences for reading this issue:
   
   - The 43–58% band assumes a table with few, large files. On a table with 
many small files, expect
     the low end or below.
   - **The speedup is diluted the same way**: 12.4x at 4 files against 6.1x at 
488, on the same
     dedicated instance. The patch is not worse — the loop it fixes is 
unchanged — but the fixed cost
     it does not touch takes a larger share of the denominator. I measured up 
to 488 files; I have not
     measured thousands.
   
   One methodological note that cuts against my own earlier numbers: the 
run-to-run spread on the
   dedicated instances was **7–13%**, against **27% median** on my WSL2 box. 
The 9.7% noise floor I
   use throughout this issue is a property of my development environment more 
than of the workload.
   Anything I mark "inside the noise" might be resolvable on quieter hardware — 
and in one case that
   mattered: on my machine a shuffle-heavy query looked like the patch made it 
*slower* (1 of 6 paired
   rounds favouring the patch); on a dedicated instance with 12 rounds it is 
**faster in 12 of 12**.
   I was looking at a regression that was not there.
   </details>
   
   <details>
   <summary><b>Evidence — what a user can do before this is fixed</b> (61 
lines)</summary>
   
   The delete check is cheap or expensive depending on which Roaring container 
the positions land in,
   so the physical layout of the table matters independently of this patch. I 
tested that directly:
   two tables with the **same rows deleted** (identical predicate, verified to 
the row: 430,575 in
   both), differing only in whether the table was sorted by the column the 
deletes target. The scan
   carries no predicate, so no files can be skipped — this isolates the 
delete-check effect from the
   file-pruning effect that sorting also gives you.
   
   | | delete-check CPU | share of scan | vs baseline |
   |---|---|---|---|
   | unsorted, unpatched | 915 | 51.0% | 1.0x |
   | **sorted, unpatched** | 327 | 27.3% | **2.8x** |
   | unsorted, patched | 117 | 10.9% | 7.8x |
   | **sorted + patched** | 74 | 8.0% | **12.4x** |
   
   The mechanism is visible in the files: unsorted, every chunk is an `array` 
container at ~7,000 bytes;
   sorted, every chunk is a `run` container at 6-10 bytes, and the whole 
deletion vector shrinks from
   864 KB to 2.5 KB. Decoding cost is unchanged between the two (non-delete 
scan samples 876 vs 883),
   which is what makes the comparison valid.
   
   **This has a condition, and it matters.** Sorting only helps when the 
deletes concentrate on
   relatively few distinct values of the sort key. Sweeping the key's 
cardinality:
   
   | sort key cardinality | rows per value | delete-check gain |
   |---|---|---|
   | ~1,000 | ~8,000 | **2.78x** |
   | ~1,000,000 | ~8 | 1.10x |
   | ~10^9 (effectively unique) | 1 | **0.89x** |
   
   So "sort by the delete key" is good advice for deletes driven by date, 
region or tenant, and no
   advice at all for deletes driven by individual row ids — there the sorted 
table performs like the
   unsorted one and you have paid the sort for nothing. The two remedies also 
overlap: the sorting
   gain drops from 2.78x to 1.58x once this patch is applied, since the patch 
has already removed most
   of what sorting was saving.
   
   (Wall-clock differences on this axis did not clear my noise band, so the 
numbers above are CPU
   samples, where the repeat ranges are disjoint.)
   
   **What the sort costs.** I measured that too, because advice that only 
prices the upside is not
   advice. Writing the same 8M-row table sorted rather than unsorted, four 
alternating rounds each:
   
   | | write wall clock | stored bytes |
   |---|---|---|
   | unsorted | 20.07s (16.31-20.84) | 857,294,068 |
   | **sorted** | **32.74s** (29.74-36.92) | **879,967,939** |
   
   Writing costs **63% more** (+12.67s, or +1.58 µs per row; the ranges are 
disjoint). Storage grows
   **2.6%**, which surprised me — I had predicted it would shrink. Per-column 
metrics explain it
   exactly: the sort key itself collapses from 10,051,608 to **25,151 bytes** 
(399x smaller, it becomes
   runs), but `id` doubles (8.2 MB to 16.9 MB) and an id-derived column doubles 
with it, because in the
   unsorted table those were perfectly sequential within each file and 
delta-encoded almost for free.
   The other 17 columns move by less than 0.1%. **Sorting by a delete key 
un-sorts whatever was
   naturally ordered** — usually an auto-increment id or a load timestamp.
   
   Putting cost and benefit together: at 0.042s saved per scan against 12.67s 
of extra write, the sort
   pays for itself after roughly **300 scans**. That ratio is fairly portable — 
both sides scale with
   row count, and the delete-check saving is independent of projection width — 
so the practical rule is
   *sort if the table is written once and read hundreds of times*, and don't if 
it is rewritten often
   by streaming upserts.
   </details>
   
   <details>
   <summary><b>Evidence — a distributed cluster</b> (39 lines)</summary>
   
   Everything above runs in one JVM, so I put the same two jars on a 3-node 
Spark 4.0.4 standalone
   cluster (1 master + 2 workers, 8 cores total, table on S3, profiler attached 
to the *executors* via
   `spark.executor.extraJavaOptions` and the per-executor profiles summed per 
run).
   
   | query | delete-check share of scan CPU | patch speedup | wall clock |
   |---|---|---|---|
   | scan, one JVM | 53.95% | 6.8x | — |
   | **scan, cluster** | **41.58%** | **5.30x** | **−9.5%** (0/6 pairs, sign 
test p = 0.03) |
   | aggregation, one JVM | 43.19% ⚠️ | 6.9x | — |
   | **aggregation, cluster** | **41.57%** | **5.07x** | +0.7% (inside noise) |
   
   Two things worth stating, both of which contradict what I predicted:
   
   - **The share drops to about 42% and the speedup to about 5x.** Executor 
startup, task
     serialization and S3 reads all land inside the scan subtree, enlarging the 
denominator. The
     delete check is still the single largest identified item, but the 58% top 
of my range is a
     single-JVM number and I have corrected the summary accordingly.
   - **In the cluster the shuffle does not move the share** — 41.58% with no 
shuffle against 41.57%
     with a large one.
   
     I originally contrasted this with a single-JVM pair of 53.95% against 
43.19% and offered an
     explanation for why the cluster behaved differently. **That contrast did 
not survive
     re-measurement.** The 43.19% came from my development machine, whose 
round-to-round spread is
     about three times that of a dedicated instance. Re-running the same three 
queries on a dedicated
     `m7i.xlarge` with 12 rounds instead of 6 gives **59.6% / 58.0% / 55.6%** — 
a spread of 4.0
     points, not 10.8. So the shuffle does not move the share in a single JVM 
either, and there is
     nothing left for the cluster to explain. I have removed the claim rather 
than the data.
   
   The wall-clock result is the useful one: on the plain scan the patched build 
is **9.5% faster end
   to end on the cluster**, with all six paired rounds agreeing in sign. That 
is the first time I have
   been able to claim a latency difference outside a single JVM.
   
   Caveat on size: 8M rows over 8 cores is small enough that the cluster is 
2.3x slower than one JVM
   on the same query (0.50s vs 0.215s), which means fixed overhead is inflating 
the denominator. On a
   job large enough to be worth a cluster I would expect the share to move back 
toward the single-JVM
   figure, so 42% reads as a floor.
   </details>
   
   <details>
   <summary><b>Evidence — hardware counters, and a mechanism I had wrong</b> 
(65 lines)</summary>
   
   I had assumed the per-row probe was expensive because the `array` container 
does a binary search
   whose branches mispredict. **That is not what the counters say.** Running 
the microbenchmark under
   `-prof perfnorm` (AMD Zen 3, 5000-row batch, 2 forks x 3 iterations):
   
   | pattern | container | us/op | cycles/row | **insn/row** | branches/row | 
**br-misses/row** | IPC |
   |---|---|---|---|---|---|---|---|
   | 0.5% density | array (card. 327) | 66.05 | 58.4 | 282.9 | 77.2 | 0.0126 | 
4.84 |
   | 5% density | array (card. 3,276) | 85.21 | 78.8 | 370.0 | 96.6 | 0.0763 | 
4.70 |
   | 12% density | **bitmap** | **21.13** | **18.6** | **86.5** | 17.8 | 0.0292 
| 4.66 |
   | scattered runs | run | 45.27 | 40.4 | 191.4 | 45.0 | 0.0066 | 4.74 |
   
   Three things fall out:
   
   - **Branch misprediction explains about 1.4% of the gap, not the gap.** To 
account for the
     301,045-cycle difference between the 5% and 12% patterns at an 18-cycle 
penalty you would need
     roughly 16,700 extra mispredicts per batch. There are 235. For the 0.5% 
pattern the array
     container actually mispredicts *less* than the bitmap one does.
   - **Cache locality does not explain it either.** The fastest pattern 
(bitmap) takes **8x more**
     L1-dcache misses than the slow ones — 1,173.9 per op against 141.2.
   - **IPC is flat at 4.66–4.84 across all four patterns**, and cycles track 
instructions to within
     0.8–3.8%. Nothing is stalling. The containers differ in **how many 
instructions they execute
     per row**, and that is the whole story.
   
   This cuts for the proposal rather than against it. Even the cheapest 
container costs **86.5
   instructions per row**, because `RoaringPositionBitmap.contains(long)` has 
to split the position,
   binary-search the key array and follow two levels of indirection before it 
can test a bit. That
   fixed per-call cost is paid 5,000 times per batch whatever the container is. 
The batch-oriented API
   pays it a handful of times instead, which is why the speedups are as large 
as they are.
   
   Control: before trusting numbers this low I checked that the counters work 
under this hypervisor,
   with a sorted-vs-shuffled branch experiment — 9.2M vs 108.6M mispredicts 
(11.8x), matching the
   theoretical 50% miss rate to within 3%.
   
   **The same holds in a real Spark scan, but with two qualifications.** 
Wrapping `spark-submit`
   itself in `perf stat` (3 alternating rounds):
   
   | arm | cycles | instructions | branch-misses | miss rate | IPC |
   |---|---|---|---|---|---|
   | baseline | 237,714,166,096 | 390,468,338,748 | 1,204,681,888 | 1.55% | 
1.64 |
   | patched | 203,636,206,746 | 260,507,995,090 | 1,200,937,204 | 2.51% | 1.28 
|
   
   - **Branch misprediction explains 0.2% of the patch's effect**, and the 
clearest sign is that the
     absolute count barely moves (1.2047G to 1.2009G) even though the patch 
deletes **38% of all
     branches**. What it removes are branches that were already being predicted 
correctly.
   - **But instruction count does not fully explain it either, unlike in the 
microbenchmark.** The
     patch removes 50% of instructions and 17% of cycles; IPC falls from 1.64 
to 1.28. The work that
     remains is more stall-bound. So the honest statement for a real query is 
*"removing half the
     instructions buys you a sixth of the time"*, which is also why my 
CPU-sample numbers overstate
     wall-clock gains by about 1.9x.
   - The real scan's branch miss rate is **1.55%**, roughly 100x the 
microbenchmark's. Microbenchmark
     counter values do not transfer; only the conclusion does.
   
   One useful side effect: profiling with `cycles` instead of the CPU-time 
timer gives a delete-check
   share of **52.2%** against **50.0%** from CPU-time sampling — within 2.2 
points. Every share figure
   in this issue holds under cycle-accurate attribution too.
   
   **Where branch prediction does matter.** At very high delete densities the 
row test
   `if (!contains(pos))` becomes a coin flip. Comparing 8% against 50% density, 
mispredicts attributed
   to the delete-check region rise from 82.7M to 115.5M (**+40%**, disjoint 
ranges), and the region's
   share of scan mispredicts goes 41.1% to 51.6%. That accounts for about 
**28%** of the extra cycles
   the delete check spends there — small in absolute terms, but 20x more than 
in any other comparison
   I ran. I have not identified the remaining 72%.
   </details>
   
   <details>
   <summary><b>Evidence — microbenchmark</b> (28 lines)</summary>
   
   JMH, batchSize 5000, positions built into a full 65536-position chunk at the 
target density, then round-tripped through 
`BitmapPositionDeleteIndex.serialize()`/`deserialize()` so the containers match 
what the read path actually sees. Time per batch, µs (RoaringBitmap 1.6.20 
standalone):
   
   | implementation | sparse 0.5% | medium 5% | dense 12% | run (L=64, 5%) | 
empty batch |
   |---|---|---|---|---|---|
   | current (`isDeleted` per row) | 70.37 | 81.54 | 19.17 | 44.67 | 23.31 |
   | `forEachInRange` + gap fill | **0.47** | **1.86** | **3.38** | **0.80** | 
**0.26** |
   | `forAllInRange` + `RelativeRangeConsumer` | 0.47 | 1.81 | 2.95 | 0.30 | 
0.27 |
   
   The `run` column uses length-64 deleted blocks spread across the chunk to 
reach 5% density
   (51 runs), so the batch partially overlaps the deletes. A fully-deleted 
batch would report
   ~3800x, which is a degenerate best case and not a number worth quoting.
   
   Two things fall out of the `run` column:
   
   - At **identical density (5%)**, the array container costs 81.54 µs and the 
run container
     44.67 µs — 1.83x, purely from container structure. `log2(3277)/log2(51) = 
2.06`, so the
     binary-search model predicts this within 11%. Table layout changes what 
the reader pays
     before any code change does.
   - **`forAllInRange` is 2.7x faster than `forEachInRange` on run containers** 
(0.30 vs 0.80),
     because it can skip the gaps between runs with `acceptAllAbsent` instead 
of only being told
     where the deletes are. On array and bitmap containers the two are 
equivalent. Both are still
     ≥55x over the current code, so I lean towards the smaller API, but this is 
the concrete cost
     of that choice and I am happy to go the other way.
   
   All variants were asserted to produce byte-identical `rowIdMapping` and live 
counts before measurement. The end-to-end gain is smaller than the 
microbenchmark ratio because the delete-check subtree also contains DV 
deserialization, the mapping array write, and the `RoaringPositionBitmap` 
wrapper — none of which this change touches.
   </details>
   
   <details>
   <summary><b>Evidence — the other two paths</b> (33 lines)</summary>
   
   **`buildIsDeleted`** is reached when a query projects the `_deleted` 
metadata column. Same tables,
   same method, only the projection differs:
   
   | configuration | current | patched | reduction |
   |---|---|---|---|
   | 0.5% deletes + `_deleted` | 649 (573–717) | **34 (25–42)** | **18.9x** |
   | 6.1% deletes + `_deleted` | 854 (793–892) | **57 (56–58)** | **14.9x** |
   
   The gain is larger here than for `buildRowIdMapping` because this path only 
has to mark the
   deleted positions — it never fills the gaps with live row ids, so the range 
traversal does
   ~25 writes per batch instead of ~4,975. Row counts and the number of rows 
flagged as deleted
   were identical between the two jars (40,154 and 488,161, matching the DV 
cardinalities exactly).
   
   **Equality deletes.** I built two tables with equality delete files written 
through
   `Parquet.writeDeletes(...).buildEqualityWriter()` and committed with 
`RowDelta`, since Spark
   itself only writes position deletes:
   
   | configuration | current | patched | ratio | verdict |
   |---|---|---|---|---|
   | equality deletes only | 3,303 (2,590–4,249) | 2,635 (2,594–2,679) | 1.25x 
| ranges overlap — no claim |
   | DVs **+** equality deletes | 3,362 (3,177–3,559) | 3,524 (3,267–3,955) | 
0.95x | ranges overlap — no claim |
   
   No regression. The second table is the case where the gate actively refuses 
the fast path, and
   the only added work there is one `hasEqDeletes()` call per batch.
   
   The first table turns out to be a useful negative control: with no position 
deletes,
   `deletedPositions == null` short-circuits before `hasEqDeletes()` is even 
called, so **both jars
   execute byte-identical code** — and the measurement still differed by 1.25x. 
That is a direct
   read on how much apparent effect noise can produce at these sample counts, 
and it is an order of
   magnitude below the numbers claimed above.
   </details>
   
   ### Proposal
   
   
   Add a range-scoped traversal to `PositionDeleteIndex` with a default 
implementation that preserves current behavior, so no existing implementation 
has to change:
   
   ```java
   /**
    * Traverses the deleted positions within the given range in ascending 
order, applying the
    * provided consumer.
    *
    * @param posStart the first position in the range, inclusive
    * @param length the number of positions in the range
    * @param consumer a consumer for the deleted positions in the range
    */
   default void forEachInRange(long posStart, int length, LongConsumer 
consumer) {
     for (int index = 0; index < length; index++) {
       long pos = posStart + index;
       if (isDeleted(pos)) {
         consumer.accept(pos);
       }
     }
   }
   ```
   
   Override it in `BitmapPositionDeleteIndex`, delegating through 
`RoaringPositionBitmap` to `RoaringBitmap.forEachInRange`. 
`EmptyPositionDeleteIndex` overrides it to a no-op.
   
   `RoaringPositionBitmap.forEachInRange` has to split the range across the 
32-bit keys, but since `length` is an `int` and a single key covers 2³² 
positions, a range can span at most two keys.
   
   Then in `ColumnarBatchUtil`, take the range path when there are no equality 
deletes and fall back to the existing per-row loop otherwise:
   
   ```java
   PositionDeleteIndex deletedPositions = deletes.deletedRowPositions();
   
   if (deletedPositions != null && !deletes.hasEqDeletes()) {
     return buildRowIdMapping(deletedPositions, deletes, rowStartPosInBatch, 
batchSize);
   }
   
   // existing per-row loop
   ```
   
   **Note the condition is `!deletes.hasEqDeletes()`, not `eqDeleteFilter == 
null`.** `DeleteFilter.eqDeletedRowFilter()` returns `t -> true` rather than 
`null` when there are no equality deletes (`DeleteFilter.java:245`), so a null 
check would never take the fast path.
   
   **The equality-delete case must keep the per-row loop**, because 
`eqDeleteFilter.test(row)` needs each row and `ColumnarBatchRow.rowId` is 
advanced inside that loop. The range path only applies to position deletes / 
deletion vectors — the V3 DV case.
   
   `deletes.incrementDeleteCount()` is still called once per deleted row.
   
   I kept the proposed API deliberately small. `RelativeRangeConsumer` (which 
additionally reports absent positions and has bulk 
`acceptAllPresent`/`acceptAllAbsent` callbacks) is faster in the dense and 
fully-deleted cases, but it is a much larger API surface to expose through 
`PositionDeleteIndex`. It also turns out that `RoaringBitmap.forEachInRange` 
internally delegates to `forAllInRange` with an 
`IntConsumerRelativeRangeAdapter`, so the small API already gets most of the 
benefit. Happy to go the other way if maintainers prefer it.
   
   
   ### Tests
   
   
   `TestColumnarBatchUtil` currently mocks `PositionDeleteIndex` and stubs only 
`isDeleted`. A Mockito mock does nothing for default methods, so the 
position-only tests would see "nothing deleted" once the range path is taken. I 
changed those tests to use a real index built with 
`Deletes.toPositionIndex(...)`, which also makes them exercise the actual 
bitmap. Tests that combine position and equality deletes additionally need 
`hasEqDeletes()` stubbed — they currently stub `hasPosDeletes()`, which 
`ColumnarBatchUtil` does not read.
   
   New coverage on the branch:
   
   - `TestPositionDeleteIndexForEachInRange` (core, 12 tests): empty index, 
zero/negative length, inclusive-exclusive boundaries, ascending order, ranges 
spanning a 65536-position Roaring chunk, ranges spanning a 32-bit key boundary, 
ranges past the allocated bitmaps, run containers after `runLengthEncode`, and 
randomized comparison of 500 ranges against a per-position `isDeleted` scan — 
for both the bitmap implementation and the interface default.
   - `TestColumnarBatchUtil` (spark, 5 new tests): non-zero batch start 
positions, deletes entirely outside the batch range, all rows deleted by 
position, and a randomized comparison of 60 batches against the per-row path 
including the delete-counter call count.
   
   ```
   :iceberg-core:test --tests "org.apache.iceberg.deletes.*"      55 tests, 0 
failures
   :iceberg-spark:iceberg-spark-4.0_2.13:test --tests "...TestColumnarBatchUtil"
                                                                  17 tests, 0 
failures
   ```
   
   
   ### Compatibility
   
   
   - `PositionDeleteIndex` already extends via `default` methods (`merge`, 
`forEach`, `cardinality`, `serialize`), so this follows an established pattern 
and is source- and binary-compatible for external implementations.
   - The default implementation is exactly the current behavior, so correctness 
does not depend on any implementation adopting the override.
   - No format or spec change.
   
   
   ### Caveats on the numbers
   
   
   - The primary measurements are on a developer machine (WSL2), Spark 
`local[4]` mode; the portability checks above are on dedicated EC2 instances. 
**Correction to an earlier version of this text:** I had written that no 
hardware PMU was available and that cycle- and branch-level attribution would 
need a bare-metal run. That was wrong — recent WSL2 kernels expose a Hyper-V 
vPMU, and the counter evidence in *hardware counters* above was collected with 
it. I had asserted the limitation in eight places over eight months without 
checking it.
   - Most of the numbers are single-JVM `local[N]`. Adding a `GROUP BY` moves 
the delete check from **14.0% of total query CPU to 6.8% and then 2.4%** as the 
aggregation grows, while its share *of the scan* stays in the 43-54% band and 
the absolute CPU saved is unchanged. I also ran it on a 3-node Spark standalone 
cluster (see below); the cluster there is small (8 cores, 8M rows), so fixed 
overhead dominates and the cluster is actually 2.3x *slower* than one JVM on 
the same query. A production-sized job would sit closer to the single-JVM 
numbers, so treat 42% as a lower bound rather than a typical cluster value.
   - One table shape for most axes: 8M rows, 4 files, 20 columns. File-skipping 
predicates and nested types are unmeasured. Many-file tables **are** now 
measured (4 vs 488 files, see above) but only to 488; thousands of files, and 
cross-region S3, remain unmeasured.
   - The run-to-run spread of the same configuration is **9.7% median, 25.9% 
max** across 15 configurations × 3 runs. I report ranges and do not claim 
differences inside that band. The patched arm has larger *relative* spread (up 
to 59%) simply because its absolute sample counts are small (43–117).
   - Equality deletes were written on a single column (`id`). Multi-column 
equality deletes were not measured.
   - I ran the length-controlled axis afterwards (four md5-derived string 
columns of 8, 16, 24 and 32 characters, same type, one column projected at a 
time). Fitting scan samples against length gives **1,099 + 63.8 x length** (R² 
= 0.94), so it is neither purely "because it is a string" nor purely "because 
it is wide" — both terms are real. At 8 characters the fixed per-column term 
and the length term are about equal; at 32 characters length dominates 2:1. 
Against an integer column the same measurement gives 2.1x for an 8-char string 
and 4.2x for a 32-char one, so the "8-10 integer columns" figure above applies 
to full-length md5, not to short strings. One point (24 chars) appeared to sit 
off the line, but that was an artifact of my own aggregation: I had averaged 
six rounds, and one round was disturbed (three of the four string columns 
spiked 1.3-1.8x in it, and its wall clock spiked with them). Taking medians 
instead, as the rest of my tooling does, gives **1,808 + 61.6 x length
 ** with R² = 0.95 and leaves the 24-char point 6% off the fit, inside my 9.7% 
noise band. There is no anomaly to explain.
   - The 0.53 sample-to-wall-clock slope is a fit to data I had already 
collected, not a prediction I tested. It is measured on one machine at 
`local[4]` with a warm page cache; I would expect a different slope at other 
parallelism or on other storage. Its main driver is JIT compilation in a 
short-lived JVM (41.6% of process CPU), which means it understates what a 
long-lived executor would see.
   - The 1-column wall-clock rows come from 3 repetitions; every other row from 
6.
   - The `_deleted` projection emits every row rather than filtering, so its 
wall clock is not comparable to the plain scans above.
   - The delete-check share depends on file count as well as projection width: 
60.7% at 4 files against 49.7% at 488, with containers held byte-identical. 
Quote the band with a file-layout assumption attached.
   - Two numbers in this issue were withdrawn after re-measuring on quieter 
hardware: a single-JVM "aggregation lowers the share to 43.19%" contrast (it 
does not — 4.0 points across three queries on a dedicated instance) and an 
apparent wall-clock regression on shuffle-heavy queries (the patch is faster, 
12 of 12 paired rounds). Both originated on a machine whose spread is ~3x that 
of a dedicated instance.
   - The microbenchmark reuses the output buffer to isolate the algorithm. The 
real code allocates `new int[batchSize]` per batch.
   
   
   ### Prior work
   
   
   I looked for existing discussion before filing: #12053 and #12054 touch 
`ColumnarBatchUtil` but are Javadoc and unit tests. I did not find an existing 
issue on the per-row probing cost, nor any reference to the Roaring range APIs 
in this repository.
   
   
   ### Query engine
   
   Spark
   
   ### Willingness to contribute
   
   - [x] I can contribute this improvement/feature independently
   - [ ] I would be willing to contribute this improvement/feature with 
guidance from the Iceberg community
   - [ ] I cannot contribute this improvement/feature at this time


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to