Implement Segmented TopK using Thread-Block-Clusters - #9224
Conversation
Also avoid unnecessary grid sync in reduce-then-scan path
|
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. |
from gmem (L2)
Also remove ifdefs since perf is clearly worse with the alternative code paths.
Also fixes use of generic addressing due to spilling of smem addresses to local memory.
BlockLoadToShared seems to have some problems when used for pipelining. So for now it was replaced with direct PTX.
Instead of folding the cluster size into the x-dimension of the grid, use the y-dimension s.t. the limit on the amount of segements is the same across backends.
This comment has been minimized.
This comment has been minimized.
Because it takes too long for a routine test. Might go into nightly/weekly testing in the future.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Among other clean up fixes races in agent and fixes triple-chevron-launch handling of cluster dims.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
elstehle
left a comment
There was a problem hiding this comment.
Flushing a few more comments on the agent.
| // Number of chunk-sized slots spanned by `items` keys (the segment's chunk count). Encapsulates the `max_chunk_items` | ||
| // granularity so callers size segments/clusters without inlining chunk arithmetic. | ||
| [[nodiscard]] _CCCL_HOST_DEVICE static constexpr ::cuda::std::uint64_t | ||
| num_chunks_from_num_items(::cuda::std::uint64_t num_items) noexcept |
There was a problem hiding this comment.
nit:
[should-defer]:
I think these could be covered by 32-bit types. As a general follow-up point: We probably want to re-assess 64-bit type usage for the cluster scenario.
Small CPU-only test was missing the new memory size classification macro. The check is not in precommit on this branch yet, so it only surfaced in CI.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
elstehle
left a comment
There was a problem hiding this comment.
Flushing a few more comments. From parsing through the call stack and following through the barrier sequence diagram, things look good.
I think we need a bigger follow-up PR that will restructure the code, things are very intertwined and hard to remember right now.
| ::cuda::std::uint64_t load_mbar[policy.pipeline_stages]; | ||
| // Persistent unaligned boundary edges (block-load path only): the head prefix (`[0, num_load_align_items)`, on rank | ||
| // 0) and the peeled tail suffix (`[num_load_align_items, 2 * num_load_align_items)`, on the tail owner whenever it | ||
| // is unaligned), each strictly `< num_load_align_items` keys. Loaded once in the first pass and consumed into every | ||
| // pass + the final filter. Block-local (never reached through DSMEM). | ||
| key_t edge_keys[2 * num_load_align_items]; |
There was a problem hiding this comment.
suggestion:
[should-defer]: For !use_block_load_to_shared, I think these two fields could be dropped.
This comment has been minimized.
This comment has been minimized.
⏱️ CCCL compile-time benchmark comparison: Public headers compile-time benchResult: 1 regression row(s), 2 improvement row(s) above threshold.
Artifacts: reports and traces Direct file processing
🔴 Direct file processing — Regressions
🟢 Direct file processing — Improvements
|
This comment has been minimized.
This comment has been minimized.
elstehle
left a comment
There was a problem hiding this comment.
Congratulations on the work, @pauleonix 🥳
I've gone through this in multiple iterations now and haven't found anything blocking. I'd suggest we handle the rest in a bigger follow-up refactor. My hope is that that will bring down the mental load down in future for our future selves 🙂
|
|
||
| // Consume the persistent boundary edges into `apply` (head prefix on rank 0; peeled tail suffix on the tail owner), | ||
| // reading the keys already staged in `edge_keys`. Used by the histogram passes; both final filters consume the edges | ||
| // through `process_tiles` instead (as separate regions for the deterministic filter, via `nondet_consume_resident`). |
There was a problem hiding this comment.
nit: I think this should be:
| // through `process_tiles` instead (as separate regions for the deterministic filter, via `nondet_consume_resident`). | |
| // through `process_tiles` instead (as separate regions for the deterministic filter, via `process_head_edge` and `process_tail_edge`). |
| { | ||
| IdentifyOp identify_op; | ||
| KeyOutIt block_keys_out; | ||
| out_offset_t num_cluster_tie_winners; |
There was a problem hiding this comment.
question: are we ever reading this on the non-deterministic path?
| out_offset_t num_cluster_tie_winners; | |
| out_offset_t num_cluster_tie_winners; |
| { | ||
| // Lower ranks follow; leader last. | ||
| _CCCL_PRAGMA_NOUNROLL() | ||
| for (unsigned rank = threadIdx.x; rank < cluster_block_rank; rank += policy.threads_per_block) |
There was a problem hiding this comment.
nit: We're using the tid member in all other places.
| for (unsigned rank = threadIdx.x; rank < cluster_block_rank; rank += policy.threads_per_block) | |
| for (unsigned rank = tid; rank < cluster_block_rank; rank += policy.threads_per_block) |
| // Higher ranks follow. Stops at `num_logical_cluster_blocks` since idle ranks own nothing; the leader at the | ||
| // last logical rank is last. | ||
| _CCCL_PRAGMA_NOUNROLL() | ||
| for (unsigned rank = cluster_block_rank + 1u + threadIdx.x; rank < layout.num_logical_cluster_blocks; |
There was a problem hiding this comment.
nit:
| for (unsigned rank = cluster_block_rank + 1u + threadIdx.x; rank < layout.num_logical_cluster_blocks; | |
| for (unsigned rank = cluster_block_rank + 1u + tid; rank < layout.num_logical_cluster_blocks; |
| { | ||
| constexpr int copy_items = copy_items_per_thread_clamped; | ||
| const offset_t num_cluster_items = static_cast<offset_t>(segment_size); | ||
| const offset_t cluster_tid = cluster_block_rank * static_cast<offset_t>(policy.threads_per_block) + threadIdx.x; |
There was a problem hiding this comment.
nit:
| const offset_t cluster_tid = cluster_block_rank * static_cast<offset_t>(policy.threads_per_block) + threadIdx.x; | |
| const offset_t cluster_tid = cluster_block_rank * static_cast<offset_t>(policy.threads_per_block) + tid; |
| using identify_candidates_op_t = | ||
| detail::topk::identify_candidates_op_t<key_t, SelectDirection, policy.bits_per_pass, decomposer_t>; | ||
|
|
||
| constexpr int total_bits = int{sizeof(key_t)} * 8; |
There was a problem hiding this comment.
suggestion: this definition appears three times and only depends on key_t, let's make it a static constexpr member var.
| constexpr int total_bits = int{sizeof(key_t)} * 8; | |
| constexpr int total_bits = int{sizeof(key_t)} * 8; |
| detail::topk::identify_candidates_op_t<key_t, SelectDirection, policy.bits_per_pass, decomposer_t>; | ||
|
|
||
| constexpr int total_bits = int{sizeof(key_t)} * 8; | ||
| constexpr int num_passes = detail::topk::calc_num_passes<key_t>(policy.bits_per_pass); |
There was a problem hiding this comment.
suggestion: this is already a class member, so we can just read num_passes.
| constexpr int num_passes = detail::topk::calc_num_passes<key_t>(policy.bits_per_pass); |
| && max_block_resident_items % static_cast<offset_t>(max_chunk_items) == offset_t{0}, | ||
| "block tile capacity must be a positive whole number of chunk slots"); | ||
| const offset_t num_local_slots = max_block_resident_items / static_cast<offset_t>(max_chunk_items); | ||
| [[maybe_unused]] const bool needs_streaming = layout.local_partition.num_chunks > num_local_slots; |
There was a problem hiding this comment.
nit: This has only one read inside if constexpr (use_block_load_to_shared), I'd move it in there.
| int streaming_stage = first_wave_is_forward ? 0 : static_cast<int>(reverse_first_stage); // reverse 1st-consumed | ||
| // stage |
There was a problem hiding this comment.
suggestion: reverse_first_stage is already declared const int
| int streaming_stage = first_wave_is_forward ? 0 : static_cast<int>(reverse_first_stage); // reverse 1st-consumed | |
| // stage | |
| int streaming_stage = first_wave_is_forward ? 0 : reverse_first_stage; // reverse 1st-consumed stage |
| if constexpr (first_wave_is_forward) | ||
| { | ||
| first_wave_chunk_idx = | ||
| (first_wave_chunk_idx + offset_t{1} == first_wave_base + num_first_wave_stages) | ||
| ? first_wave_base | ||
| : first_wave_chunk_idx + offset_t{1}; | ||
| } | ||
| else | ||
| { | ||
| first_wave_chunk_idx = | ||
| (first_wave_chunk_idx == first_wave_base) | ||
| ? first_wave_base + num_first_wave_stages - offset_t{1} | ||
| : first_wave_chunk_idx - offset_t{1}; | ||
| } |
There was a problem hiding this comment.
suggestion:
[may-defer]: This snippet is duplicated. May be worth extracting into a lambda/helper.
| if constexpr (first_wave_is_forward) | |
| { | |
| first_wave_chunk_idx = | |
| (first_wave_chunk_idx + offset_t{1} == first_wave_base + num_first_wave_stages) | |
| ? first_wave_base | |
| : first_wave_chunk_idx + offset_t{1}; | |
| } | |
| else | |
| { | |
| first_wave_chunk_idx = | |
| (first_wave_chunk_idx == first_wave_base) | |
| ? first_wave_base + num_first_wave_stages - offset_t{1} | |
| : first_wave_chunk_idx - offset_t{1}; | |
| } | |
| if constexpr (first_wave_is_forward) | |
| { | |
| first_wave_chunk_idx = | |
| (first_wave_chunk_idx + offset_t{1} == first_wave_base + num_first_wave_stages) | |
| ? first_wave_base | |
| : first_wave_chunk_idx + offset_t{1}; | |
| } | |
| else | |
| { | |
| first_wave_chunk_idx = | |
| (first_wave_chunk_idx == first_wave_base) | |
| ? first_wave_base + num_first_wave_stages - offset_t{1} | |
| : first_wave_chunk_idx - offset_t{1}; | |
| } |
🥳 CI Workflow Results🟩 Finished in 12h 10m: Pass: 100%/381 | Total: 20d 14h | Max: 4h 28m | Hits: 26%/3141581See results here. |


Description
closes #9077 #9259 #9548
Cluster-based Segmented Top-K (CUB)
This PR adds a thread-block-cluster implementation of segmented top-k to CUB and unifies it
with the existing baseline backend behind CUB's public
cub::DeviceBatchedTopKAPI. Givennum_segmentssegments, each of variable size, the algorithm selects theklargest (or smallest)keys of every segment (optionally carrying a value payload, i.e. key/value pairs).
This is a production backend, not a throwaway prototype.
cub::DeviceBatchedTopKnow exposes twobackends behind one kernel symbol and picks between them per architecture (the compile-time,
device-side
policy_selector_from_types, resolved viacurrent_policy+if constexpr):serves segments that fit a single thread block on all architectures, but currently only the
fully non-deterministic request
(not_guaranteed, unspecified).and every supported determinism / tie-break combination.
The backend is chosen from three request facts: the architecture, the statically-known maximum
segment size, and the determinism / tie-break requirement. Deterministic (or tie-broken)
requests, and segments larger than the baseline can cover, route to the cluster backend (SM 9.0+);
among non-deterministic, baseline-coverable requests the baseline-vs-cluster choice is an
architecture / segment-size crossover. When no backend can serve the request on a target
architecture the dispatch reports it either at compile time (strict, default) or at runtime as
cudaErrorNotSupported(see §3.6, §10).Because this is CUB's first cluster kernel, this document spends extra time on the
cluster-specific mechanics — distributed shared memory, cluster barriers (and how few of them we
use), the leader block, dynamic cluster sizing, and portability — which reviewers will not have
seen elsewhere in the codebase.
1. High-level overview
One cluster per segment · radix digit top-k · histogram merge · early stop · output placement · determinism
1.1 One cluster per segment, sized for portability
The kernel launches a grid of thread-block clusters and assigns exactly one cluster to one
segment. The blocks (CTAs) of a cluster co-operate through distributed shared memory (DSMEM)
and cluster barriers, so a segment is processed entirely on-chip without a global scratch
buffer or multiple kernel launches. (The non-segmented
cub::DeviceTopKinstead uses a multi-kernelsingle block.)
The number of CTAs per cluster (the "cluster width") is chosen at runtime by the dispatch
layer, so it is not a template parameter; the agent reads it from
cooperative_groups::this_cluster().num_blocks(). The primary reason to spread a segment across aruntime-sized cluster (rather than baking in a fixed size) is portability: the resident
shared-memory budget that lets a segment of a given size stay on-chip is not guaranteed on future
GPUs, so we size the cluster to whatever the device actually offers. Being able to feed the
runtime maximum-segment-size information into the dispatch decision is a welcome bonus, but not the
main motivation. For the same reason the resident key storage lives in dynamic shared memory
(Section 3.2) rather than a compile-time-sized static buffer.
1.2 Radix (digit) top-k
Selection uses the standard multi-pass radix / digit-iteration approach shared with the rest
of CUB's top-k (
detail::topk): process the key onebits_per_pass-bit digit at a time, from themost-significant digit down. Each pass:
digits already match the running "k-th key" prefix).
kand the candidate set to thatbucket.
After the passes converge, the splitter key (the k-th largest key) is known, and a final
filter pass writes out:
k.flowchart LR P0["Pass 0 (MSD digit)<br/>candidates = all keys"] --> P1["Pass 1<br/>candidates = k-th bucket"] P1 --> P2["Pass 2<br/>candidate set shrinks"] P2 --> Pn["Pass n (LSD digit)<br/>splitter (k-th key) known"] Pn --> F["final filter<br/>front (> splitter) + back (= splitter)"] P1 -. "bucket holds exactly k → early stop" .-> F1.3 The histogram: block-private accumulation + DSMEM merge into the leader
Each pass builds the cluster-wide histogram in three steps:
hist[num_buckets]at the same offset inits own shared memory and accumulates its own keys into it with the builtin shared-memory
atomicAdd.nonzero bucket into the leader CTA's
histthrough DSMEM. The leader'shistthereforedoes double duty: its own block-private histogram first, then the cluster-merged histogram after
the merge.
(
cub::BlockScan), find the bucket holding the k-th key, and publish the result into acluster-shared
state. Every CTA reads that result back and sets the winning digit in itsown local splitter key (so the full splitter key never has to be broadcast).
flowchart TD subgraph bp["1 · block-private accumulation"] A["CTA 0<br/>local hist"] B["CTA 1<br/>local hist"] C["CTA n<br/>local hist"] end L["leader CTA hist<br/>(own hist, then merged hist)"] A -- "2 · DSMEM reduce" --> L B -- "2 · DSMEM reduce" --> L C -- "2 · DSMEM reduce" --> L L --> S["3 · BlockScan merged hist<br/>find k-th bucket"] S --> R["publish pass_result<br/>(kth_bucket, early_stop)"] R -. "read back via DSMEM,<br/>set digit in own splitter" .-> A R -.-> B R -.-> C1.4 Early stop
When the identified splitter bucket contains exactly the remaining
kcandidates, everycandidate in it is part of the answer and no finer digit can change the result. The leader sets an
early_stopflag; every CTA decodes it from the same broadcast word and breaks out of the passloop together.
1.5 Final filter and output placement
The final pass re-reads the segment's keys and routes each through one shared placement helper
(
place_one): the front (strictly-selected) and back (tie) paths are unified — a singleblock-local SMEM
atomicAddon the relevant region counter yields the key's output slot, and auniform
out < kguard drops the losing ties while always accepting a strictly-selected key. Bothregions fill forward (low index up):
[0, num_selected).[num_selected, k)(i.e.[k - num_ties, k)).The trick is that the two region counters are pre-seeded to this CTA's absolute region base, so
the placing
atomicAddreturns the final output slot with no per-key base arithmetic. Computingthose bases is the one cross-CTA step, done by a cross-CTA prefix scan: a handful of one-time
cross-CTA pushes seed every CTA's front and back counters to their region bases at once (Section 2.5),
after which per-element placement is just the bare block-local SMEM
atomicAddabove, fullydecoupling the CTAs (no shared cursor).
(An earlier design instead kept a DSMEM output cursor: each output element claimed its slot with
a fetch-add into a cluster-wide cursor held in the leader via DSMEM atomics — one DSMEM atomic per
placed key. Section 2.5 keeps the comparison because it explains why the scan is the better shape.)
The scan puts its (single) cluster barrier before the filter (a post-push sync), so there is no
cluster barrier after the final filter pass and CTAs exit independently — unlike the old DSMEM
cursor, which needed a cluster barrier after the filter (whose later timing also gated early exit).
1.6 Determinism / tie-breaking
The public request carries a
cuda::execution::determinismrequirement and an optionaltie_breakpreference:
equal (tied) keys, which ones land in the back is arrival-order (nondeterministic), but the
set of winning keys is always correct.
BlockScan, so a reproducible subset of the tied keys wins.prefer_larger_indexreverses thescan so the largest-index ties win instead of the smallest.
The determinism contract is that the set of winning elements is deterministic, not their output
positions. The user can also request weaker, valid intermediate points on the determinism /
tie-break lattice (e.g. run-to-run determinism, or GPU-to-GPU determinism with an unspecified
tie-break). For now those all map onto the two behaviors above, because we do not yet know a
cheaper way to provide them.
2. Cluster mechanics (the first-cluster-kernel details)
Cluster barriers · DSMEM and state · leader and pass_result · idle ranks · cross-CTA output scan
This is the section most worth close review, since these patterns are new to CUB.
2.1 Cluster barriers and how we minimize them
A cluster barrier (
cluster.sync()) is expensive: unlike__syncthreads(), synchronizingacross CTAs generally requires a round-trip through L2. Keeping their count low is a first-order
concern, so several design choices exist purely to avoid cluster barriers:
counter the later phases rely on (histogram, the output-scan accumulator, the local front/back
counters, the
statefields) is zeroed before the launch's firstcluster.sync(), so that onecluster barrier both orders the inits and publishes them cluster-wide. No later phase needs its own
extra init-ordering cluster barrier.
need a cluster barrier after building their local histograms before reducing them into the leader
(so the leader's own local increments are ordered ahead of the incoming reductions). We drop that
cluster barrier by leaning on atomic scope instead: the block-private increments use the builtin
atomicAdd, whose default.gpuscope already spans the cluster peers, so the leader's ownincrements are mutually atomic with the non-leaders'
.cluster-scoped DSMEM reductions racing intothe same
hist. That saves one L2-round-trip cluster barrier per pass. (See §6 for why the localatomicAddstill needs a pinned 32-bit shared base to stay cheap.)counters (
front_local_cnt/back_local_cnt) are pre-zeroed with the front-loaded inits above, thescan is a set of fire-and-forget pushes followed by a single post-push cluster barrier — there
is no separate "start of scan" cluster barrier. (This is the only cluster barrier the scan adds;
earlier notes about a "cluster-prefix barrier" refer to exactly this one post-push cluster barrier.)
barrier that precedes the filter; the filter itself writes output through block-local atomics
into gmem, touching no peer, so the last cross-CTA access is the already-fenced output-seeding step.
Every value a CTA still needs from a peer afterward (notably the leader's
early_stop) is cachedinto registers before that last cluster barrier, so no CTA reads a peer's shared memory during or
after the filter and each can return independently. This co-residency safety matters: reading a peer
that has already returned faults the launch ("cluster target block not present"). (This exact shape
may change if the output seeding changes; see Section 2.5.)
The net per-pass cost is small: a local
__syncthreads()plus two cluster barriers (one beforethe leader reads the merged histogram, one that publishes the pass result / orders the histogram
reset), and one post-push cluster barrier for the output scan.
A single-CTA "cluster" (Section 3.4) has no peers, so
cluster_or_block_sync(single_cta)routesevery one of these cluster barriers to a plain
__syncthreads()and all atomics drop to CTA scope.2.2 Distributed shared memory (DSMEM) and
stateEvery CTA allocates the identical
_TempStoragelayout, so any field can be reached in a peer CTAat a known offset. Cross-CTA reads/writes are done through DSMEM (a CTA's shared memory mapped into
another rank's address space); the histogram merge and the output scan write into a peer's fields,
and the leader's
stateis read back by every CTA.The cluster-shared
state(typestate_t, nested in the agent so its counters reuse the kernel's32-bit
offset_t/out_offset_t) lives in the leader block's shared memory. It is split into twonaturally-aligned 8-byte sub-structs —
size {len, k}andresult {kth_bucket, early_stop}(§2.3) —so each is fetched with a single DSMEM load. Non-leaders have no local copy; they read it from the
leader through DSMEM (and cache what they need).
2.3 The leader block and the
pass_resultbroadcastOne CTA is the leader. It owns the merged histogram and the shared
state, does theBlockScan-based splitter identification, and publishes the per-pass result.The per-pass result is an 8-byte
pass_resultsub-struct with twouint32_tfields:kth_bucket: the splitter bucket (each CTA sets this digit in its own splitter key);early_stop: the early-stop flag.Because the sub-struct is exactly 8 bytes and naturally aligned, every CTA pulls it from the leader
with a single 64-bit DSMEM load (
ld.shared::cluster.u64) via the templatedleader_state_load<FieldT>accessor, whichcuda::std::bit_casts the loaded word back to thesub-struct and decodes both halves locally — no second remote load for the early-stop check, and no
broadcast of the full splitter key. (The
size {len, k}sub-struct is read the same way. An earlierversion hand-packed the two halves into a
uint64_twith shifts/masks and forced 16-byte statealignment; the two 8-byte sub-structs drop both the manual packing and the over-alignment.)
The leader is always the CTA that is last in the cross-CTA scan order. This is required, not
arbitrary: the leader's histogram has been merged with everyone else's, so it cannot recover its
own local front/candidate counts directly. Sitting last in the scan, it receives the sum over all
other CTAs and derives its own counts by subtracting that sum from the known totals. (For the
deterministic-prefer-smaller case "last" is the highest effective rank; otherwise it is rank 0.)
2.4 Idle ranks
The launch is sized for the largest segment a cluster may have to handle, so a smaller segment may
not have enough chunks to give every CTA of its cluster work. Those CTAs are idle ranks: they
own no chunks, contribute nothing, and never lead. They cannot simply return, because they still have to
arrive at every
cluster.sync()— a CTA that returned early would make the cluster barrierunreachable and hang the cluster. Each cluster-barrier site notes (
TODO(cccl)) that a sub-cluster mbarrierover just the working ranks would let idle ranks exit and free their SM slots (see Section 10).
2.5 Seeding output positions: the cross-CTA scan (and the cursor it replaced)
Output positions are seeded through DSMEM atomics. The chosen design is a cross-CTA scan; an
earlier DSMEM output cursor is contrasted throughout because the contrast is exactly what motivates
the scan: how many atomics (cluster-bounded and paid once vs. one per placed element), whether they
are fire-and-forget or fetch-add, and — consequently — where the cross-CTA cluster barrier lands.
As introduced in Section 1.5, each CTA needs its front/back region bases. The chosen design is a
cross-CTA prefix scan fused with priming the placement counters
(
prime_placement_counters): each CTA adds its selected count and its candidate count into everysuccessor's
front_local_cntandback_local_cnt— the very counters the final filter later placesinto. The same call also adds this CTA's own back-region base
num_selectedinto its ownback_local_cnt, so after the scan the counters already hold the absolute region bases thefilter needs:
front_local_cnt = sel_prefixandback_local_cnt = num_selected + cand_prefix. The leader is last, so it ends up holding the fullpredecessor sum and derives its own counts by subtraction. As a bonus, after the scan every CTA also
knows the totals it needs for the early-exit bookkeeping without any further DSMEM traffic.
Two 32-bit pushes, not one 64-bit push. An earlier version packed both counts into a single
(front_count << 32) | cand_countword and scanned them with one 64-bit add. That was droppedbecause
red.add.u64on shared memory is not a native atomic — ptxas emulates it with aCAS-spin loop (
ATOMS.CAST.SPIN.64in SASS) — whereas twored.add.u32s are nativewarp-aggregatable shared atomics. So each CTA now issues two independent 32-bit DSMEM reductions
per successor (front lane and back lane); they were never going to carry into each other anyway.
These pushes are fire-and-forget atomics (they do not return a value), so, unlike a fetch-add
into a shared cursor, they don't force a CTA-to-CTA round-trip to read a result back. They are also
lane-parallel (each thread owns a strided slice of the successor ranks), which measurably beat
the original single-threaded loop.
Crucially, the scan's DSMEM traffic is bounded by the cluster width and paid once (O(cluster²)
pushes), independent of
k— the per-element placement that follows uses only cheap block-localSMEM atomics. The shared cursor instead issues one DSMEM fetch-add per placed element, so its
cross-CTA atomic count scales with the number of placed keys: normally ≈
k, which is usually≫ cluster width, and it can even exceed
kwhen candidates are placed before the CTA hasobserved early exit. (In the rare
k < cluster_sizeregime the cursor could issue fewer, so this isa "typically", not a guarantee.)
The two schemes also differ in where the cross-CTA cluster barrier lands, not whether one exists:
the scan's cluster barrier sits before the filter (a post-push sync), so there is no cluster
barrier after the final filter pass and CTAs exit independently; the old shared cursor instead
needed a cluster barrier after the filter, whose later timing was precisely what stopped CTAs
from early-exiting. The front-loaded inits (Section 2.1) hold either way.
3. Launch, dynamic cluster sizing, and portability
One kernel symbol · dynamic-SMEM block tile · wave-aware sizing · single-CTA path · portability · strict vs lenient
The dispatch first picks a backend (baseline vs cluster) from the request facts, then — for the
cluster backend — sizes the launch. Both steps fit together as follows:
flowchart TD Req["request:<br/>arch, max seg size,<br/>determinism/tie-break"] --> BE{"needs cluster?<br/>deterministic/tie-break,<br/>seg > baseline coverage,<br/>or crossover prefers it"} BE -- "no" --> BL["baseline backend<br/>(worker-per-segment,<br/>1 block/segment)"] BE -- "yes" --> Arch{"SM 9.0+?"} Arch -- "no" --> NS["unsupported<br/>(strict: compile error;<br/>lenient: cudaErrorNotSupported)"] Arch -- "yes" --> Sel{"k ≥ segment size?"} Sel -- "yes" --> All["select-all fast path<br/>(copy only, no radix)"] Sel -- "no" --> H["host: query CUDA RT APIs<br/>pick (cluster width C, dyn SMEM)"] H --> One{"fits 1 CTA and<br/>≤ single_block_max?"} One -- "yes" --> SC["single-CTA path<br/>(cluster-barrier-free)"] One -- "no" --> W["wave-aware search:<br/>min waves, tie-break largest C"] W --> Fit{"max segment<br/>fully resident?"} Fit -- "yes" --> RES["cluster launch, all resident"] Fit -- "no" --> STR["widest cluster + max SMEM,<br/>stream overflow"]3.1 One kernel symbol, both backends
Both backends live behind a single kernel symbol (
device_batched_topk_kernel). The device-sidecurrent_policy+if constexprinstantiate only the agent named by the selectedtopk_algorithm, soa build compiles exactly one backend per architecture — no wasted kernels — while host code stays
agnostic to the architecture actually chosen (a TU may target several). This follows DeviceScan's
pattern rather than emitting a distinct kernel per backend.
The API is host-only: it does not support CUDA dynamic parallelism (device-side launch), so the
cluster backend needs only one launch shape (the baseline backend has no cluster dimension and also
uses the plain host launch):
__cluster_dims__, launched viacudaLaunchKernelExwith aruntime cluster dimension. This lets the host pick the cluster width per problem.
The host uses a whole battery of CUDA runtime APIs to find the ideal
(cluster width, dynamic-SMEM)combination that keeps the runtime maximum segment resident —
cudaDeviceGetAttribute,cudaFuncGetAttributes,cudaOccupancyMaxActiveClusters,cudaFuncSetAttribute,cudaOccupancyMaxPotentialClusterSize. None of these are available from a device-side launch, which isone reason the API is host-only.
3.2 Dynamic shared memory and the resident "block tile"
The segment's keys are staged in dynamic shared memory partitioned into fixed-size chunks (one
chunk = one slot =
ChunkBytes).smem_block_tile_layoutcomputes, from a dynamic-SMEM byte budget,how many whole chunks fit and thus the per-CTA
block_tile_capacity(resident key capacity).The base is rounded up to
slot_alignmentso every bulk-copy destination has the same alignment itsgmem source has.
slot_alignmentismax(LoadAlignBytes, alignof(key_t)):LoadAlignBytesonly needs to be the TMA minimum (16 B) for bulk-copy correctness, but thepolicy currently uses 128 B so every chunk starts on a cache-line boundary.
LoadAlignBytesonly for over-aligned key types whosealignofexceeds theload alignment.
3.3 Wave-aware cluster-size search (host)
The host dispatch chooses
(cluster_blocks, dynamic_smem_bytes)analytically. The free variable isthe cluster width
C; eachCis paired with the smallest dynamic SMEM that keeps the max segmentfully resident. It enumerates the feasible
Crange, queriescudaOccupancyMaxActiveClustersforclusters-per-wave, and picks the
Cthat minimizes the number of waves, tie-breaking toward thelargest
C.The largest-
Ctie-break is chosen for parallelism, not L1 behavior: the bulk copies land keysstraight into shared memory and largely bypass L1, so spreading a segment over more CTAs mostly buys
more SMs working it in parallel. We scale that parallelism via the cluster width rather than a
bigger block because
threads_per_blockhas to be a compile-time constant forcub::BlockScan, sothe block size is fixed and the cluster width is the free knob.
All the size arithmetic is done in 64-bit to stay overflow-safe: a single segment is capped at
2^21keys (so a segment-internal
uint32_toffset cannot overflow), but cross-segment quantities are not: mostnotably the launch grid's block count
num_segments × cluster_blocks(which the host caps atINT_MAX)and the residency / wave-count sizing products, so computing them in 32 bits could overflow. If full residency is impossible
(segment larger than the widest cluster can hold), it maximizes residency with the widest launchable
cluster at the largest SMEM and lets the agent stream the overflow (Section 4).
3.4 Single-CTA path and the runtime collapse
Small segments that fit resident in one CTA and are at/below a tuning threshold
(
single_block_max_seg_size) take a dedicated single-CTA path: it is cluster-barrier-free(all cluster barriers become
__syncthreads(), all atomics CTA-scope) and cheaper than spreading a tinysegment across CTAs.
This happens two ways:
clusterDim.x = 1.sized for the largest segment, so a small segment collapses at runtime — ranks other than 0
return immediately (freeing their SM slots) and rank 0 runs the cluster-barrier-free path.
The eligibility math (
single_cta_eligible) is shared between host and device so both sides agreeexactly. A related knob,
min_chunks_per_block, controls how many chunks a CTA must own to join theeffective cluster; it is currently 1, i.e. it just implements the "drop zero-chunk CTAs" idle-
rank avoidance and nothing more aggressive.
3.5 Portability
Any device without opt-in SMEM runs within the portable 48 KiB total SMEM budget. This works
because the agent peels the unaligned boundary edges into a tiny static buffer (Section 4.3), so
streaming needs only a single resident-or-streaming slot; the only hard requirement
is that at least one load-aligned chunk fits. Segments that exceed the small portable block tile are
still correct — the agent re-streams overflow from gmem. The kernel raises its dynamic-SMEM opt-in
lazily (
cudaFuncSetAttribute) only up to the selected size.3.6 Unsupported requests: strict vs. lenient
A request may have no viable backend on a given architecture — e.g. a deterministic / tie-break
request, or a segment larger than the baseline can cover, while a pre-SM 9.0 target is present (the
cluster backend requires SM 9.0+). Because host code compiles for a list of architectures, this is
diagnosed two ways:
static_assertfires at compile time if the request cannot be served onany architecture in
CMAKE_CUDA_ARCHITECTURES. This is the least-surprising UX for callersbuilding the default multi-arch preset.
CUB_DISABLE_TOPK_UNSUPPORTED_ARCH_ASSERTdefers the diagnosis to runtime:the dispatch returns
cudaErrorNotSupportedon devices whose architecture cannot serve therequest. CUB's own tests and benchmarks define this macro so they compile the full configuration
space across all target architectures and skip at runtime where unsupported.
4. Data movement: resident vs. streaming, and the TMA pipeline
Chunking and the resident/streaming split · first pass · raw TMA pipeline and ping-pong · boundary edges · latency hiding · straddling reuse
4.1 Chunking and the resident/streaming split
Each CTA's assigned keys are partitioned into
ChunkByteschunks. If a CTA's chunks fit its residentslots, they all stay resident and are re-read from SMEM every pass. If a CTA has more chunks than
slots (an "overflow"), it reserves a small round-robin streaming region at the tail of its block
tile and re-streams the overflow chunks from gmem each pass. The split is decided independently
per CTA — CTAs need not agree, because all cross-CTA traffic and cluster barriers are still reached uniformly.
4.2 The first pass: filling the resident tile through the pipeline
The very first radix pass has to load the resident keys (later passes just re-read them from SMEM).
That initial load runs through the same async bulk-copy pipeline, using up to
PipelineStagesin-flight copies (
prologue = min(PipelineStages, resident_chunks)) — the pipeline depth is used forthe initial resident load whether or not any streaming happens afterward. So "pipeline depth"
(
PipelineStages) and "number of streaming slots" (p_eff) are separate things: the first-passresident load can use the full depth even with zero overflow.
The whole first pass is one fused loop over this rank's chunks — resident chunks first, then the
overflow chunks — behind a single TMA-issue site and a single histogram-consume site. (It used to be
three loops: resident load, resident consume, overflow consume. Merging them removed the replicated
cp.async.bulkissue sequences that had bloated the first pass's SASS.)The first overflow wave is primed as part of this same pass rather than strictly after it: an
up-front loop issues the resident-chunk loads plus — when the stream is wider than the resident window
— the early-consumed overflow copies onto stages the resident load never occupies; then, as resident
stages drain in the consume loop, they are re-armed with the remaining first-wave overflow copies
(interleaved priming). The two pipelines overlap seamlessly on one shared set of mbarriers, so no TMA
idle gap opens between "resident loaded" and "streaming primed" (Section 4.5).
4.3 The overflow stream and the raw TMA pipeline
Overflow chunks move through a fixed set of
p_eff (<= PipelineStages)streaming slots driven by anelected-thread
cp.async.bulk(TMA) copy against raw per-stage mbarriers. This inlines theinternals of
BlockLoadToShared(one mbarrier per stage, one elected thread issuing the copy +mbarrier_arrive_expect_tx, and per-stage phase-parity waits);BlockLoadToShareditself was triedfirst but did not behave well as a software pipeline, so it was replaced with direct PTX. The
mbarriers are initialized once up front and reused across all radix passes and the final filter. (The
streaming state — inflight mask, ping-pong direction, slot base — lives directly in the agent; it was
previously a separate
overflow_streamerhelper, now inlined.)The ping-pong pattern (why it matters). Reusing mbarriers across passes is ordinary; the
non-obvious part is that the stream reverses ("ping-pongs") its chunk-visit direction every pass to
cut memory traffic two ways:
p_effchunks visited in a pass are still sittingin the streaming slots when the pass ends. By reversing direction, those same chunks are the
first ones the next pass needs, so they are read straight from SMEM instead of being re-fetched
from gmem (the "priming" copies are skipped).
re-fetched most-recently-touched-first, so they are more likely to still be hot in L2.
The same ping-pong direction is carried into the final filter, which is where the straddling-CTA
nuance in Section 4.6 applies.
4.4 Boundary edges
The bulk-copy path requires load-aligned chunks, but a segment's start (and possibly its end) are
generally unaligned. Both unaligned boundaries — the head prefix and the tail suffix — are
always peeled into a tiny persistent static buffer (
edge_keys), loaded once in the first pass andconsumed by every subsequent pass and the final filter. Peeling the tail leaves the tail chunk's
aligned bulk as a normal (possibly partial) aligned chunk that resides or streams like any other; no
partial tail chunk is ever kept resident. Peeling both boundaries removes dual-boundary pressure so
streaming needs only one full slot — this is what enables the portable 48 KiB path.
(An earlier design kept the tail suffix resident inside its own partial tail chunk and peeled it only
in the single-slot portable case. Always peeling it is simpler — the stream already had to handle a
tail chunk shorn of its peeled suffix for that portable case — and removes the "reserve a resident slot
for the tail" bookkeeping and its forced-resident-tail special cases.)
4.5 Latency hiding
Later passes overlap the first streaming wave with useful work: the stream issues its first
p_effloads, then runs the caller's resident-chunk work (
overlap_work) — histogramming or filtering thealready-resident keys — before waiting on the in-flight copies. Further waves rely on pipeline
depth.
The first pass hides even more: its first overflow wave is interleaved into the resident load
(Section 4.2), so the initial TMA copies for streaming are already in flight while the boundary edges
are staged/consumed and the resident keys are histogrammed. The stream ring and the resident window
share the same mbarriers (
stage_cycle = max(stream_stages, prologue)distinct stages), and the waveis always primed in consumption order — the earliest-consumed copy issues first — so the first
wait_stagefinds its copy the most in-flight:first_wave_is_forward): the overflow is consumed ascending from stage 0.The resident window sits at
[stage_base, stage_base + prologue)walked+1, and astage_rotcorrection realigns a misaligned reload tail so the last resident consumes free the shared stages in
consume order.
wave descend): the overflow is consumed descending from
reverse_first_stage. The resident windowis placed as a cyclic block starting at
(reverse_first_stage + 1) mod stage_cycle, with adescending chunk→stage map, so the descending resident re-arms free the shared stream stages in
consume order too. This replaced an earlier scheme that primed reverse ascending while consuming it
descending, where the first-consumed 16 KiB copy could be issued last and erase the overlap.
A single
first_wave_chunkring counter is seeded once in consume order and handed from the up-frontloop to the consume loop's re-arms; every cursor steps unit-stride with a conditional-select wrap, so no
hot path pays a runtime
%by the non-power-of-twoprologue/stage_cycle.4.6 Straddling-CTA reuse in the final filter
Only the single straddling CTA — the one whose candidates cross the k-boundary while ties are
still unresolved — actually needs the final stream in strict scan order. Every other CTA is
order-independent:
The stream's initial ping-pong direction is preselected at
runentry from the compile-timepass count: a streaming rank flips direction once per histogram pass, so starting at
(!tie_reversed) ^ (num_passes & 1)makes the leftover direction after allnum_passespasses equalexactly the filter's required
!tie_reversed. The straddling CTA therefore enters the final filteralready in scan order and — like every order-independent CTA — always reuses the chunks already
resident in its streaming slots, skipping the re-prime copies entirely. Under early stop fewer passes
run so the leftover direction is arbitrary, but then every CTA is all-below (no straddling CTA), so the
direction is order-independent anyway. Preselecting the direction at compile time removes the old
runtime "force the scan direction and conditionally re-prime" branch from the straddling CTA's critical
path.
5. Determinism and tie-breaking internals
Shared tile engine · lazy scan · blocked vs striped loads · reverse residency
The deterministic and non-deterministic filters share one tile engine (
process_tiles, gated by aDeterministictemplate parameter and a PODdet_filter_state/nondet_filter_state); thedeterministic-only tie machinery is compiled in only where needed. On the deterministic path the
front (strictly-selected) keys are always order-independent, so they go through the same
place_oneatomic as everything else. Only the back (ties) needs ordering, and only on the straddling CTA:
they place the back with arrival-order atomics (order-independent set) and skip the scan.
BlockScan(emit_indexed) so thenum_backsmallest-index (or largest-index, for
prefer_larger_index) ties win reproducibly.Lazy scan: rather than running the
BlockScanon every tile, the deterministic filter runs thecheap atomics by default and only falls back to the
BlockScanon the single tile that actuallycrosses the k-boundary (plus a forced "last-tile" scan when needed). All other tiles run at nearly the
speed of the non-deterministic path. Single-tile CTAs skip the laziness and scan directly to avoid the
extra latency.
Blocked vs. striped loads. A
BlockScanover a tile needs the keys in a blocked threadarrangement (thread
towns a contiguous run), but blocked loads bank-conflict on SMEM and don'tcoalesce as well as striped ones (consecutive threads read consecutive keys). Only the straddling
CTA, and only while its ties are unresolved, actually needs blocked. So
process_tilesruns thestraddling deterministic CTA in two phases: phase A loads blocked while
is_tie_active, thenswitches once to striped for the remaining tiles (past the boundary no tie scan is possible, so
only strictly-selected keys are placed). Every other deterministic CTA and the entire
non-deterministic path load striped throughout; a
static_assertenforces that a stripeddeterministic tile never reaches the index-ordered scan.
reverse_residency: forprefer_larger_indexthe final scan visits high indices first, so theresident/streaming split is flipped to keep the high-index chunks resident, restoring the same
"first-visited chunks stay resident, skip re-reading the overflow" symmetry the ascending path enjoys.
6. Lower-level optimizations (worth knowing while reviewing)
Split load/atomic loops · register scans · unroll clamping · select-all · coarse early-exit · forward-fill placement · elected leader · generic fallback
non-unrolled block-stride loop over tiles of
Unroll * threads_per_blockkeys, but each tile isprocessed by two separate fully-unrolled inner loops: the first reads the whole tile of keys
from SMEM into registers, the second feeds those registers to
apply(the histogram SMEM atomic).Merely (partially) unrolling the block-stride loop was not enough. The compiler cannot prove the
SMEM key-read range and the SMEM histogram range are disjoint, so a single fused unrolled loop
serializes each load against its dependent atomic. Splitting them lets the whole wave of
LDSloads issue and be in flight ahead of the atomics, which is what recovered histogram throughput.
the merged histogram. Non-leaders exclusive-scan their own (un-merged) histogram into registers
in parallel, so once the leader publishes the bucket, the owning lane can read its strictly-selected
prefix and its splitter-bucket count without any extra SMEM traffic; this feeds the output scan and
the early-exit counters and lets
histreset on its normal schedule.enough that the segment will take the single-CTA path — the only path that knows its per-CTA chunk
count at compile time — the per-thread unroll factors are clamped (both
floorandceilflavorsfor the two loop styles) so sub-tile segments don't pay for predication/registers they can't use.
Larger/unbounded types keep the full unroll.
k >= segment_size, every element wins; the kernel skips the radixpasses, histogram, and ordering entirely and just copies keys (and values) across the full cluster.
before each streaming refill copy — never per tile — using block-local placement counters behind one
__syncthreads(); the atomic front/back paths run without a__syncthreads()between those points.Both filters exploit this: the non-deterministic filter also stops streaming between chunks once
this CTA's front is full (
front_local_cntreachedsel_prefix + my_front) and its back is full(
back_local_cnt >= k), so a CTA whose whole contribution is already placed skips re-fetching itsremaining overflow chunks.
(
place_one): a single SMEMatomicAddon the region counter — already primed to the absoluteregion base — plus a uniform
out < kguard, both regions filling forward. This keeps the hot loopbranch-uniform (only the target counter differs), lets the shared increment warp-aggregate, and is
what let the deterministic and non-deterministic filters merge into one tile engine.
__block_elect_one()(
is_block_leader) is reused for every "one thread does it" site (mbarrier driving, the scan'slocal seed, the single-CTA back-base write), not just the TMA issue, so the compiler can keep those
guards in uniform registers.
non-contiguous iterators) fall back to plain per-element gmem loads/stores; that path reserves no
streaming slots and re-reads overflow from gmem each pass (still ping-ponging direction for L2
locality).
Shared-memory atomics: builtin
atomicAddon a pinned 32-bit baseBlock-private histogram increments and the local front/back placement counters use the builtin
atomicAddon a 32-bit shared address pinned bydetail::warpspeed::optimizeSmemPtr(the sametrick the dynamic-SMEM allocator uses; nvbug 4907996). Without the pin,
atomicAdd(&smem[i])re-derives the address from
_TempStorage's 64-bit generic base on every update, spilling a 64-bitbase and demoting the op to a generic atomic; pinning a hoisted 32-bit base keeps the per-key
increment at pure 32-bit addressing (
ATOMS.POPC.INC.32).atomicAddkeeps its default.gpuscope(not
.cta— that isatomicAdd_block), which spans the cluster peers and so is mutually atomic withthe cross-CTA DSMEM reductions (§2.1). This replaced an earlier hand-written inline-PTX
red/atomsequence keyed on the 32-bit address. The cross-CTA reductions themselves stay inline PTX
(
red.relaxed.cluster.shared::cluster.add.u32): a.cluster-scoped reduction into a peer's DSMEMwindow (a remapped 32-bit remote address) is not expressible through the builtin
atomicAdd.7. Tuning parameters
Policy layout and default per-block knobs
The policy types live in
detail::batched_topk(tuning_batched_topk.cuh). The combinedtopk_policycarries the selectedtopk_algorithmplus both sub-policies (baseline_topk_policyand
cluster_topk_policy). The selectorpolicy_selector_from_typesbuilds it, taking the backenddecision inline in its
operator(); it lives indispatch_batched_topk.cuh(not the tuning header)because that decision needs
baseline_can_cover_v, which is only computable from the concrete baselineagent types. The kernel then instantiates only the arm named by
backend(chosen device-side viacurrent_policy). Atuned selector supplied through the dispatch tuning environment (keyed ontopk_policy) replacespolicy_selector_from_typeswholesale.cluster_topk_policycarries only the per-block knobs; the cluster width and dynamic-SMEM capacityare chosen at runtime by the dispatch. Current defaults:
threads_per_blockBlockScan)histogram_items_per_threadpipeline_stageschunk_bytesload_align_bytesbits_per_passmin_blocks_per_smtie_break_items_per_threadsingle_block_max_seg_sizemin_chunks_per_blockcopy_items_per_threadThe tuning is currently identical across all cluster-capable CCs (SM 9.0+).
8. Things we tried and dropped
BlockLoadToShared · histogram unroll · push broadcast · chunk subdivision · 64-bit scan · forced nounroll
BlockLoadToSharedfor pipelining → replaced with raw mbarrier +cp.async.bulkPTX. It didn'tbehave well as a software pipeline; the hand-rolled pipeline is what ships.
medium/large ones too much; the simpler clamped unroll won.
added complexity without a clear win; the single 64-bit DSMEM load of the 8-byte
pass_result(fewer DSMEM loads) was kept.
meant to help small segments but measured worse there.
(front << 32) | candword and scanning them with a singlered.add.u64looked cheaper, but a64-bit
red.addon shared memory is CAS-spin-emulated (ATOMS.CAST.SPIN.64); two nativered.add.u32s are faster (Section 2.5). A single 64-bit load/store is still used for thepass_resultbroadcast (a plain load/store, not an atomic).nounrollon every runtime-bound loop → partially reverted. After annotating all loopswith explicit unroll/nounroll pragmas, the histogram/filter sub-tile remainders and the radix pass
loop regressed under forced
nounrolland were restored to compiler-decided (partial/full)auto-unroll. The bulk-copy priming/consume loops went the other way: letting the compiler
auto-unroll them replicated the whole
cp.async.bulkissue sequence per iteration and bloated thefirst pass's SASS (it more than doubled the
UBLKCPcount and hurt even non-streaming kernels, whichstill carry the cold streaming machinery), so they are pinned to
nounrolland were merged into thesingle fused first-pass loop (Section 4.2).
9. Testing
Functional, API, layout, and compile-fail tests · benchmarks
All functional tests drive the public
cub::DeviceBatchedTopKAPI (not the low-level dispatch),so they exercise the same backend-selection path production callers hit.
catch2_test_device_segmented_topk_{keys,pairs}.cu: functional coverage across key types (includinghalf/bf16 and small/large index types), keys-only and pairs, non-deterministic and deterministic
(both tie-break directions), a range of segment sizes spanning single-CTA, fully-resident multi-CTA,
and streaming/overflow regimes, plus the select-all path (
k >= segment_size), negative-kclamping (a negative runtime
kselects nothing, mirroring negative segment sizes), and a positivesize-based baseline→cluster crossover case. The pairs test additionally pins first-pass scheduling corner cases with tiny custom
tunings (forcing a single CTA and specific
stream_slots/stages): forwardstage_baseprimingwith a resident chunk below a wider stream, a wrapping reverse resident window, a misaligned reload
tail (
stage_rot), and a partial final overflow wave (overflow_chunks % stream_stages != 0) —paths end-to-end tests otherwise reach only nondeterministically.
catch2_test_device_batched_topk_{api,env_api}.cu: smoke tests of the public API surface (with andwithout an explicit execution environment).
catch2_test_device_segmented_topk_cluster_layout.cu: a host-only unit test that pins down thesmem_block_tile_layoutmath (capacity, padding, per-rank worst-case chunk counts) independent of aGPU.
test_device_batched_topk_requirements_fail.cu/test_device_batched_topk_unsupported_arch_fail.cu:compile-fail tests, split into many
%PARAM%variants. The first covers the execution-requirementstatic_asserts (e.g. non-unsortedordering) and the argument-validation asserts for each ofsegment_sizes,k, andnum_segments(bad element types, a per-segmentnum_segments, and akwhose element type is wider than 64 bits). The second is compiled for two arch sets from one source:
a pre-SM 9.0 arch alone (
89-virtual) and a mixed pre/post-Hopper pair (89-virtual;90-virtual),both verifying the strict unsupported-arch
static_assertfires when any target arch cannotserve the request. It is the only top-k test built without
CUB_DISABLE_TOPK_UNSUPPORTED_ARCH_ASSERT; all other tests define it and, rather than skippingoutright, dispatch and assert they get
cudaErrorNotSupportedfor unsupported configurationsbefore skipping the correctness checks.
Benchmarks under
cub/benchmarks/bench/segmented_topk/cover fixed and variable (keys / indexed)segment sizes. A
TUNE_BACKENDknob selects which backend a build measures (baseline / cluster /device-reference / automatic), so each backend can be tuned independently while
automaticbenchmarksthe production selector.
All keys+pairs test binaries pass on a Blackwell (B200) GPU.
10. Known limitations and future work
Idle-rank spin · 32-bit segment offsets · SM 9.0+ only
unreachable. A sub-cluster mbarrier over just the working ranks (the "L2-free sync" plan) would let
them return and free their SM slots.
kvalue and type handling.khas no algorithmic maximum — akpast a segment's size selectsthe whole segment. Its element type may be at most 64 bits wide, enforced by a
static_assert(
sizeof(k's element type) <= 8): the device clampskto the segment size through a 64-bitintermediate, so a wider type (e.g.
__int128) is rejected to avoid a silent wrap. As with segmentsizes, negative
kis allowed under a negative static lower bound and clamped to 0 (selectingnothing); the clamp happens before any type cast (
__get_and_clamp_param_to_nonnegative).uint32_t(unsigned: offsets, ranks, and blockcounts are non-negative, and negative segment sizes — and negative
k— are clamped to 0 upstream). The binding limit is notthe offset width but the public entry's cap on the statically-known maximum segment size, currently
2^21keys, enforced at compile time from the segment-size argument's static upper bound: a type whosemaximum already fits (e.g.
uint8_t/int16_t/uint16_t) is accepted un-annotated, while a type whosemaximum exceeds
2^21(e.g.int32_t/uint32_t/int64_t) must carry acuda::args::boundswhose upperend is
<= 2^21— an out-of-range static upper bound, or an un-annotated wide type, fails to compile. Anegative static lower bound is accepted (a negative runtime size is clamped to 0, i.e. treated as empty);
a non-negative lower bound is trusted. A per-segment runtime value outside its declared bound is a caller
precondition violation (undefined
behavior): the statically declared bounds are validated at compile time, while the argument values are
bounds-checked only by assertions active in assertion-enabled (e.g. debug) builds — host-side for a
host-known
immediatevalue and device-side (via CUB's__assert_param_in_bounds) for values read from adeferred/deferred_sequencehandle. Cross-segment counts use 64-bit where needed; widening the internaloffset to 64-bit
is future work (the cross-CTA scan already avoids 64-bit shared atomics — it pushes the two counts
as separate 32-bit
red.add.u32s because a 64-bit sharedred.addis CAS-spin-emulated — so awider offset would need native 64-bit shared atomics or a different scan).
baseline (worker-per-segment) backend serves single-block, non-deterministic requests; deterministic
/ tie-break requests and segments too large for a single block have no backend there and are reported
strictly (compile-time) or leniently (runtime
cudaErrorNotSupported) per §3.6. A multi-blockbaseline backend for large segments on older GPUs is future work.
Checklist