-
Notifications
You must be signed in to change notification settings - Fork 4.3k
GH-51268: [C++][Parquet] Scan DELTA_BINARY_PACKED deltas a vector at a time - #51295
Draft
prtkgaur wants to merge 3 commits into
Draft
GH-51268: [C++][Parquet] Scan DELTA_BINARY_PACKED deltas a vector at a time #51295prtkgaur wants to merge 3 commits into
prtkgaur wants to merge 3 commits into
Conversation
...ne call The miniblocks of a DELTA_BINARY_PACKED block are packed back to back with no padding between them, so a run of miniblocks that share a bit width is bit-identical to a single longer run at that width. GetInternal called the bit unpacker once per miniblock all the same, which with the default geometry is one call per 32 values - mostly per-call setup. Look ahead over the block's stored bit widths and extend the current call over each following miniblock that has the same width and that the caller has room for in full. A miniblock joins the run only when its width equals the current delta_bit_width_, which InitMiniBlock has already validated, and the run also stops at the end of the block. Add a test over the width patterns that decide where a run starts and stops, and read at a batch size that stops partway through a coalesced run. On the decode benchmarks already in the tree this is 1.17x to 1.33x on top of the previous commit wherever the unpacker's per-call cost is a meaningful share of the work. Decoded values are identical; no encoded byte changes.
The two non-degenerate DELTA_BINARY_PACKED decode arms both hold values in random order, so their deltas are random and the frame sits well below zero. The columns this encoding is chosen for are usually non-decreasing, where every delta is non-negative instead and the frame is at or near zero. Add an arm whose deltas come from the same 1000-wide range as the existing narrow one but accumulate, so the two differ in the order of the values at a nearly unchanged packed width.
The prefix sum that turns deltas back into values does one addition per value, and each one waits on the value before it, so the loop is bound by that chain rather than by how much arithmetic the machine can retire. Replace it with an inclusive scan, which shifts and adds a vector to itself once per power of two and so turns a vector of deltas into a vector of running sums. That shortens the chain from one addition per value to one per vector. The frame is added before the scan, which makes its running multiple fall out of the scan itself, and the previous vector's last value is carried forward in a vector register -- reading it out into a general-purpose register instead costs several times what the scan saves. The scan runs only where a register holds at least four values, the narrowest width measured to win; below that, and on the tail, the value-at-a-time loop does the work. xsimd is already a dependency of this target, so nothing is added to the build. Decode throughput over the previous commit, on the arms whose values are 32-bit: 1.26x on non-decreasing values, 1.24x in random order, and 1.23x at 31 bits per delta. The 64-bit arms hold two values in a register here and are unchanged. Together with the two commits before it this is 2.17x, 2.11x and 1.89x over main, or 2.64 to 5.73 GB/s of decoded output on non-decreasing values.
@github-actions
github-actions
Bot
added
awaiting review
Awaiting review
Component: Parquet
Component: C++
and removed
awaiting review
Awaiting review
labels
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
Rationale for this change
Once equal-width miniblocks are unpacked in one call, what is left per value is the prefix
sum: a chain of dependent additions with the running total carried from each to the next. A
log-step inclusive scan computes it a register at a time in log2(lanes) shifted additions,
and adding the frame of reference before the scan makes its running multiple fall out of
the scan rather than needing a multiply per lane.
What changes are included in this PR?
The value-at-a-time loop becomes a helper that scans whole registers and finishes the
remainder one value at a time. Every term stays in the unsigned type, so the wrapping the
format specifies is unchanged and decoded values are identical. Two details are worth a
reviewer's attention:
general-purpose register, re-broadcast it -- is slower than the scalar loop it replaces,
so the helper keeps the carry in a vector register and broadcasts the last lane with a
shuffle. Timings in a comment below.
loop is compiled only where a register holds four or more values. At Arrow's default
128-bit baseline that scans 32-bit values and leaves 64-bit ones on today's loop; a wider
baseline scans both. So the 64-bit vector loop is not built by a default configuration and
no CI job executes it as things stand.
This overlaps #51249, whose effect the helper gets for free by taking the frame and the
running total by value, so whichever lands second needs a trivial rebase -- and it is why the
64-bit arms gain here without being vectorized.
Are these changes tested?
A new typed test walks every residual bit width for both integer widths, at each width over
enough lengths to leave every remainder a register-sized group can leave, so each width is
decoded through the vector loop, through the remainder, and across the hand-off. Its deltas
alternate between the frame and the widest value the width holds, which pins the stored width,
keeps a non-zero frame in play, and wraps the running total repeatedly. Three mutations turn
it red: dropping the frame's running multiple and losing the carry fail the 32-bit
instantiation, and ending the remainder loop one value early fails both.
Benchmark
Same setup as #51250.
NarrowSortedis added by the first of the two commits and holdsnon-decreasing values, the shape DELTA_BINARY_PACKED is usually chosen for.
Decode_Int32_NarrowSortedDecode_Int32_NarrowDecode_Int32_WideDecode_Int64_NarrowSortedDecode_Int64_NarrowDecode_Int64_WideDecode_Int32_FixedDecode_Int64_FixedThe gain needs #51250 underneath it: on #51249 alone the same kernel measures 0.98x to 1.03x
across the 32-bit arms, because per-register setup only amortizes over the longer runs
coalescing produces.
Are there any user-facing changes?
No. No API change, no format change, and decoded values are identical.