Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Eight numbers sit in GPU memory: 3, -1, 4, 1, 5, -9, 2, 6. A transpose can move every number to a new address. It can't answer three common questions: What is their total? How many values came before each position? Which value is largest, and where did it occur?
Those questions need parallel primitives, small algorithms that many larger kernels reuse. Reduction turns many inputs into fewer aggregates. Prefix scan returns an aggregate at every boundary. Arg reduction carries a value and its location together. Stable softmax combines reductions with exponentials without overflowing.
The GPU Kernel Performance Engineering Lab established coalescing, shared memory, barriers, exact checks, and event timing through one transpose. Keep those contracts. Here one stable tensor exposes new dependency shapes instead of a new memory layout.
Work eight values by hand
Start with sum. A serial loop visits all eight values in order and ends at 11. A balanced tree reaches the same integer through three parallel levels:
1input: [ 3, -1, 4, 1, 5, -9, 2, 6]
2level1: [ 2, 5, -4, 8 ]
3level2: [ 7, 4 ]
4output: [11]Eight leaves require seven additions either way. Tree depth is only , provided enough workers can execute each level together. That shrinking output is reduction's signature.
Prefix scan keeps every boundary instead. For addition, an inclusive scan includes current value at index . An exclusive scan stops before it:
| Index | Input | Exclusive prefix | Inclusive prefix | How to check row |
|---|---|---|---|---|
| 0 | 3 | 0 | 3 | exclusive starts at additive identity 0 |
| 1 | -1 | 3 | 2 | |
| 2 | 4 | 2 | 6 | previous inclusive becomes next exclusive |
| 3 | 1 | 6 | 7 | |
| 4 | 5 | 7 | 12 | prefix retains position 4 boundary |
| 5 | -9 | 12 | 3 | negative input lowers running total |
| 6 | 2 | 3 | 5 | output count still equals input count |
| 7 | 6 | 5 | 11 | last inclusive equals full reduction |
The relation is exact for every valid :
Argmax needs another payload. Maximum value is 6 and its index is 7, so output is pair (6, 7). If tensor contained another 6, operator would need a tie rule such as "smaller index wins." Value, index, and tie rule form one reduction state.
![Two aligned views of tensor [3, -1, 4, 1, 5, -9, 2, 6]. Reduction groups adjacent values into pair sums [2, 5, -4, 8], then [7, 4], then scalar 11, so only one final aggregate remains. Prefix scan keeps one output per input position: exclusive prefixes [0, 3, 2, 6, 7, 12, 3, 5] and inclusive prefixes [3, 2, 6, 7, 12, 3, 5, 11]. Horizontal position preserves each prefix boundary.](/cdn/content-image/fundamentals/gpu-parallel-primitives-lab/illustrations/_generated/primitive_dependency_shapes_dark.png?v=faa80e6129b9)
The runnable model below checks all four outputs and constructs stable softmax state used later. It uses Python's math module so mechanism stays visible.
1from math import exp, inf, isclose
2
3x = [3.0, -1.0, 4.0, 1.0, 5.0, -9.0, 2.0, 6.0]
4
5inclusive = []
6running = 0.0
7for value in x:
8 running += value
9 inclusive.append(running)
10
11exclusive = [0.0] + inclusive[:-1]
12argmax = max(enumerate(x), key=lambda pair: (pair[1], -pair[0]))
13
14def merge_softmax_states(left, right):
15 left_max, left_denom = left
16 right_max, right_denom = right
17 if left_denom == 0.0:
18 return right
19 if right_denom == 0.0:
20 return left
21 merged_max = max(left_max, right_max)
22 merged_denom = (
23 left_denom * exp(left_max - merged_max)
24 + right_denom * exp(right_max - merged_max)
25 )
26 return merged_max, merged_denom
27
28state = (-inf, 0.0)
29for value in x:
30 state = merge_softmax_states(state, (value, 1.0))
31
32maximum, denominator = state
33probabilities = [exp(value - maximum) / denominator for value in x]
34
35assert inclusive == [3.0, 2.0, 6.0, 7.0, 12.0, 3.0, 5.0, 11.0]
36assert exclusive == [0.0, 3.0, 2.0, 6.0, 7.0, 12.0, 3.0, 5.0]
37assert argmax == (7, 6.0)
38assert merge_softmax_states((-inf, 0.0), (-inf, 0.0)) == (-inf, 0.0)
39assert isclose(sum(probabilities), 1.0, rel_tol=0.0, abs_tol=1e-15)
40
41print(f"sum={inclusive[-1]:.0f}")
42print(f"exclusive={[int(value) for value in exclusive]}")
43print(f"inclusive={[int(value) for value in inclusive]}")
44print(f"argmax=(value={argmax[1]:.0f}, index={argmax[0]})")
45print(f"online_state=(max={maximum:.0f}, denominator={denominator:.6f})")
46print(f"softmax_sum={sum(probabilities):.12f}")1sum=11
2exclusive=[0, 3, 2, 6, 7, 12, 3, 5]
3inclusive=[3, 2, 6, 7, 12, 3, 5, 11]
4argmax=(value=6, index=7)
5online_state=(max=6, denominator=1.578968)
6softmax_sum=1.000000000000Move sum into one warp
A naive GPU baseline assigns all eight additions, or all additions, to one thread. That gives a correct, easy-to-check answer but preserves serial dependency depth and leaves rest of GPU idle. Hierarchical version begins by giving each lane a local sum.
A warp is CUDA's group of 32 lanes that executes instructions together. __shfl_down_sync lets one participating lane read a register from a higher-numbered lane without routing value through shared memory. Five offsets, 16, 8, 4, 2, 1, reduce 32 lane-local values to lane 0.[1]
The lab uses this helper:
1__device__ long long warp_sum(long long value) {
2 constexpr unsigned kFullMask = 0xffffffffu;
3 for (int offset = warpSize / 2; offset > 0; offset /= 2) {
4 value += __shfl_down_sync(kFullMask, value, offset);
5 }
6 return value;
7}Full mask is correct only because every lane reaches each shuffle. Tail lanes load additive identity 0 instead of exiting. CUDA defines a shuffle read from an inactive source lane as undefined, and participating lanes named by mask must execute same intrinsic with same mask.[1]
That contract rules out a tempting edge shortcut:
1if (index < n) {
2 value += __shfl_down_sync(0xffffffffu, value, 16);
3}On partial warp, mask names lanes that skipped call. Repair by keeping all lanes active and substituting identity for missing input, or compute one valid mask with __ballot_sync before lanes diverge and ignore shuffle results whose source lane isn't valid. Calling __activemask() after divergence can give participating lanes different masks. Mask says who participates. It doesn't turn invalid source lane into zero.
Only 20 values remain in final warp. Why is full mask safe in supplied lab but unsafe when lanes 20 through 31 return early?
Answer
Lab keeps all 32 lanes at every shuffle and gives lanes 20 through 31 value 0, so every named lane participates. Early return violates mask contract because full mask still names absent lanes. Use uniform participation with identity values or a correctly formed active mask plus valid-source guards.
Join warps inside one block
A 256-thread block contains eight warps. Each warp first reduces registers. Lane 0 of every warp writes one partial to eight-element shared array. One block-wide barrier then makes those writes visible before warp 0 reduces eight partials.
1sum = warp_sum(sum);
2if (lane == 0) {
3 warp_sums[warp] = sum;
4}
5__syncthreads();
6
7if (warp == 0) {
8 long long block_sum = lane < warp_count ? warp_sums[lane] : 0;
9 block_sum = warp_sum(block_sum);
10 if (lane == 0) {
11 block_sums[blockIdx.x] = block_sum;
12 }
13}__syncthreads() waits for every thread in block and makes earlier shared and global memory accesses visible to that block. Placing it under a condition that differs across block can hang or produce unintended behavior.[1]
Older teaching reductions begin with interleaved active threads, then replace divergent modulo branch with contiguous active lanes. They also give each thread several elements before shared tree begins. Those steps reduce branch divergence, address work, and synchronization while keeping total work.[2] Current NVIDIA samples still include a two-stage shared-memory reduction as runnable reference.[3]
One block can't synchronize with every other ordinary block inside kernel. Hierarchical reduction therefore writes one partial per block, then launches another reduction pass over partial array. Each launch on same stream forms device-wide ordering boundary:

Two practical details matter:
- Each thread accumulates two or more inputs before warp shuffle, balancing parallel depth against useful work per thread.
- Accepted operator needs associative grouping. Commutativity is optional only for algorithms that preserve operand order, such as scan. CUB
DeviceReducedoesn't support non-commutative reduction operators.[4]
For mathematical addition, (a+b)+c equals a+(b+c). Floating-point addition only approximates that law because each addition rounds. Tree can differ from serial sum without either kernel containing race.
Make floating-point reductions deliberate
Suppose float32 values include 100000000.0, 1.0, and -100000000.0. Serial grouping can lose 1 before cancellation, while another tree preserves it. Integer fixture avoided that ambiguity so CUDA lab could demand exact equality.
Use numerical contract suited to workload:
| Need | Reduction state or method | Check |
|---|---|---|
| exact counts or offsets | integer accumulator wide enough for total | exact equality and overflow boundary |
| ordinary float sum | pairwise tree, often wider accumulator | compare with higher-precision reference under stated tolerance |
| reproducible same-device runs | fixed tree and fixed launch policy | repeated bitwise check on same build and GPU |
| cross-device reproducibility | library guarantee or explicit portable tree | verify documented guarantee and test every target |
| cancellation-sensitive sum | compensated or higher-precision scheme | adversarial signed fixtures and error distribution |
Reproducibility and accuracy answer different questions. A fixed wrong order can reproduce. A more accurate algorithm can still choose different trees on different architectures.
Current CUB DeviceReduce documentation states default run_to_run determinism. Same input, build, launch configuration, tuning, CCCL version, and GPU select same fixed tree. Another architecture, policy, or toolkit release can change floating-point combining order.[4] Pin CCCL version and verify installed API when reproducibility enters product contract.
Two float sum kernels pass abs(error) < 1e-4. One returns same bits on every run, while other has lower error against FP64 but changes last bit across runs. Which one is correct?
Answer
Both satisfy stated accuracy tolerance. First also satisfies same-run reproducibility; second doesn't. If product needs both properties, neither single observation is enough. Write separate accuracy and determinism gates, then choose operator, accumulator, and library policy that pass both.
Keep every prefix with scan
Reduction's tree can discard intermediate totals. Scan must send block prefix into every later output. A work-efficient block scan has two phases:
- Upsweep: combine leaves into block total.
- Downsweep: replace root with identity, then propagate left-prefix information back to leaves.
Supplied Blelloch-style kernel handles 512 items per 256-thread block. Every thread loads two values into shared memory, participates in both tree phases, and writes exclusive output. It also records block total before root becomes 0.
Arrays longer than one block need three layers:
| Layer | Input | Output | Invariant |
|---|---|---|---|
| local block scan | original values | local exclusive prefixes plus one block total | each output excludes current value |
| recursive total scan | block totals | exclusive offset for every block | block 0 offset is identity |
| uniform add | local prefixes plus block offset | device-wide exclusive prefixes | every element receives totals of earlier blocks |
If first output of block 1 is 0 instead of sum of block 0, local tree probably passed and uniform add failed. That symptom points at propagation, not arithmetic inside block.
Blocks of four scan [3,-1,4,1 | 5,-9,2,6]. Local exclusive outputs are [0,3,2,6 | 0,5,-4,-2]. What offset repairs second block?
Answer
First block total is 7, so scan of block totals gives offsets [0,7]. Add 7 to every local output in second block, producing [7,12,3,5]. Combined output is [0,3,2,6,7,12,3,5].
Multi-pass scan is easy to inspect but moves block metadata in extra kernels. Merrill and Garland's decoupled look-back scan lets block compute local scan, publish aggregate and status, then look backward through earlier block states until global prefix is known. Small redundant work overlaps global prefix propagation with local work. Their report describes about input reads plus output writes, matching sequential scan's asymptotic data movement.[5]
CUB has used decoupled look-back for device-wide scan since early releases. DeviceScan docs expose inclusive and exclusive forms, in-place support, and temporary-storage contract. Classic stream overloads warn that pseudo-associative results may vary across runs. Newer execution-environment controls depend on version, type, and operator: current unstable docs default to not_guaranteed and permit run_to_run only for supported integral operators and floating-point cuda::std::plus.[6] Check pinned CCCL docs or headers before turning a determinism mode into a release gate.
Decoupled look-back isn't invitation to improvise spin protocol. Published states, memory ordering, forward progress, fallback path, and architecture behavior all need proof. Use library for standalone device scan unless fusion or data layout gives custom kernel clear reason to exist.
Treat stable softmax as reduction state
Softmax turns logits into probabilities:
Direct exponentiation can overflow for large positive logit. Stable form subtracts row maximum :
Subtracting same constant from all logits doesn't change probabilities, while largest exponent becomes .
Safe implementation can read logits once for maximum, again for denominator, then again to write normalized outputs. Online normalizer combines first two reductions. State (m, d) represents partition maximum and shifted exponential sum . Merge two partitions and by:
This operator is associative over finite logits in exact real arithmetic, so same warp and block reduction machinery can merge chunk states. Empty partition uses identity (-inf, 0), but merge must return other operand before evaluating formula. Otherwise two empty partitions evaluate -inf - -inf and poison denominator with NaN. Python model's two early returns enforce identity law. Milakov and Gimelshein derive online update, prove invariant, and reduce softmax memory accesses by combining maximum and denominator passes.[7]
Attention masks expose same edge. A masked logit contributes empty identity, not singleton state (-inf, 1). Fully masked row has no mathematical softmax distribution, so kernel contract must reject it or define explicit fallback before normalization. NaN and positive-infinity logits also need declared input contract because subtracting maximum doesn't make inf - inf defined.
Running tensor ends with m=6 and denominator near 1.578968. Python model then revisits logits only to write eight probabilities. Fusing next consumer can sometimes avoid writing full probability vector, but correctness contract expands to fused operation.
Online doesn't mean one sequential thread. Each lane can summarize local chunk into (m,d), then parallel tree merges those states. Intermediate maximum can rise, so old denominator must be rescaled before new contribution arrives.
Left chunk state is (5, 2) and right state is (6, 1). What merged denominator is measured relative to maximum 6?
Answer
Merged maximum is 6. Left denominator rescales by , while right stays at scale 1. Merged denominator is , about 1.7358. Adding 2 and 1 directly would mix different exponential scales.
Carry segments and indices when output needs them
Primitive choice follows output contract, not operator name.
- Segmented reduction: reduce several variable-length ranges independently, such as one maximum per sequence in packed batch.
- Arg reduction: return both winning value and index, with explicit tie and
NaNrule. - Reduce by key: combine adjacent runs sharing key, often after grouping.
- Scan by key: reset prefix when key changes.
For argmax, define state (value, index). Combine larger value; on tie, choose smaller index. Empty segment also needs documented identity or sentinel. Without those rules, parallel tree may return different valid-looking index after scheduling or tile change.
CUB provides device-wide, block-wide, and warp-wide collectives plus segmented variants. Scope controls who invokes operation and where output is valid. A BlockReduce result is meaningful only for designated thread; every lane writing it would duplicate or race output.[8]
Prefer library until fusion earns custom kernel
Current CUDA Core Compute Libraries (CCCL) CUB documentation layers reduction across thread, warp, block, and device scopes. Device APIs choose tuned policies by type, size, and architecture; block APIs expose reusable temporary storage and synchronization rules.[8]
Two-phase device reduction call looks like this. First call queries temporary bytes, second launches operation:
1void* temporary = nullptr;
2std::size_t temporary_bytes = 0;
3
4cub::DeviceReduce::Sum(
5 temporary, temporary_bytes, device_input, device_output, count
6);
7cudaMalloc(&temporary, temporary_bytes);
8cub::DeviceReduce::Sum(
9 temporary, temporary_bytes, device_input, device_output, count
10);Real code must check every CUDA return, own stream explicitly, reuse temporary allocation across hot calls, and free it after stream work completes. Newer CCCL execution-environment APIs can manage stream-ordered temporary storage, but version pin decides available surface.
Use this boundary:
| Situation | Default | Reason |
|---|---|---|
| standalone sum, min, max, argmax, or scan | CUB device primitive | tuned multi-pass policy, edge handling, and documented contract already exist |
| reduction inside custom epilogue | CUB block or warp primitive | keep fusion while reusing collective machinery |
| unusual reduction state with associative, commutative operator | CUB custom reduction first | custom state doesn't require custom scheduling |
| fuse load, transform, reduce, and write | custom kernel, benchmark against CUB composition | avoided intermediate traffic may justify ownership |
| unsupported layout or strict ordering | custom kernel with explicit tests | library contract may not match semantics |
| teaching mechanism | custom lab plus library comparison | inspectable source clarifies invariants, not performance leadership |
Custom kernel owns more than arithmetic: architecture policy, partial warps, temporary storage, stream semantics, deterministic order, sanitizer coverage, and future retuning.
Run downloadable CUDA lab
Download complete CUDA source. It contains four paths:
| Operation | Baseline | Hierarchical candidate | Correctness rule |
|---|---|---|---|
| sum reduction | one GPU thread loops over all values | two items per thread, warp shuffles, block partials, recursive passes | exact signed 64-bit total |
| exclusive scan | one GPU thread emits running total | 512-item block scans, recursive block-total scan, uniform add | every signed 64-bit prefix matches CPU |
Baselines are intentionally serial. They expose dependency and produce trusted integer reference, but they aren't competitive implementations.
Compile for installed GPU architecture. Attached local receipt used sm_120; command below keeps target explicit:
1GPU_ARCH=${GPU_ARCH:-sm_80}
2nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \
3 assets/primitives_lab.cu -o primitives_lab
4
5./primitives_lab 4194304 30Program prints hardware identity, API versions, workload, warmups, iteration count, exact correctness status, CUDA-event time, and logical byte rate. Reduction rate counts input bytes; scan rate counts one input read plus one output write. It isn't physical DRAM bandwidth because hierarchical paths also move partials and offsets.
Run odd size through correctness tools before trusting power-of-two benchmark:
1compute-sanitizer --tool memcheck --error-exitcode=99 ./primitives_lab 4099 1
2compute-sanitizer --tool racecheck --error-exitcode=99 ./primitives_lab 4099 1
3compute-sanitizer --tool synccheck --error-exitcode=99 ./primitives_lab 4099 1Compute Sanitizer distinguishes invalid addresses, shared-memory hazards, and synchronization misuse.[9] Numerical checks still matter because sanitizer can't prove prefix values.
Local benchmark receipt records full raw output from RTX 5070 Ti with CUDA 13.2, source hash, compiler command, warm-cache timing method, exact CPU checks, and clean sanitizer results. It doesn't claim CUB comparison, physical bandwidth, or portability. Re-run same receipt on target hardware before using any timing decision.
Test properties, not one fixture
One eight-value tensor makes mechanism visible. Property tests catch edge behavior.
Reduction properties
- Empty input returns declared identity or documented error.
- One value returns that value.
- Split then merge matches full reduction for truly associative operator.
- Permutation leaves output unchanged only when operator is commutative.
- Integer accumulator doesn't overflow tested bound.
- Float error stays within stated tolerance against higher-precision reference.
Scan properties
- Output length equals input length.
- Exclusive output at index 0 equals identity.
exclusive[i+1] == exclusive[i] op input[i]for exact operator.- Last inclusive output matches full reduction.
- Segment reset occurs exactly at supplied boundary.
- Sizes around block boundary pass: 0, 1, 31, 32, 33, 511, 512, 513.
Softmax properties
- For an accepted finite row with at least one unmasked logit, every output is finite and nonnegative.
- Row sums to 1 within tolerance.
- Adding constant to every logit leaves probabilities unchanged within tolerance.
- Probability argmax matches logit argmax under same tie rule.
- Extreme fixtures such as
[-10000, 0, 10000]don't produceNaNorInf. - Masked positions contribute empty identity, and fully masked rows follow declared error or fallback contract.
Property that fails often identifies broken layer faster than output dump.
Diagnose by first broken invariant
| Symptom | Likely boundary | First evidence | Repair |
|---|---|---|---|
| wrong only for partial warp | shuffle mask names absent lanes or invalid source | test 31, 32, 33 elements; inspect mask | zero-fill active lanes or guard valid source |
| hang only on edge block | some threads skipped block barrier | synccheck plus edge shape | keep barrier participation uniform |
| one correct sum, later runs vary | race, atomic order, or uninitialized partial | racecheck, initialized buffers, fixed-tree rerun | remove race; state determinism contract |
| each block scan restarts at zero | missing recursive offset propagation | inspect block totals and offsets | scan totals and uniform-add offsets |
| inclusive scan shifted one slot | identity or inclusive/exclusive convention mismatch | tiny hand fixture | write contract at API boundary |
softmax contains Inf | exponentials used unshifted logits | print row max and max shifted logit | subtract max or use online state |
| softmax finite but row sum wrong | denominator states merged without rescaling | inspect (m,d) per chunk | rescale both denominators to merged max |
softmax is NaN only on fully masked row | empty row entered ordinary normalization | count unmasked logits before normalize | reject row or apply declared fallback |
| argmax value right, index unstable | tie rule unspecified | duplicate maxima at different indices | encode deterministic pair comparator |
| custom kernel loses after library update | copied policy no longer fits target | same-shape CUB benchmark and profile | use library or retune with current target |
Stop timing at first correctness failure. Fast wrong prefix can corrupt allocation offsets far from kernel that produced them.
Keep complete benchmark receipt
Primitive timing is incomplete without five fields:
- Hardware and software: GPU, compute capability, driver, toolkit, compiler flags, source hash.
- Workload: count, segments, value distribution, alignment, warmups, iterations, cache state.
- Precision and algorithm: input, accumulator, output types, operator, hierarchy, deterministic policy.
- Baseline: exact serial, CUB, framework, or prior kernel path under equivalent work.
- Correctness: reference, tolerance, property sizes, sanitizer results.
Measure kernel execution with CUDA events in same stream. Separate allocation, first-use loading, and JIT cost unless latency objective includes them. Report distributions across process runs when decision is close. Attach profiler counters only after timing and correctness identify meaningful candidate.
No universal speedup follows from primitive name. Reduction shape, value type, segment distribution, architecture, library version, fusion, and launch overhead can reverse ranking.
Mastery check
Key concepts
- Reduction shrinks output; scan preserves one prefix per position.
- Warp shuffles move register values only among valid participating lanes.
- Block collectives need shared-memory visibility and uniform barriers.
- Device-wide collectives need inter-block propagation through more launches or proven look-back protocol.
- Stable softmax merges
(maximum, shifted denominator)states. - Masked softmax values contribute empty identity; fully masked rows need explicit contract.
- CUB is default for standard standalone primitives; custom CUDA needs fusion or semantic reason.
Evaluation rubric
- Foundational: Compute reduction, inclusive scan, exclusive scan, and argmax for eight-value tensor by hand.
- Intermediate: Explain warp, block, and device hierarchy plus mask, barrier, identity, and offset invariants.
- Advanced: Implement and benchmark custom fused primitive against CUB with numerical, determinism, sanitizer, and receipt gates.
Common pitfalls
- Treating active mask as replacement for valid-source check.
- Returning before block-wide barrier on tail data.
- Assuming floating-point addition is exactly associative.
- Forgetting to scan block totals before uniform add.
- Merging online-softmax denominators measured against different maxima.
- Calling demonstration baseline competitive without tuned library comparison.