Skip to content

Speed up segmented_bitmask_and for tall tables - #23686

Draft
vuule wants to merge 8 commits into
NVIDIA:mainfrom
vuule:segmented-bitmask-binop-parallelism
Draft

Speed up segmented_bitmask_and for tall tables#23686
vuule wants to merge 8 commits into
NVIDIA:mainfrom
vuule:segmented-bitmask-binop-parallelism

Conversation

@vuule

@vuule vuule commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

segmented_offset_bitmask_binop assigns one warp to each segment, so a segment's entire null mask is walked by 32 threads regardless of how long it is. For the struct null mask reduction this kernel exists to serve, a segment is one column's mask and its length is the table's row count, so a handful of columns over a million rows runs on a few hundred threads and reaches a fraction of a percent of achievable bandwidth.

Blocks now cooperate on a segment when there are few segments, with each segment's null count accumulated across its blocks.blocks_per_segment is chosen so that a batch of a few wide segments splits each segment across enough blocks to fill the device, while a batch of many segments keeps one block each.

Also added benchmark cases that show the improvement. Result are posted in a comment.

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 Aug 17, 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 libcudf Affects libcudf (C++/CUDA) code. label Aug 17, 2026
@vuule vuule added Performance Performance related issue improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Aug 17, 2026
vuule added 2 commits August 21, 2026 19:27
…blocks

The kernel gave each segment a single warp, so the whole null mask of a
column was walked by 32 threads: reducing 192 masks of 500k rows was only
6144 threads for 3M words and ran at roughly a tenth of achievable
bandwidth. Blocks now cooperate on a segment when there are few of them,
with the per-segment null count accumulated across the blocks.

The existing benchmark axes only cover masks of up to 128 bits, which fit
in a handful of words and cannot show this, so add a case with mask sizes
in the range of real table row counts.
@vuule
vuule force-pushed the segmented-bitmask-binop-parallelism branch from 2c852ca to 8bed98e Compare August 21, 2026 20:00
@vuule

vuule commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #23689 is merged, and re-measured against the new base.

The rebase took the identity seed and the guard ordering from #23689, so this branch no longer carries the bounds-check move. Only the parallelization is left. blocks_per_segment blocks now cooperate on each segment, seeding each destination word with identity and accumulating the per-block null counts with atomicAdd into a zeroed array.

Updated after review: the multi-block path now has a test, the null counts are zeroed with make_zeroed_device_uvector_async instead of a raw cudaMemsetAsync, and the grid cap is derived from the device rather than hardcoded. Numbers below are re-measured on the current head.

Performance, A100 80GB PCIe

parquet_read_fixed_width_struct, GPU time:

cardinality run_length before after change
0 1 10.559 ms 9.510 ms -9.9%
1000 1 12.565 ms 11.468 ms -8.7%
0 32 11.139 ms 10.043 ms -9.8%
1000 32 11.122 ms 10.012 ms -10.0%

These are from the pre-review measurement session; the review changes do not affect this path. The launch configuration here is identical before and after, and I re-checked the kernel itself under nsys: 75.5 us then, 76.7 us now, i.e. within noise.

segmented_bitmask_and_wide_masks, the new axes, GPU time and bandwidth utilization:

segments masks/segment mask_size_bits before after speedup
8 4 100,000 568.2 us (0.05%) 62.1 us (0.45%) 9.1x
64 4 100,000 661.0 us (0.26%) 152.8 us (1.12%) 4.3x
512 4 100,000 1.396 ms (0.96%) 892.7 us (1.50%) 1.6x
8 8 100,000 672.6 us (0.07%) 62.9 us (0.73%) 10.7x
64 8 100,000 879.1 us (0.38%) 160.0 us (2.11%) 5.5x
512 8 100,000 1.701 ms (1.54%) 928.5 us (2.82%) 1.8x
8 4 1,000,000 5.201 ms (0.05%) 63.1 us (4.40%) 82x
64 4 1,000,000 5.349 ms (0.32%) 202.0 us (8.50%) 26x
512 4 1,000,000 6.174 ms (2.17%) 1.219 ms (11.00%) 5.1x
8 8 1,000,000 6.264 ms (0.07%) 72.3 us (6.34%) 87x
64 8 1,000,000 7.478 ms (0.45%) 233.7 us (14.43%) 32x
512 8 1,000,000 9.159 ms (2.86%) 1.543 ms (16.98%) 5.9x

These are larger than the numbers I posted before the rebase because #23689 switched the benchmark to Poisson-distributed segment sizes. With one warp per segment all segments run concurrently, so the kernel takes as long as the largest segment, and the spread that Poisson introduces lands entirely on the critical path. Spreading a segment across blocks makes that imbalance mostly irrelevant.

The existing narrow axes (mask_size_bits 32 to 128) are unchanged within noise; a segment there is one to four words, so there is no parallelism to gain and the per-segment output allocation dominates. A sample, GPU time:

segments masks/segment mask_size_bits before after
100 4 32 197.1 us 200.5 us
1,000 4 32 1.598 ms 1.609 ms
10,000 4 32 16.187 ms 16.412 ms
100 16 128 202.5 us 205.9 us
1,000 16 128 1.644 ms 1.649 ms
10,000 16 128 16.658 ms 17.337 ms

Choosing the grid cap

Review asked why the cap on total blocks was a hardcoded 4096, and suggested deriving it from cudaOccupancyMaxActiveBlocksPerMultiprocessor and num_multiprocessors(). It turns out a single occupancy wave is too small, and no cap at all is too large, so here are all four variants:

workload 4096 1 wave (~648) uncapped 8 waves (~5184)
parquet_read_fixed_width_struct, kernel time 75.5 us 75.4 us 97.6 us 76.7 us
512 seg / 4 masks / 1M bits 1.229 ms 1.693 ms 1.245 ms 1.219 ms
512 seg / 8 masks / 1M bits 1.559 ms 2.151 ms 1.491 ms 1.543 ms
64 seg / 8 masks / 1M bits 228.7 us 281.2 us 229.4 us 233.7 us

A single wave costs up to 38% on the microbenchmarks, because at 512 segments it collapses blocks_per_segment to 1 and gives up most of what this PR is doing. Dropping the cap entirely looks fine on the microbenchmarks but costs 29% on the path this kernel actually exists to serve: the Parquet grid grows from ~640 to ~20,100 blocks, and every one of those blocks still pays for its own BlockReduce and its own atomicAdd even when it only owns a few words.

Eight waves is within noise of the tuned constant on every shape measured. So the device-independent constant is gone and the target is now 8 * cudaOccupancyMaxActiveBlocksPerMultiprocessor(...) * num_multiprocessors(), which at least scales with the hardware. The multiplier is still empirical and the comment in the code says so, along with what it trades off.

Validation

BITMASK_TEST (54), STRUCTS_TEST (582), COLUMN_TEST (450), and PARQUET_TEST (537) pass. compute-sanitizer --tool=memcheck on BITMASK_TEST reports 0 errors, and --tool=racecheck over the segmented cases reports 0 hazards.

The new MergeBitmaskTest.TestSegmentedBitmaskAndMultipleBlocksPerSegment is what makes those sanitizer runs meaningful for this change. Every pre-existing segmented case used 5 rows, so the whole suite only ever launched one block per segment and could not have caught a race or a bad index in the new decomposition. The new case uses 100,003 rows over two segments: 100,003 is not a multiple of 32, so the last word is partial, and 3,126 words is not a multiple of block_size, so the last block of a segment owns only part of its range. Confirmed under nsys that it launches 26 blocks for 2 segments, i.e. blocks_per_segment == 13.

@vuule vuule changed the title Spread each segment of the segmented bitmask reduction across several blocks Speed up segmented_bitmask_and for tall tables Aug 21, 2026
vuule added 5 commits August 21, 2026 21:58
Add a test with a segment wide enough to span several blocks, so the
cross-block null count accumulation and the partial last word are
exercised. Scale the grid cap to the device instead of hardcoding it,
and zero the null counts at the declaration site.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

1 participant