Skip to content

Optimize RLE decoding using a warp-balanced chunking approach - #23271

Open
vyasr wants to merge 37 commits into
rapidsai:mainfrom
vyasr:opt/rle-chunked-expand
Open

Optimize RLE decoding using a warp-balanced chunking approach#23271
vyasr wants to merge 37 commits into
rapidsai:mainfrom
vyasr:opt/rle-chunked-expand

Conversation

@vyasr

@vyasr vyasr commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

This PR implements an alternative approach for the RLE decoding. The old approach used a producer-consumer model where warp 0 populates a ring buffer of runs for other warps to pick off. That leads to two sources of imbalance:

  1. Warp 0 becomes a bottleneck for the other warps because production can't keep up with decode.
  2. Different warps operate on runs of different lengths, leading to interwarp imbalances even among consumer warps.

With the new approach, the full stream is split into chunks of a fixed size (determined at compile time). Within each chunk, thread 0 does a serial pass through the data to find all of the RLE headers and populates the associated splits and metadata (RLE vs bit-packed) into a shared memory array. Then, all warps can cooperatively read through all of that data. Since data is parsed by chunk rather than by run, there is no longer any imbalance between warps. Warps keep track of boundaries via the same shared memory arrays, and therefore warps can start in the middle of any run and completely traverse runs short enough to fit within their chunks.

Checklist

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

@vyasr vyasr self-assigned this Jul 15, 2026
@vyasr
vyasr requested review from a team as code owners July 15, 2026 04:35
@vyasr vyasr added the libcudf Affects libcudf (C++/CUDA) code. label Jul 15, 2026
@vyasr
vyasr requested review from lamarrr and shrshi July 15, 2026 04:35
@vyasr vyasr added Performance Performance related issue improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Jul 15, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the CMake CMake build issue label Jul 15, 2026
vyasr added 17 commits July 15, 2026 04:35
…chunked expand

Readability-only. Ptxas SMEM unchanged at 5168 B for preprocess_levels_kernel on SM 8.0. All ctest -R PARQUET pass including PARQUET_RLE_CHUNKED_EQUIVALENCE_TEST. No performance change expected.
Dead scaffolding from earlier iterations of the chunked-expand path.
The actual decode_next_chunked function allocates gen_out_off and gen_meta
directly as __shared__ arrays; this struct was never instantiated anywhere
in the tree.

No functional change. SMEM/register footprint of preprocess_levels_kernel
unchanged (5168 B / 48 reg on SM 8.0). All PARQUET tests pass.
Both decode_next_ring and decode_next_chunked had the same all-zeros

short-circuit for level_bits == 0. Pull it up into decode_next so the

helpers stay focused on the general RLE path. Uses cur_values-relative

addressing (the ring version's form was only correct because cur_values

was always 0 on entry in practice).
…nce test

The rle_stream::init signature added a Group parameter as part of the
SMEM-staging refactor; update the chunked-equivalence test to pass
cg::this_thread_block() to match.
A100 nvbench parquet_read_decode sweep on LIST/STRUCT/STRING shows k=1024
is 80-95% faster than k=512 on the chunked-expand path in
preprocess_levels_kernel. k=2048 saturates or regresses ~10% on LIST due
to occupancy pressure, so 1024 is the sweet spot.

Keep k=512 on sm_70 (V100) where the larger SMEM footprint would exceed
the preprocess_levels_kernel budget. H100/Blackwell tiers are left as
TODO in the comment; sm_80+ tier applies until they are measured.

SMEM cost: (2 * kGenRuns + 1) * 4 bytes = 8196 B at k=1024 (vs 4100 B
at k=512). Well within A100/H100 budgets.
level_mask is invariant across the whole decode_next_chunked call
(depends only on level_bits, which is class state). Move it out to the
top of the function.

Verified with cuobjdump --dump-resource-usage that this is a no-op for
register counts on sm_80/86/90 - the compiler was already hoisting it -
so this is a readability/intent-signalling change only.
The earlier sm_80+ bump to 1024 was justified by an isolated kGenRuns
sweep whose k=512 STRUCT baseline was in a pathological regime (245-415
ms with 46-75% noise, hitting nvbench timeouts). A full 36-config
parquet_read_decode A/B against upstream/main on A100 shows that k=1024
vs k=512 differences are within noise, and a matching sweep on H100
found the same pattern - k=2048 was numerically best but within noise of
both k=512 and k=1024.

Since no architecture shows a meaningful preference in real workloads,
drop the arch-adaptive split and use a single kGenRuns=512 everywhere.
This also reclaims 4 KiB of SMEM per block on sm_80+, easing occupancy
pressure on register-constrained architectures like sm_86.
Rename the identifiers we introduced with the chunked-expand path to
match the snake_case convention used throughout libcudf:

  kGenRuns   -> max_runs_per_chunk  (also better describes what the
                                     constant controls)
  kWarps     -> num_warps           (matches existing num_rle_stream_*
                                     naming)
  gen_out_off, gen_meta (and their _v span views) -> chunk_out_off,
    chunk_meta                      (the opaque "gen_" prefix came from
                                     "generated in phase 1"; "chunk_"
                                     parallels the existing s_chunk_*
                                     shared vars produced alongside them)

Verified with build-cudf-cpp and PARQUET_RLE_CHUNKED_EQUIVALENCE_TEST (8/8).
warp_fill was only used from the RLE-run arm of the chunked-expand
loop. Inline it at the call site and drop the helper. This removes the
now-unnecessary __forceinline__ / __restrict__ annotations and the
comments that referenced warp_fill by name; the loop is short enough
to be self-explanatory at the call site.
Sweeps of {256, 512, 1024, 2048, 4096} on A100, H100, and B200 all show
1024 either as the numerical optimum or within noise of it (H100 leaned
toward 2048 by a small margin, still within noise). The delta over 512
is small - typically a few percent - but 1024 is the consistent winner
across the modern arches, so specialize by __CUDA_ARCH__.

sm_70/sm_75 stay at 512 because 1024 does not fit the
preprocess_levels_kernel SMEM budget on those older architectures.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The Parquet RLE stream gains an optional chunked-expand decoder with partial-run resumption and configurable staging capacity. Parquet level preprocessing selects this mode and supplies the staging size during repetition- and definition-level decoder initialization.

Changes

Parquet RLE chunked decoding

Layer / File(s) Summary
Decoder contract and stream state
cpp/src/io/parquet/rle_stream.cuh
The stream template and init() contract add chunked-mode selection, configurable staging capacity, anchored payload offsets, and partial-run state.
Decode path implementation and dispatch
cpp/src/io/parquet/rle_stream.cuh
The ring decoder is extracted, chunked expansion parses and cooperatively expands runs, and decode_next() dispatches between the two paths.
Parquet preprocessing integration
cpp/src/io/parquet/decode_preprocess.cu
Level preprocessing selects the chunked stream and passes its staging capacity when initializing repetition- and definition-level decoders.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • rapidsai/cudf#23090: Continues shared-memory RLE stream work in Parquet preprocessing and updates decoder initialization parameters.

Suggested labels: cuIO

Suggested reviewers: shrshi, lamarrr

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: optimizing RLE decoding with a warp-balanced chunking approach.
Description check ✅ Passed The description directly matches the implemented RLE decoding redesign and explains the chunked, warp-balanced approach.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
cpp/tests/io/parquet_rle_chunked_equivalence_test.cu (2)

6-20: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing explicit gtest include.

TEST_F/EXPECT_EQ are used directly but the file doesn't include <cudf_test/cudf_gtest.hpp> (or raw gtest/gtest.h); it relies on a transitive include via cudf_test/base_fixture.hpp.

As per coding guidelines, "Test files must include #include <cudf_test/cudf_gtest.hpp> instead of raw gtest/gtest.h."

🧪 Proposed fix
 `#include` "../../src/io/parquet/rle_stream.cuh"

+#include <cudf_test/cudf_gtest.hpp>
 `#include` <cudf_test/base_fixture.hpp>
 `#include` <cudf_test/testing_main.hpp>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/tests/io/parquet_rle_chunked_equivalence_test.cu` around lines 6 - 20,
Add the explicit <cudf_test/cudf_gtest.hpp> include to
parquet_rle_chunked_equivalence_test.cu alongside the other cudf_test headers,
so TEST_F and EXPECT_EQ do not rely on the transitive inclusion from
base_fixture.hpp.

Source: Coding guidelines


179-186: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

ManyShortRepeatedRuns doesn't reliably cross the chunk boundary on sm_80+.

600 short runs is below max_runs_per_chunk (1024) on sm_80+, so the outer multi-chunk loop in decode_next_chunked (which is the core new logic this PR adds) may not actually be exercised on the modern architectures this feature targets, only on sm_70/75 (max 512). Consider sizing the run count off cudf::io::parquet::detail::max_runs_per_chunk directly (e.g. max_runs_per_chunk * 2 + 1) so the test deterministically crosses the boundary regardless of the GPU running CI.

As per coding guidelines, "Test suites should cover edge cases such as empty input, null values, sliced columns, boundary sizes, and multi-block sizes."

TEST_F(ParquetRleChunkedEquivalenceTest, ManyShortRepeatedRuns)
{
  using cudf::io::parquet::detail::max_runs_per_chunk;
  int const num_runs = max_runs_per_chunk * 2 + 1;  // guarantees >1 chunk on any arch
  std::vector<uint8_t> encoded;
  for (int i = 0; i < num_runs; ++i) {
    append_repeated(encoded, 1, i & 15, 4);
  }
  run_case<uint8_t>(encoded, 4, num_runs, num_runs);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/tests/io/parquet_rle_chunked_equivalence_test.cu` around lines 179 - 186,
Update the ManyShortRepeatedRuns test to derive its run count from
cudf::io::parquet::detail::max_runs_per_chunk, using a value greater than two
chunk capacities (such as 2 * max_runs_per_chunk + 1). Use that count in the
encoding loop and run_case expectations so the test deterministically exercises
decode_next_chunked’s multi-chunk path on every supported architecture.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@cpp/tests/io/parquet_rle_chunked_equivalence_test.cu`:
- Around line 6-20: Add the explicit <cudf_test/cudf_gtest.hpp> include to
parquet_rle_chunked_equivalence_test.cu alongside the other cudf_test headers,
so TEST_F and EXPECT_EQ do not rely on the transitive inclusion from
base_fixture.hpp.
- Around line 179-186: Update the ManyShortRepeatedRuns test to derive its run
count from cudf::io::parquet::detail::max_runs_per_chunk, using a value greater
than two chunk capacities (such as 2 * max_runs_per_chunk + 1). Use that count
in the encoding loop and run_case expectations so the test deterministically
exercises decode_next_chunked’s multi-chunk path on every supported
architecture.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0c826ec9-5852-4d01-9114-574ad1224e6b

📥 Commits

Reviewing files that changed from the base of the PR and between a5b8cd7 and 5b6bc60.

📒 Files selected for processing (4)
  • cpp/src/io/parquet/decode_preprocess.cu
  • cpp/src/io/parquet/rle_stream.cuh
  • cpp/tests/CMakeLists.txt
  • cpp/tests/io/parquet_rle_chunked_equivalence_test.cu

vyasr and others added 6 commits July 25, 2026 06:33
Applies the mechanical batch of review comments from PR rapidsai#23271:

- Remove stale `// otherwise, full decode.` comment in decode_next_ring
  (leftover from a removed branch).
- Replace hand-written `(x + N - 1) / N` with cudf::util::div_rounding_up_{safe,unsafe}
  in decode_next_chunked (level_bits->byte width, per-warp chunk slice size).
- Rename single-letter locals in decode_next_chunked for readability:
    Phase 1: n -> num_runs, co -> run_prefix_end, cnt -> run_len, meta -> run_desc
    Phase 2: a -> first_run_idx, r -> run_idx, r_lo/r_hi -> run_start_out/run_end_out,
             seg_lo/seg_hi -> out_lo/out_hi, meta -> run_desc
  Comments referring to those names updated accordingly.
- Add missing `#include <cudf_test/cudf_gtest.hpp>` in the RLE chunked
  equivalence test.

No functional changes. PARQUET_RLE_CHUNKED_EQUIVALENCE_TEST passes (8/8),
and TPC-H SF1000 sum-of-min iteration times are within noise of the
pre-change baseline.
Per PR rapidsai#23271 review (mhaseeb123): the existing parquet reader test
suite (PARQUET_TEST, PARQUET_DELETION_VECTORS_TEST, STREAM_IO_PARQUET_TEST)
already exercises both the ring-buffer and chunked-expand rle_stream
paths through real parquet page data, so the standalone equivalence
harness is redundant. Removing it eliminates 213 lines of unit-test
scaffolding that duplicates existing coverage.

The remaining parquet tests continue to exercise the chunked-expand
path via the use_chunked_expand=true rle_stream instantiation used
during page decode.
Addresses PR rapidsai#23271 review comments #3, #4, rapidsai#11 from mhaseeb123:

- Template `decode_next_chunked` on a cooperative_groups `Group` type,
  matching the pattern already used by `rle_stream::init`. The public
  entrypoint `decode_next(int t, int count)` forwards to
  `decode_next_chunked(cg::this_thread_block(), count)` so no call sites
  in decode_fixed.cu / decode_preprocess.cu change.
- Replace hand-computed lane and warp indexing with cg primitives:
    t & 31             -> warp.thread_rank()
    t >> 5             -> warp.meta_group_rank()   (renamed local: warp_id)
    hardcoded 32       -> warp.size()
    num_rle_stream_decode_threads / warp_size
                       -> warp.meta_group_size()
  where `warp = cg::tiled_partition<cudf::detail::warp_size>(group)`.
- Replace the two `__syncthreads()` calls with `group.sync()`.
- Replace `if (t == 0) { ... }` with `cg::invoke_one(group, [&](){ ... });`
  and refresh the surrounding comment to match.
- Add a `namespace cg = cooperative_groups;` alias inside
  cudf::io::parquet::detail, following the convention already used in
  decode_preprocess.cu.

Codegen is intended to be a no-op: `cg::tiled_partition<32>(block)` and
its accessors are all `_CG_STATIC_QUALIFIER` inline, `group.sync()` on a
`thread_block` lowers to `__syncthreads()`, and `cg::invoke_one` on a
block lowers to `if (group.thread_rank() == 0) fn()` (no implicit sync)
per <cooperative_groups/details/invoke.h>. Comparing object files
built pre- and post-refactor confirms this:
  decode_fixed.cu.o           9,715,392 -> 9,715,392   (identical)
  page_string_decode.cu.o     1,768,912 -> 1,768,912   (identical)
  decode_preprocess.cu.o        807,048 ->   812,848   (+0.7% debug info)

Verification:
- PARQUET_TEST 481/481 pass (1 pre-existing skip)
- PARQUET_DELETION_VECTORS_TEST 6/6 pass
- STREAM_IO_PARQUET_TEST 4/4 pass
- TPC-H SF1000 sum-of-min: 221.09s vs 222.46s pre-refactor baseline
  (-0.6%, within run-to-run noise).
Addresses PR rapidsai#23271 review comment #1 from mhaseeb123: mirror the
cooperative-groups treatment that commit 041c4ab applied to
decode_next_chunked in the pre-existing ring path.

- Template `decode_next_ring` on a cooperative_groups `Group` type and
  take `Group const& group` instead of `int t`.  The dispatcher
  `decode_next(int t, int count)` still forwards from a raw thread id
  by calling `decode_next_ring(cg::this_thread_block(), count)`, so
  call sites in decode_fixed.cu / decode_preprocess.cu are untouched.
- Replace hand-computed indexing with cg primitives:
    t / warp_size  -> warp.meta_group_rank()   (warp_id)
    t % warp_size  -> warp.thread_rank()       (warp_lane)
  where `warp = cg::tiled_partition<cudf::detail::warp_size>(group)`.
- Replace the three `__syncthreads()` calls with `group.sync()` and
  the single `__syncwarp()` with `warp.sync()`.
- Replace the outer `if (t == 0) { ... }` block-level shared-var
  initialisation with `cg::invoke_one(group, [&](){ ... })`.
- Replace the two `if (warp_lane == 0) { ... }` warp-level leader
  blocks with `cg::invoke_one(warp, [&](){ ... })`.

As with the chunked variant, cg primitives on a `thread_block` lower to
the same instructions as the original hand-coded versions:
`warp.thread_rank()` -> `threadIdx.x & 31`, `group.sync()` ->
`__syncthreads()`, `cg::invoke_one` on a block/tile ->
`if (group.thread_rank() == 0) fn()` (no implicit sync, verified in
<cooperative_groups/details/invoke.h>).  libcudf.so grows by 56 KiB
(0.005%) due to the additional lambda debug info; the code section is
functionally unchanged.

Verification:
- PARQUET_TEST 481/481 pass (1 pre-existing skip)
- PARQUET_DELETION_VECTORS_TEST 6/6 pass
- STREAM_IO_PARQUET_TEST 4/4 pass
- TPC-H SF1000 sum-of-min: 213.44s vs 222.46s pre-refactor baseline
  (-4.1%, i.e. no regression; the ring path is exercised by
  decode_fixed.cu dict/bool streams and by decode_preprocess.cu
  repetition-level decoding, so every query in the benchmark touches
  this code).
Rewrite the phase-2 expand loop so each lane iterates over its own
output positions and walks run_idx forward, instead of the warp
looping over runs. Per pmattione review comment on PR rapidsai#23271.

Design:
- Each lane owns positions p = lo + lane + k * warp.size().
- run_idx is set once via binary search on the lane's initial p,
  then advanced by linear walk (usually 0 steps when still in the
  same run).
- The old per-run inner loop left most of the warp idle on short
  runs; this version keeps all 32 lanes writing every iteration.

Amortization:
- Total run-advances by all 32 lanes over the slice equals
  num_runs_in_slice (each boundary crossed once). Divided by 32
  lanes in parallel, this is (num_runs_in_slice / 32) warp cycles
  of linear-walk cost across the whole slice - a rounding error
  against payload work.
- Long-run pages: zero-step inner walks, same access pattern as
  before. No regression.

TPC-H SF1000 A/B against the prior HEAD (post-cg baseline
213.440s sum-of-min):
- New: 208.841s sum-of-min (-2.15%).
- Big wins on dictionary-heavy queries: Q14 -11.5%, Q16 -12.6%,
  Q19 -12.5%, Q13 -11.2%.
- Q18 +5.06% and Q21 +4.39% are the expected small tradeoff on
  long-run-heavy lineitem aggregations, where per-position setup
  is paid on every iteration instead of once per run. Net effect
  is still positive because the short-run wins dominate.

Parquet gtests: PARQUET_TEST 481/481 (+1 pre-existing skip),
PARQUET_DELETION_VECTORS_TEST 6/6, STREAM_IO_PARQUET_TEST 4/4.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@vyasr

vyasr commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

On B200 the chunked-expand path is faster on all the microbenchmarks I tested with a geomean speedup of -2.4% across the full suite. The largest wins are, as expected, on the cardinality=0, run_length=1 LIST configs - up to -11% on parquet_read_decode, -9% on parquet_read_chunks / parquet_read_subrowgroup_chunks, and -5.7% on parquet_read_fixed_width_struct - where the ring-buffer path leaves lanes idle.

Full nvbench_compare.py output - all 40 configs, B200, GPU-time reference (baseline) vs. candidate

Comparison direction: Ref = baseline (rle_stream<..., false>, ring-buffer path), Cmp = candidate (rle_stream<..., true>, chunked path). Negative %Diff = candidate is faster.

parquet_read_subrowgroup_chunks

[0] NVIDIA B200

T io_type cardinality run_length chunk_read_limit pass_read_limit data_size row_group_size_bytes row_group_size_rows Ref Time Ref Noise Cmp Time Cmp Noise Diff %Diff Status
LIST DEVICE_BUFFER 0 1 0 0 536870912 0 0 12.935 ms 0.43% 11.769 ms 0.23% -1165.956 us -9.01% FAST
LIST DEVICE_BUFFER 1000 1 0 0 536870912 0 0 14.310 ms 0.29% 14.188 ms 0.28% -122.651 us -0.86% FAST
LIST DEVICE_BUFFER 0 32 0 0 536870912 0 0 12.732 ms 0.29% 12.549 ms 0.21% -182.733 us -1.44% FAST
LIST DEVICE_BUFFER 1000 32 0 0 536870912 0 0 12.459 ms 0.25% 12.327 ms 0.21% -131.886 us -1.06% FAST
LIST DEVICE_BUFFER 0 1 500000 0 536870912 0 0 177.615 ms 0.46% 174.849 ms 0.02% -2766.019 us -1.56% FAST
LIST DEVICE_BUFFER 1000 1 500000 0 536870912 0 0 159.227 ms 0.37% 157.525 ms 0.16% -1702.182 us -1.07% FAST
LIST DEVICE_BUFFER 0 32 500000 0 536870912 0 0 168.299 ms 0.19% 167.429 ms 0.18% -869.760 us -0.52% FAST
LIST DEVICE_BUFFER 1000 32 500000 0 536870912 0 0 166.106 ms 1.59% 165.119 ms 0.13% -986.718 us -0.59% FAST
LIST DEVICE_BUFFER 0 1 0 500000 536870912 0 0 410.169 ms 0.04% 405.354 ms 0.16% -4814.972 us -1.17% FAST
LIST DEVICE_BUFFER 1000 1 0 500000 536870912 0 0 354.901 ms 0.04% 353.751 ms 0.09% -1150.616 us -0.32% FAST
LIST DEVICE_BUFFER 0 32 0 500000 536870912 0 0 229.962 ms 0.01% 224.759 ms 0.03% -5202.841 us -2.26% FAST
LIST DEVICE_BUFFER 1000 32 0 500000 536870912 0 0 252.426 ms 0.04% 246.873 ms 0.19% -5552.954 us -2.20% FAST
LIST DEVICE_BUFFER 0 1 500000 500000 536870912 0 0 533.773 ms 0.06% 531.347 ms 0.03% -2425.964 us -0.45% FAST
LIST DEVICE_BUFFER 1000 1 500000 500000 536870912 0 0 443.766 ms 0.07% 444.835 ms 0.07% 1.069 ms 0.24% SLOW
LIST DEVICE_BUFFER 0 32 500000 500000 536870912 0 0 343.355 ms 0.03% 340.103 ms 0.12% -3251.587 us -0.95% FAST
LIST DEVICE_BUFFER 1000 32 500000 500000 536870912 0 0 362.383 ms 0.05% 358.248 ms 0.08% -4134.705 us -1.14% FAST

parquet_read_fixed_width_struct

[0] NVIDIA B200

data_type io_type compression_type cardinality run_length data_size row_group_size_bytes row_group_size_rows Ref Time Ref Noise Cmp Time Cmp Noise Diff %Diff Status
STRUCT DEVICE_BUFFER NONE 0 1 536870912 0 0 6.019 ms 0.40% 5.715 ms 0.50% -304.312 us -5.06% FAST
STRUCT DEVICE_BUFFER NONE 1000 1 536870912 0 0 7.321 ms 0.50% 6.907 ms 0.30% -413.992 us -5.66% FAST
STRUCT DEVICE_BUFFER NONE 0 32 536870912 0 0 6.564 ms 0.57% 6.270 ms 0.37% -293.521 us -4.47% FAST
STRUCT DEVICE_BUFFER NONE 1000 32 536870912 0 0 6.592 ms 0.66% 6.286 ms 0.79% -305.872 us -4.64% FAST

parquet_read_decode

[0] NVIDIA B200

data_type io_type compression_type cardinality run_length data_size row_group_size_bytes row_group_size_rows Ref Time Ref Noise Cmp Time Cmp Noise Diff %Diff Status
LIST DEVICE_BUFFER NONE 0 1 536870912 0 0 10.176 ms 0.27% 9.053 ms 0.17% -1123.714 us -11.04% FAST
LIST DEVICE_BUFFER NONE 1000 1 536870912 0 0 12.403 ms 0.21% 12.399 ms 0.46% -4.368 us -0.04% SAME
LIST DEVICE_BUFFER NONE 0 32 536870912 0 0 11.302 ms 0.19% 11.231 ms 0.48% -70.714 us -0.63% FAST
LIST DEVICE_BUFFER NONE 1000 32 536870912 0 0 11.183 ms 0.21% 11.072 ms 0.23% -111.383 us -1.00% FAST

parquet_read_chunks

[0] NVIDIA B200

T io_type cardinality run_length chunk_read_limit data_size row_group_size_bytes row_group_size_rows Ref Time Ref Noise Cmp Time Cmp Noise Diff %Diff Status
LIST DEVICE_BUFFER 0 1 0 536870912 0 0 12.931 ms 0.39% 11.764 ms 0.22% -1166.752 us -9.02% FAST
LIST DEVICE_BUFFER 1000 1 0 536870912 0 0 14.312 ms 0.98% 14.199 ms 0.31% -112.379 us -0.79% FAST
LIST DEVICE_BUFFER 0 32 0 536870912 0 0 12.692 ms 0.19% 12.555 ms 0.24% -136.163 us -1.07% FAST
LIST DEVICE_BUFFER 1000 32 0 536870912 0 0 12.491 ms 0.19% 12.299 ms 0.16% -191.536 us -1.53% FAST
LIST DEVICE_BUFFER 0 1 500000 536870912 0 0 536.629 ms 0.09% 526.314 ms 0.14% -10315.759 us -1.92% FAST
LIST DEVICE_BUFFER 1000 1 500000 536870912 0 0 422.651 ms 0.04% 419.647 ms 0.08% -3003.784 us -0.71% FAST
LIST DEVICE_BUFFER 0 32 500000 536870912 0 0 345.866 ms 0.03% 337.628 ms 0.07% -8238.361 us -2.38% FAST
LIST DEVICE_BUFFER 1000 32 500000 536870912 0 0 365.392 ms 0.04% 357.786 ms 0.13% -7606.744 us -2.08% FAST
STRUCT DEVICE_BUFFER 0 1 0 536870912 0 0 14.705 ms 0.36% 14.381 ms 0.50% -324.074 us -2.20% FAST
STRUCT DEVICE_BUFFER 1000 1 0 536870912 0 0 10.956 ms 0.75% 10.598 ms 0.78% -358.173 us -3.27% FAST
STRUCT DEVICE_BUFFER 0 32 0 536870912 0 0 17.369 ms 0.40% 17.117 ms 0.50% -251.305 us -1.45% FAST
STRUCT DEVICE_BUFFER 1000 32 0 536870912 0 0 10.936 ms 0.74% 10.609 ms 0.50% -327.587 us -3.00% FAST
STRUCT DEVICE_BUFFER 0 1 500000 536870912 0 0 158.617 ms 0.14% 158.957 ms 0.36% 339.655 us 0.21% SLOW
STRUCT DEVICE_BUFFER 1000 1 500000 536870912 0 0 115.347 ms 0.27% 111.686 ms 0.35% -3661.206 us -3.17% FAST
STRUCT DEVICE_BUFFER 0 32 500000 536870912 0 0 164.191 ms 0.45% 159.679 ms 0.38% -4511.743 us -2.75% FAST
STRUCT DEVICE_BUFFER 1000 32 500000 536870912 0 0 118.041 ms 0.26% 115.891 ms 0.48% -2150.305 us -1.82% FAST

Summary

  • Total Matches: 40
    • Pass (diff <= min_noise): 1
    • Unknown (infinite noise): 0
    • Failure (diff > min_noise): 39

@vyasr

vyasr commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

TPC-H is completely unaffected by these changes since there are no nontrivial repetition/definition levels to preprocess for those tables, which are all scalar types with no nulls. TPC-DS is slightly affected by this since there are some nullable columns in the tables, but there are still no nested data, so potential gains are limited. I did a bunch of runs of TPC-DS to try and measure something beyond just noise. I wasn't very scientific about it, and the results are technically still within the expected spread, so I hesitate to make any strong claims here, but averaged over 10 runs with 2 iterations each there is a small improvement of 1-2% that I don't think is just noise. The net effect remains quite small though.

@mhaseeb123

mhaseeb123 commented Jul 27, 2026

Copy link
Copy Markdown
Member

On B200 the chunked-expand path is faster on all the microbenchmarks I tested with a geomean speedup of +2.4% across the full suite. The largest wins are, as expected, on the cardinality=0, run_length=1 LIST configs - up to -11% on parquet_read_decode, -9% on parquet_read_chunks / parquet_read_subrowgroup_chunks, and -5.7% on parquet_read_fixed_width_struct - where the ring-buffer path leaves lanes idle.

Full nvbench_compare.py output - all 40 configs, B200, GPU-time reference (baseline) vs. candidate

Is the slowdown due to the use of cg vs not or is this the final branch vs main comparison? I am okay with removing cg stuff if it causes slowdown I saw speedups reported with different signs and got confused. Nvm please

@vyasr

vyasr commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Ah good catch, sorry about that! I fixed it so the signs are consistent.

Comment thread cpp/src/io/parquet/rle_stream.cuh Outdated
fill_index = 0;
decode_index = -1; // signals the first iteration. Nothing to decode.

cudf_assert(stage_capacity >= 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure stage_capacity cannot exceed the buffer size for this specialization. Otherwise the async copy can write past the shared-memory buffer.

Suggested change
cudf_assert(stage_capacity >= 0);
cudf_assert(stage_capacity >= 0 and stage_capacity <= smem_stage_size);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 64c6af24.

Comment thread cpp/src/io/parquet/rle_stream.cuh Outdated
Comment on lines +515 to +533
// Slot 0 special case: resume a run that was split by the previous
// call. `cur` already points past this run's payload (fully
// consumed last call), so we do NOT re-parse its header - we just
// reuse the saved meta and continue emitting values.
if (partial_run_meta != -1) {
int const remaining = partial_run_total - partial_run_done;
int const room = out_end - out_base;
int const run_len = min(remaining, room);
chunk_meta_v[0] = partial_run_meta;
chunk_out_off_v[1] = run_len;
s_run0_payload_offset = partial_run_done;
num_runs = 1;
run_prefix_end = run_len;
if (run_len < remaining) {
partial_run_done += run_len;
} else {
partial_run_meta = -1;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI pointed out that the partial-run resume path has no production coverage: preprocess_levels_kernel is the only chunked caller and invokes each decoder once. Add a focused test that splits both a long RLE run and a literal run across two decode_next calls, or explicitly restrict chunked decoding to a single call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the partial run code for now. I may want it in a future PR building on top of this one, but I can add it back in then. We're not using it now and I'm not 100% sure it will prove useful later.

while (written < output_count) {
int const batch_size = min(num_rle_stream_decode_threads, output_count - written);
if (t < batch_size) {
output[rolling_index<max_output_values>(cur_values + written + t)] = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add regression coverage for the zero-bit fast path. decode_fixed.cu repeatedly decodes dictionary indices into a rolling buffer, and changing written + t to cur_values + written + t fixes stale slots after the first decode. Cover a nullable, single-value dictionary with more than rolling_buf_size values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to add a test for this, but it turns out the level_bits == 0 fast path is never entered with cur_values > 0 in any reachable code path today. All calls stacks that reach here short-circuit on dict_bits == 0 before they reach this point. I verified this by injecting the bug back, removing the (dict_bits > 0) guard, and pre-poisoning sb->dict_idx[0..rolling_buf_size), and all PARQUET_TEST cases still pass. I just kept the defensive cur_values + written + t form for completeness.

@@ -440,4 +748,7 @@ struct rle_stream {
__device__ inline int decode_next(int t) { return decode_next(t, max_output_values); }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decode_next_chunked does not update output_pos, but skip_runs relies on it. Let's add a static_assert to ensure that doesn't happen.

static_assert(not use_chunked_expand, "skip_decode is not supported by chunked-expand");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 64c6af24.

Comment thread cpp/src/io/parquet/decode_preprocess.cu Outdated
Comment on lines +455 to +456
&copy_barrier,
rle_stream_t::smem_stage_size);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same rle_stream specialization for rle_stream_t as for decoders. The stage sizes match today, but a future template change could make this capacity disagree with the actual shared-memory buffer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 44d0571dfd with the decoder_stream_t alias.

Comment thread cpp/src/io/parquet/decode_preprocess.cu Outdated
__shared__ rle_run rep_runs[rle_run_buffer_size];
static constexpr int max_output_values = cuda::std::numeric_limits<int>::max();
rle_stream<level_t, level_decode_block_size, max_output_values>
rle_stream<level_t, level_decode_block_size, max_output_values, true>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the alias directly here instead of true

Suggested change
rle_stream<level_t, level_decode_block_size, max_output_values, true>
rle_stream_chunked<level_t, level_decode_block_size, max_output_values>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 44d0571dfd with the decoder_stream_t alias introduced above.

Comment thread cpp/src/io/parquet/decode_preprocess.cu Outdated
@@ -418,7 +418,7 @@ CUDF_KERNEL void __launch_bounds__(level_decode_block_size)
__shared__ rle_run def_runs[rle_run_buffer_size];
__shared__ rle_run rep_runs[rle_run_buffer_size];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decode_next_chunked does not use runs. Remove these shared ring buffers for chunked streams, or make the chunked stream constructible without an rle_run*, to recover shared memory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ceb81d66f7. I went with the second option and split the constructor with requires clauses on use_chunked_expand.

Comment thread cpp/src/io/parquet/rle_stream.cuh Outdated
if (level_run & 1u) {
int const groups = level_run >> 1;
run_len = groups * 8;
run_desc = static_cast<int>(cur - s_start) | (1u << 31);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
run_desc = static_cast<int>(cur - s_start) | (1u << 31);
run_desc = static_cast<uint32_t>(cur - s_start) | (1u << 31);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e3738f385b.

Comment thread cpp/src/io/parquet/rle_stream.cuh Outdated
cur += groups * level_bits;
} else {
run_len = level_run >> 1;
run_desc = static_cast<int>(cur - s_start);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
run_desc = static_cast<int>(cur - s_start);
run_desc = static_cast<uint32_t>(cur - s_start);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e3738f385b.

Comment thread cpp/src/io/parquet/rle_stream.cuh Outdated
// Assumption: (cur - s_start) fits in 31 bits, i.e. the encoded
// level stream for a single Parquet page is < 2 GiB. This is
// guaranteed by the Parquet format in practice (page payloads are
// orders of magnitude smaller than 2 GiB) and is independent of

@mhaseeb123 mhaseeb123 Jul 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: Perhaps we want to add a cudf_assert enforcing the 2GB limit?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 64c6af24.

@mhaseeb123 mhaseeb123 added 3 - Ready for Review Ready for review by team and removed CMake CMake build issue labels Jul 29, 2026
@mhaseeb123 mhaseeb123 removed their assignment Jul 29, 2026
vyasr added 10 commits July 30, 2026 02:01
The chunked-expand decoder had cross-call resume state (partial_run_meta /
partial_run_total / partial_run_done plus s_run0_payload_offset) written in
anticipation of a chunked-expand dict_stream in decode_page_data_generic
that was later benchmarked and formally deferred (see the DEFER verdict
recorded on feature branch opt/rle-def-rep-split, geomean B/A 1.0083x inside
noise band with a Q18 regression).

The current sole caller (preprocess_levels_kernel) invokes decode_next_chunked
exactly once per stream with max_output_values = INT_MAX, so a single RLE run
cannot exceed the output window and the resume path is structurally
unreachable. Untested unreachable state-machine code was flagged in review;
deleting it is the cleanest response.

Replace the overflow-stash block with a cudf_assert enforcing the single-call
invariant, and leave a comment referencing the prior chunked-dict work
(commits 4dbde92dd1 / 3e349acb27) as a recovery pointer for whoever revisits
multi-call chunked decoding.
The rle_stream::decode_next fast path for level_bits == 0 uses
`cur_values + written + t` as the ring index rather than the simpler
`written + t`. No current caller enters this fast path with
cur_values > 0 (the writer floors dict_rle_bits >= 1 in chunk_dict.cu,
and REPETITION/DEFINITION decoders in decode_preprocess.cu are
single-call), so the simpler form would also pass all end-to-end
tests. Documenting the invariant explicitly so it is preserved
defensively for any future caller that iterates decode_next with
level_bits == 0.
Introduce class-level constants `run_desc_literal_flag` (1u << 31) and
`run_desc_offset_mask` (0x7fffffffu) on rle_stream and use them at all
five sites that previously spelled the literal-run flag / offset mask
inline. Also folds the standalone block comment about the 2 GiB
invariant into the constants' docblock. Pure naming refactor -- no
behavior change. Verified PARQUET_TEST still passes 481/481.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 - Ready for Review Ready for review by team improvement Improvement / enhancement to an existing function libcudf Affects libcudf (C++/CUDA) code. non-breaking Non-breaking change Performance Performance related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants