Repository navigation
Core, Spark 4.2: Add PositionDeleteIndex.forEachInRange and use it in vectorized delete checks - #18027
Conversation
… vectorized delete checks
|
Hello @aokolnychyi @huaxingao Tagging you as the people who have worked most on these files. No rush at all, a look whenever you have time would be much appreciated. I tried to make this easy to evaluate rather than something you have to take on trust. The change was measured across delete densities from 0.5% to 50%, on three CPU microarchitectures, and on both local disk and S3. I also used hardware counters to verify the underlying mechanism instead of inferring it from timings, which is actually how I realized my initial explanation for the cost was incorrect. The linked issue also lists the conditions under which these numbers do not hold. I am happy to adjust the approach if you see a better way forward. If this direction looks right, I will backport it to v3.5, v4.0, and v4.1 in a follow-up PR. |
|
Hi, it’s been a while! I hope you’ve been doing well. I reached out to the Core maintainers to ask for discussion and feedback on this PR, but I imagine everyone has been quite busy. So I wanted to ask if you might be able to take a look at it when you have a chance. Eduard Tudenhoefner suggested on Slack that we discuss this through the mailing list, so I moved the discussion to the dev@iceberg mailing list. However, I haven’t received much response there yet, so I wasn’t quite sure how best to proceed and thought I’d reach out to you directly. Sorry for the unexpected mention, and I’d really appreciate it if you could take a look when you have some time. Thank you! |
|
@JeonDaehong: Could you please link the letter sent to the dev list? |
|
Ahh.. this went into the Spam folder for me 😢 I suggest way sorter letters and descriptions. Or at least a TL;DR; in the beginning otherwise maintainers pressed for time will skip your messages. I often make this mistake myself, but learning.... I ended up putting your description to an LLM, and I got this answer: Is this accurate? |
|
Ah, thank you for the feedback! |
|
I’ve resent the email as well! Here is a quick summary for you here too :D TL;DR — yes, 13-19% is accurate and if anything conservative. But the Is 13-19% accurate?It's measured rather than extrapolated, on projections of 5 columns or fewer, When it helps
The one that's backwards"DV-heavy" reads as "more deletes, more benefit", and it's the other way One rule instead of sixThe patch is worth it when the delete check is more than ~21% of scan CPU. Point taken on length — I've added a "when this helps" paragraph at the top of the description, and I'll lead with a TL;DR on the mailing list. Sorry it landed in your spam folder. |
| DeleteFilter<InternalRow> deletes, | ||
| long rowStartPosInBatch, | ||
| int batchSize) { | ||
| RowIdMappingBuilder builder = new RowIdMappingBuilder(rowStartPosInBatch, batchSize); |
There was a problem hiding this comment.
Could we shortcut if there are no deletes?
There was a problem hiding this comment.
Done. The fast path now returns null right away when the index is empty, before touching the range.
There was a problem hiding this comment.
We tried to optimize performance by checking if the bitmap index is empty before processing each batch, but it actually made things 53% slower because isEmpty() was doing an expensive full traversal under the hood. We’ve reverted the change.There is a cheap
| } | ||
|
|
||
| /** Appends the live rows after the last deleted position and returns the live row count. */ | ||
| int build() { |
There was a problem hiding this comment.
Yep. Split into appendRemainingLiveRows() and liveRowCount(), so the side effect and the accessor are separate.
| * @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) { |
There was a problem hiding this comment.
Other methods have long posStart, long posEnd. Shall we use that?
There was a problem hiding this comment.
Done, and thanks for catching it... this turned out to be more than just a rename.
The old implementation relied on length being an int to guarantee a range spans at most two 32-bit keys, and the comment said so explicitly. With a long end that no longer holds, so the per-key loop is now general, and the inner traversal chunks any sub-range wider than Integer.MAX_VALUE (RoaringPositionBitmap.forEachInRange takes an int length, so a fully covered middle bitmap cannot be expressed in one call).
RoaringPositionBitmap.forEachInRange now follows setRange for parameter names, for the precondition, and for treating an empty range as a no-op, so the two read the same way. Added a test for a range spanning three keys, which is the path that did not exist before.
| if (length <= 0) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Done. It now uses the same precondition and message as setRange ("Start position must not exceed end position: [%s, %s)"), and an empty range where start equals end stays a no-op, also matching setRange.
| * {@link #isDeleted(long)} once per position. Implementations backed by a bitmap can locate the | ||
| * containers covering the range once and walk them, instead of resolving the container for every | ||
| * position. |
There was a problem hiding this comment.
No impl details in the javadoc
There was a problem hiding this comment.
Done. dropped the paragraph about locating containers, left only the contract.
| @Test | ||
| void testPositionOnlyPathMatchesPerRowPath() { | ||
| // the range traversal must agree with a straightforward per-row probe on every batch | ||
| java.util.Random random = new java.util.Random(20260818L); |
There was a problem hiding this comment.
nit: no fully qualified name
| * PositionDeleteIndex#isDeleted(long)} reports as deleted in the same range, in ascending order, | ||
| * for every implementation. | ||
| */ | ||
| public class TestPositionDeleteIndexForEachInRange { |
There was a problem hiding this comment.
Removed the file and moved the tests into the existing two classes to match their style.
| private static final long BITMAP_SIZE = 0xFFFFFFFFL + 1L; | ||
|
|
||
| @Test | ||
| public void testEmptyIndex() { |
There was a problem hiding this comment.
package private - no test prefix for new tests
There was a problem hiding this comment.
also moved these into TestRoaringPositionBitmap and TestBitmapPositionDeleteIndex to follow the existing public void testX() convention.
| * PositionDeleteIndex#isDeleted(long)} reports as deleted in the same range, in ascending order, | ||
| * for every implementation. | ||
| */ | ||
| public class TestPositionDeleteIndexForEachInRange { |
There was a problem hiding this comment.
Why new test class?
Shall we put these into TestRoaringPositionBitmap, or TestBitmapPositionDeleteIndex?
There was a problem hiding this comment.
Thought about creating a new test class, but on second thought it didn't seem right, so I dropped it and split the tests by what they actually exercise
-
TestRoaringPositionBitmap: container and key boundaries, a range spanning three keys, ranges past the allocated bitmaps, run containers after runLengthEncode, the new precondition, and a randomized comparison against contains. -
TestBitmapPositionDeleteIndex: the range contract (inclusive start, exclusive end), ascending order, empty index and empty range, and a randomized comparison against isDeleted for both the bitmap-backed index and the interface default.
org.apache.iceberg.deletes went from 55 to 63 tests.
| IsDeletedBuilder builder = new IsDeletedBuilder(isDeleted, rowStartPosInBatch); | ||
| deletedPositions.forEachInRange(rowStartPosInBatch, batchSize, builder); | ||
| for (int index = 0; index < builder.deletedRowCount(); index++) { | ||
| deletes.incrementDeleteCount(); |
There was a problem hiding this comment.
Could we just call it inside the IsDeletedBuilder.accept?
There was a problem hiding this comment.
Done. IsDeletedBuilder now takes the DeleteFilter and increments in accept, so the counter field and its accessor are gone along with the loop.
| for (int index = liveRowId; index < batchSize; index++) { | ||
| deletes.incrementDeleteCount(); | ||
| } |
There was a problem hiding this comment.
This is a bit tricky to read. Is this more clear? Optimizer will fix it anyways.
| for (int index = liveRowId; index < batchSize; index++) { | |
| deletes.incrementDeleteCount(); | |
| } | |
| int deletedRowCount = batchSize - liveRowId; | |
| for (int i = 0; i < deletedRowCount; i++) { | |
| deletes.incrementDeleteCount(); | |
| } |
There was a problem hiding this comment.
I applied the same idea you suggested for IsDeletedBuilder here as well
RowIdMappingBuilder.accept is called once per deleted position, so incrementing there removes this loop entirely rather than making it easier to read. That means I didn't commit the suggestion literally — let me know if you'd rather keep an explicit loop.
|
Could you please add JMH test to show the gains? |
|
@pvary |
Added PositionDeleteIndexBenchmark in core/src/jmh. It reads 5M positions in 5,000-position batches, the way the vectorized reader does, at densities on either side of the array/bitmap boundary. JMH 1.37, JDK 21.0.10:
The spread is wide at five single-shot iterations, but the two arms are two orders of magnitude apart, so the comparison holds either way. Two things are worth noting here:
|
| long lowEnd = | ||
| key == endKey | ||
| ? Integer.toUnsignedLong(pos32Bits(posEndExclusive - 1)) + 1 | ||
| : MAX_POS_32_BITS + 1; |
There was a problem hiding this comment.
Is this the same as 1L << 32 used elsewhere?
There was a problem hiding this comment.
Switched to 1L << 32 to keep it consistent, and removed the now-unused MAX_POS_32_BITS
|
@JeonDaehong: I suggest adding this as a topic for tomorrow’s Iceberg Community Sync so you can present the proposal to the broader community and gather feedback. You can add it to the agenda here: https://docs.google.com/document/d/1YuGhUdukLP5gGiqCbk0A5_Wifqe2CZWgOd3TbhY3UQg/edit?tab=t.0#heading=h.z3dncl7gr8m1 |
|
|
||
| PositionDeleteIndex deletedPositions = deletes.deletedRowPositions(); | ||
|
|
||
| if (deletedPositions != null && !deletes.hasEqDeletes()) { |
There was a problem hiding this comment.
Is this intentionally different from the similar check in buildRowIdMapping?
There was a problem hiding this comment.
Not intentional.
I folded the branches in buildRowIdMapping
and left buildIsDeleted behind, so the two read differently for the same
condition.
They now have the same shape, and buildIsDeleted also skips the per-row
loop when there is nothing deleted at all
if (!deletes.hasEqDeletes()) {
// positions in a batch form a contiguous ascending range, so the index
// can be traversed once for the whole range instead of being probed
// once per row
if (deletedPositions != null) {
IsDeletedCollector collector =
new IsDeletedCollector(deletes, isDeleted, rowStartPosInBatch);
deletedPositions.forEachInRange(
rowStartPosInBatch, rowStartPosInBatch + batchSize, collector);
}
return isDeleted;
}
|
The kafka-connect job failed while resolving dependencies... Maven Central returned 403s for zstd-jni, lz4-java, and javax.ws.rs-api, all of which are pulled in by kafka-clients. |
|
Thanks for suggesting that I present the proposal ! |
|
Thanks for the suggestion! I’d be happy to add it to the agenda and sync with the community, but I’m a little concerned about presenting in English given the timing in Korea. Would it be okay to add it as a discussion item instead, so I can simply sync with the community without giving a formal presentation? If so, where would I be able to follow up on the discussion, including any Q&A or feedback? |
|
Sure thing! Simply a few sentences:
The community members are clever, don't need too much detail. They will ask details if they need it. |
|
Thank you for guiding me! I wrote it this way. I hope it leads to a great discussion! As an engineer who loves Iceberg, I'm thrilled to be a part of this journey. |
See you there! 😄 |
|
@pvary |
You can find the recordings on the Apache Iceberg youtube channel. There were no objections against adding the new API. @nastra promised a second opinion on the PR. |
nastra
left a comment
There was a problem hiding this comment.
I did a high-level pass of the APIs but haven't checked the tests yet
| * Traverses the deleted positions within the given range in ascending order, applying the | ||
| * provided consumer. | ||
| * | ||
| * <p>Callers that test a contiguous range of positions should prefer this method over calling |
There was a problem hiding this comment.
not sure if that's helpful, maybe just remove this
There was a problem hiding this comment.
Thanks for feedback.
I've removed it.
| * @param consumer a consumer for the deleted positions in the range | ||
| * @throws IllegalArgumentException if posStart > posEnd | ||
| */ | ||
| default void forEachInRange(long posStart, long posEnd, LongConsumer consumer) { |
There was a problem hiding this comment.
| default void forEachInRange(long posStart, long posEnd, LongConsumer consumer) { | |
| default void forEachInRange(long posStartInclusive, long posEndExclusive, LongConsumer consumer) { |
we use similar naming in RoaringPositionBitmap.setRange(..), so I think being explicit about the ranges is helpful. Please also update the implementing methods
There was a problem hiding this comment.
also I think it's not entirely clear from the method naming that this will only look at deleted positions in the range. forEach itself iterates over all positions, so one would assume that forEachInRange would do the same without checked for deleted positions
There was a problem hiding this comment.
maybe we should make the predicate part of the method parameters
There was a problem hiding this comment.
PositionDeleteIndex.forEach(consumer) only calls the consumer for the position set in the index. So for only the deleted positions. This is the same for the forEachInRange.
Good catch on the naming!
There was a problem hiding this comment.
PositionDeleteIndex.forEach(consumer) only calls the consumer for the position set in the index. So for only the deleted positions. This is the same for the forEachInRange.
sorry I missed that, in that case I'm ok with naming this forEachInRange()
There was a problem hiding this comment.
Renamed in the interface and both implementations.
Thanks to Peter for the clarification -- keeping forEachInRange().
|
|
||
| @Override | ||
| public void forEachInRange(long posStart, long posEnd, LongConsumer consumer) { | ||
| Preconditions.checkArgument( |
There was a problem hiding this comment.
what about just throwing an UOE stating that this isn't supported? Not sure we're adding any value by checking the start/end but essentially not doing anything
There was a problem hiding this comment.
I requested that we should have the same behaviour between the implementations. That is why the check.
I'm not sure that throwing an UOE is a good idea. BaseDeleteLoader uses PositionDeleteIndex.empty() for data files where no position is deleted. We might throw if we start using forEachInRange there, even if the correct response is not to call the consumer at all.
There was a problem hiding this comment.
Agreed
Thanks for the BaseDeleteLoader pointer, that is the case I had in mind too. Keeping it as is: the override also avoids the default implementation walking the whole range calling isDeleted, which is always false here.
| @Test | ||
| public void testForEachInRangeEmptyRange() { | ||
| PositionDeleteIndex index = indexOf(0L, 1L, 2L); | ||
| assertThat(collect(index, 0L, 0L)).isEmpty(); |
There was a problem hiding this comment.
we also need tests where end < start
There was a problem hiding this comment.
Added testForEachInRangeInvalidRange. The range is validated in three separate places -- the default method, BitmapPositionDeleteIndex (via RoaringPositionBitmap) and EmptyPositionDeleteIndex -- so the test covers all three. I verified it by disabling each precondition in turn and confirming the test fails for each.
|
@nastra |
| return positions; | ||
| } | ||
|
|
||
| private static List<Long> collect(PositionDeleteIndex index, long posStart, long posEnd) { |
There was a problem hiding this comment.
maybe name this forEachInRange or positionsInRange?
There was a problem hiding this comment.
Renamed to positionsInRange. Parameters now match the other helper too.
| RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); | ||
| bitmap.setRange(10L, 20L); | ||
|
|
||
| // the beginning is inclusive and the end is exclusive |
| } | ||
| } | ||
|
|
||
| private static List<Long> collectInRange( |
There was a problem hiding this comment.
same naming as in the other test
There was a problem hiding this comment.
Renamed to positionsInRange as well.
nastra
left a comment
There was a problem hiding this comment.
left a few minor comments but generally LGTM
|
Merged to main. @JeonDaehong: Please don't forget to create a PR to backport the changes to the other Spark branches. |
|
Thanks for merging! I’ll create the backport PRs for the other Spark branches. 👍 |
Summary:
Vectorized reads probe the position delete index once per row, but the positions in a batch are a contiguous ascending range. The index can be traversed once instead.
Significant gain when few columns are projected. Not visible end to end on wide projections, where the delete check is a small share of scan CPU.
Needs a new API: PositionDeleteIndex.forEachInRange
In details:
When this helps. Scans with a narrow projection of integer columns, where the query is scan-dominated rather than aggregation-dominated, and the table is written as fewer, larger files. Under those conditions, the end-to-end query was 13-19% faster in our measurements (up to 29% for a single projected column on a dedicated instance). The gain is largest just below the Roaring array/bitmap boundary at ~6.25% deletes, not at high delete rates. It does not help when a string column is projected (one md5 column costs 8-10 integer columns of decode), when heavy aggregation dominates, or when the table is split into many small files. As a single rule: it is worth it when the delete check is more than ~21% of scan CPU.
ColumnarBatchUtil.buildRowIdMappingandbuildIsDeletedcallPositionDeleteIndex.isDeleted(pos)once for every row in a batch. Positions within a batch are a contiguous ascending range, so every call repeats the same work: extracting the high and low keys, bounds-checking the bitmap array, and binary searching the container array. None of it is amortized across the batch even though every call lands in the same one or two containers.This adds
PositionDeleteIndex.forEachInRange(posStart, posEnd, consumer), which traverses the deleted positions in a range in ascending order. The start is inclusive and the end exclusive, matchingdelete(posStart, posEnd)on the same interface. The default implementation falls back to the per-position loop, andBitmapPositionDeleteIndexoverrides it to resolve the range against the 32-bit bitmaps it covers and walk each once withRoaringBitmap.forAllInRange.ColumnarBatchUtiltakes that path when the scan has no equality deletes; tables with equality deletes keep the existing loop unchanged.The measurements below were performed locally on 8M rows across 4 files, with one column projected and 3 repetitions per configuration in separate JVMs (delete-check CPU samples, async-profiler
ctimer). The results are environment-dependent and are intended as comparative measurements under the same test conditions:The gain is largest just below the array/bitmap boundary, which is where the removed work — a binary search per row — was most expensive.
buildIsDeletedimproves 14.9x–18.9x since it does not need to fill the gaps between deleted positions.Correctness was checked before performance:
count(*),sum(id)andmin/max(id)over 7 tables are identical for both jars, and tables with equality deletes show no regression.This touches
spark/v4.2only, to keep the diff reviewable.ColumnarBatchUtilis byte-identical in v3.5, v4.0 and v4.1, so I will backfill those in a follow-up if this direction looks right.Closes #18026, which has the rest of the measurements — portability across three CPU microarchitectures, local disk and S3, warm and dropped page cache, task parallelism 1 through 16, a distributed cluster, and hardware counters — along with the conditions under which the numbers above do not hold.