Skip to content

Add the DELTA_LENGTH_BYTE_ARRAY nz map consumer and a block-wide delta decode - #24639

Draft
vyasr wants to merge 1 commit into
NVIDIA:mainfrom
vyasr:nz-map/03-delta-length-byte-array
Draft

vyasr wants to merge 1 commit into
NVIDIA:mainfrom
vyasr:nz-map/03-delta-length-byte-array

Conversation

@vyasr

@vyasr vyasr commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

This PR adds the DELTA_LENGTH_BYTE_ARRAY consumer for the nz map, still behind LIBCUDF_PARQUET_NZ_MAP and still off by default. It follows #24501, which added the producer and the DELTA_BINARY_PACKED consumer; classify_nz_map_kind widens by one encoding and the host routes DLBA pages the same way it already routes delta binary ones.

The new kernel is decode_delta_length_byte_array_kernel_with_nz_map. Like the delta binary consumer it reads the map instead of decoding levels, so it has no level-decoding warp and puts the whole block on values. The existing decode_delta_length_byte_array_kernel is unchanged apart from skipping the pages the consumer takes, and we skip launching it entirely when every DLBA page in the subpass has a map.

The other half of this PR is a block-wide delta decode. decode_next_passes_wide decodes one DELTA pass per warp into registers instead of running a single warp through the rolling buffer one pass at a time, with a cross-warp running total to carry the prefix sum between passes. A DELTA block narrower than the thread block, which the spec forbids but init_binary_block accepts, goes through the same loop with fewer warps active. This is what makes the consumer worth having for strings: without it the decode is still serialized through one warp no matter how many threads write values.

Both nz map consumers now reject a page whose map asks for more values than its delta header declares, before writing any output. Without that check neither noticed a short stream: the loop produces a value for every lane of a pass, and the decoder's cursor moves a mini-block at a time, so both run past the declared count. For DLBA that wrote invented lengths whose offsets ran past the character data. The DELTA_BINARY_PACKED consumer from #24501 had the same gap, copying buffer slots the page never wrote, so it is fixed here too.

A validity axis is added to the 512 MiB delta benchmarks so the nullable cases are measurable there; it is skipped for the nested table builder, which does not take a null probability.

Benchmark numbers to follow in a comment; the machine this series is measured on is mid-rebuild.

Contributes to #23965.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@copy-pr-bot

copy-pr-bot Bot commented Oct 15, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

Third of the nz map series, and the first to decode values with every warp of
the block. Still opt-in behind `LIBCUDF_PARQUET_NZ_MAP=1`.

`decode_next_passes_wide` (delta_binary.cuh) decodes one DELTA pass per warp and
returns each lane's value in a register. A single call stops at the end of the
current DELTA block, which is what makes the passes independent: within a block
the header is already parsed into `cur_min_delta` and `cur_bitwidths`, so every
pass is addressable with O(num_warps_t) pointer arithmetic, whereas finding the
next block's first byte needs a serial varint parse of its header.

`active` is how many warps have a pass to decode: `num_warps_t`, or fewer when
the block has fewer left. The spec requires a block to hold a multiple of 128
values, so four passes, and `active` is then always `num_warps_t` -- which holds
only while `num_warps_t` divides those four, hence the static_assert.
`init_binary_block` is weaker than the spec and accepts any block whose
mini-blocks hold a multiple of 32 values, so a narrower block runs the same loop
with fewer warps, and `active` is then also what keeps `mini_block_idx` inside
`cur_bitwidths`.

What this buys over the single-warp path is the serial dependency between a
block's passes: there, each pass ends by writing `last_value` from its last
lane, so pass i+1 cannot start its scan until pass i finishes, and one warp of
four does the unpack. Here all four unpack concurrently and combine through a
per-warp total, which also stops routing values through the decoder's rolling
buffer and cuts the value loop from two block barriers per 32 values to two per
128.

`warp_pass_total` is a parameter sized by `num_warps_t` rather than a member, so
each caller sizes it from its own `decode_block_size` and a kernel running two
decoders can give each its own array. The two prefix sums over it are taken
before the trailing barrier and unrolled over `num_warps_t` with predicated adds:
the caller's loop has no barrier between iterations, so a warp re-entering the
function overwrites its slot and a read placed after the barrier would race it;
and a loop bounded by the runtime `warp_idx` and `active` compiles to a
variable-length ladder over what is a four-element array.

Lanes of an active warp past the stream's `value_count` still carry an ordinary
index and a value built from the block's min delta alone, and the decoder's
cursor advances a mini-block at a time, so neither notices when a stream holds
fewer values than a caller wants. Both nz map consumers therefore compare the
map's `nz_count` against `value_count` before writing anything and report
`DATA_STREAM_OVERRUN` if the stream is short. Without that, a page whose
definition levels ask for more values than its delta header declares read
without error: the DLBA consumer wrote invented lengths whose offsets ran past
the character data, and the DELTA_BINARY_PACKED consumer from NVIDIA#24501 copied
rolling-buffer slots the page never wrote. The count includes any valid values
before `first_row`, which is the prefix of the stream a read needs, so a bounded
read that uses fewer values than the page declares is unaffected. The DLBA
consumer keeps a post-loop check as a backstop, and returns before the offsets
scan rather than running it over unwritten lengths.

`init_binary_block` now rejects a block size that does not fit in 32 bits.
`block_size` is a uleb128 and so is 64 bits wide, while `values_per_mb` and the
pass counts derived from it are 32: a crafted header can divide evenly in 64 bits
and narrow to zero, leaving `passes_per_mini_block == 0` and a loop thread 0
cannot leave. The single-warp path never derived a pass count from `block_size`,
so this is reachable only through the new loop.

A mini-block's byte length is now computed in 64 bits. The bitwidth comes from
the page unvalidated, so up to 255, and `values_per_mb` is bounded only by what
fits in 32, so `bitwidth * values_per_mb / 8` could wrap and place the
mini-block's start below the block, where the `block_end` checks still pass and
the unpack reads the wrong bytes. The wide loop walks further ahead than the
single-warp path, which is what made this worth closing now; the same expression
on the single-warp path is widened too rather than left as its twin.

`classify_nz_map_kind` now accepts a second kernel mask, so flat
DELTA_LENGTH_BYTE_ARRAY pages get a map. Routing matches the DELTA_BINARY pair:
the consumer runs on the producer's stream, so it is ordered behind it without
an event, and the level-decoding kernel's skip is gated on the same flag that
decides whether the consumer is launched. The host now records which encodings
still have a page without a map, so each level-decoding kernel is launched only
when its encoding has work for it rather than on one bool scoped to
DELTA_BINARY. `decode_delta_length_byte_array_kernel` keeps its name and its
decode path; it gains that flag and the early return.

Tests: `NzMapMatchesLevelDecoder` gains a nullable DELTA_LENGTH_BYTE_ARRAY
column, without which its consumer is never selected. `NzMapNarrowDeltaBlock`
covers blocks of 32 and 64 values, where `active` clamps below `num_warps_t`,
and a 256-value block where it does not. `NzMapRejectsShortDeltaStream` builds
pages that ask for more values than their stream declares, for both consumers,
plus controls that read a stream holding exactly enough and a bounded read
holding more than it needs.

The two 512 MiB delta benchmarks gain a `validity` axis. The existing nullable
sweep is 8 MiB, which is L2-resident on an H100, so the nz map's extra traffic of
4 bytes per value could not show up there -- and for a string column that cost is
carried against a much larger payload than the fixed-width case. The nested path
builds its own data profile and ignores `validity`, so those cells are skipped
rather than run three times over on identical data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vyasr
vyasr force-pushed the nz-map/03-delta-length-byte-array branch from e0dc837 to 662ab36 Compare October 16, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

libcudf Affects libcudf (C++/CUDA) code.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant