Skip to content

Navigation Menu

Sign in
Sign up

GH-51268: [C++][Parquet] Scan DELTA_BINARY_PACKED deltas a vector at a time - #51295

Draft
prtkgaur wants to merge 3 commits into
apache:main from
prtkgaur:gh51268-dbp-vector-prefix-sum
Draft

GH-51268: [C++][Parquet] Scan DELTA_BINARY_PACKED deltas a vector at a time #51295
prtkgaur wants to merge 3 commits into
apache:main from
prtkgaur:gh51268-dbp-vector-prefix-sum

Conversation

@prtkgaur

@prtkgaur prtkgaur commented Sep 10, 2026
edited by github-actions Bot
Loading

Copy link
Copy Markdown

Builds on #51250 and contains its commit, since the gain here depends on it. Only the last
two commits belong to this PR.

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:

  • Carrying the total between registers. The obvious way -- read the last lane into a
    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.
  • The threshold is a lane count, not a type. At two lanes the scan loses, so the vector
    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. NarrowSorted is added by the first of the two commits and holds
non-decreasing values, the shape DELTA_BINARY_PACKED is usually chosen for.

benchmark #51250 this PR vs. main
Decode_Int32_NarrowSorted 73.7 us 45.7 us 1.61x 2.18x
Decode_Int32_Narrow 74.8 us 46.4 us 1.61x 2.15x
Decode_Int32_Wide 83.7 us 53.6 us 1.56x 1.90x
Decode_Int64_NarrowSorted 71.4 us 55.0 us 1.30x 1.47x
Decode_Int64_Narrow 70.9 us 55.2 us 1.28x 1.49x
Decode_Int64_Wide 318.6 us 301.3 us 1.06x 1.07x
Decode_Int32_Fixed 20.4 us 20.5 us 1.00x 0.97x
Decode_Int64_Fixed 31.3 us 31.3 us 1.00x 0.98x

The 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.

...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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@wgtmac wgtmac Awaiting requested review from wgtmac wgtmac will be requested when the pull request is marked ready for review wgtmac is a code owner
@pitrou pitrou Awaiting requested review from pitrou pitrou will be requested when the pull request is marked ready for review pitrou is a code owner

Assignees

No one assigned

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /