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. In an elementwise kernel like ReLU or bias addition, every thread works in total isolation: each lane loads one element, runs an arithmetic instruction, and writes its result straight back to memory without ever talking to a neighbor. The GPU's massive parallelism shines effortlessly.
Real deep learning operators don't get off that easily. Attention softmax, LayerNorm, RMSNorm, cross-entropy loss, and top-k sampling all force threads to combine information. Threads must pass values across registers inside a warp, across shared memory inside a thread block, and across global High Bandwidth Memory (HBM) across the grid.
These building blocks are parallel primitives, foundational collective patterns embedded inside larger kernels. Reduction folds an entire tensor or tile into a single summary scalar. Prefix scan keeps a running cumulative total at every boundary. Arg reduction tracks both the winning value and its source position. Stable softmax fuses these ideas to normalize exponentials in a single streaming pass without arithmetic overflow or intermediate round trips to off-chip memory.
The GPU Kernel Performance Engineering Lab examined memory coalescing and shared-memory bank conflicts through matrix transpose. Here, the layout is simple; the challenge is coordinating thread dependencies: which values must be written, synchronized, and published before another lane can proceed.
One total or every boundary?
Pair adjacent values in the running array [3, -1, 4, 1, 5, -9, 2, 6]. Adding neighboring pairs at the first level yields [2, 5, -4, 8]. Combining those adjacent sums at the second level yields [7, 4]. The final addition produces 11. A balanced reduction tree with leaves requires additions and sequential combining steps.
A prefix scan asks for a completely different contract: it keeps every intermediate boundary. An exclusive sum at index accumulates elements strictly before index , placing the additive identity 0 at index 0. An inclusive sum also includes . For the first four elements [3, -1, 4, 1], the exclusive prefix sum is [0, 3, 2, 6] and the inclusive prefix sum is [3, 2, 6, 7].

For exact real addition, the two scan conventions connect through a direct identity:
Arg reduction carries a structured payload: (value=6, index=7). When multiple positions share the maximum, you must pair the comparison operator with a deterministic tie-breaking rule, such as picking the lowest index. The Python reference below verifies all three primitive contracts before introducing GPU hardware details.
1x = [3, -1, 4, 1, 5, -9, 2, 6]
2exclusive, inclusive, total = [], [], 0
3for value in x:
4 exclusive.append(total)
5 total += value
6 inclusive.append(total)
7
8def argmax(values):
9 if not values:
10 raise ValueError("Argmax requires a nonempty sequence")
11 index = max(range(len(values)), key=lambda i: (values[i], -i))
12 return values[index], index
13
14assert total == 11
15assert exclusive == [0, 3, 2, 6, 7, 12, 3, 5]
16assert inclusive == [3, 2, 6, 7, 12, 3, 5, 11]
17assert argmax(x) == (6, 7)
18assert argmax([6, -1, 6]) == (6, 0)
19print("sum:", total)
20print("exclusive:", exclusive)
21print("inclusive:", inclusive)
22print("argmax (value, index):", argmax(x))1sum: 11
2exclusive: [0, 3, 2, 6, 7, 12, 3, 5]
3inclusive: [3, 2, 6, 7, 12, 3, 5, 11]
4argmax (value, index): (6, 7)Reduce within a warp using shuffle intrinsics
Early GPU reduction kernels relied heavily on shared memory: threads wrote inputs into a __shared__ array, synchronized via __syncthreads(), and accumulated across stride intervals. That approach cost shared memory capacity, suffered from bank conflicts when strided accesses hit identical memory banks, and stalled on block barriers at every tree level.
Modern CUDA warps (32 threads) bypass shared memory completely by using warp shuffle intrinsics like __shfl_down_sync. Shuffle instructions let threads read register values directly from other lanes within the same warp across the streaming multiprocessor's crossbar, delivering single-cycle register-to-register communication.[1]
Offsets 16, 8, 4, 2, 1 construct a five-stage reduction tree across all 32 lanes. Lane 0 accumulates the warp total, while higher lanes hold intermediate partials:
1__device__ long long warp_sum(long long value) {
2 constexpr unsigned kFullMask = 0xffffffffu;
3 const unsigned lane = threadIdx.x % warpSize;
4 for (int offset = warpSize / 2; offset > 0; offset /= 2) {
5 const long long other = __shfl_down_sync(kFullMask, value, offset);
6 if (lane + offset < warpSize) {
7 value += other;
8 }
9 }
10 return value; // Full aggregate is valid in lane 0.
11}Two hardware rules govern this kernel. First, starting with the Volta architecture, NVIDIA GPUs run with Independent Thread Scheduling. Before Volta, all 32 threads in a warp shared a single program counter and executed in lockstep. On modern architectures, threads maintain independent program counters and call stacks. Omitting a mask or relying on implicit lockstep execution leads to divergence bugs. The active mask kFullMask = 0xffffffffu explicitly forces all 32 lanes to synchronize at each shuffle step.[2]
Second, participating in the shuffle is separate from adding the result. If a warp processes only 20 active inputs, threads 20 through 31 shouldn't exit early. In CUDA's shuffle contract, reading from an exited or non-participating lane returns an undefined value or the caller's own value. If lane 4 reads from lane and lane 20 has exited, lane 4 receives its own value and doubles it. Feeding 0 into inactive lanes and running the full mask guarantees that every source lane contributes the additive identity. The conditional addition if (lane + offset < warpSize) belongs strictly after the shuffle instruction so that all lanes execute the collective together.
The CPU model verifies this behavior by checking lane participation, missing source rejections, and signed 64-bit integer overflow protection:
1from assets.primitives_model import warp_sum, I64_MAX, expect_error
2
3lanes = [1] * 20 + [0] * 12
4assert warp_sum(lanes) == 20
5expect_error(ValueError, lambda: warp_sum(lanes, active=range(20)))
6assert warp_sum([0] * 31 + [I64_MAX]) == I64_MAX
7expect_error(OverflowError, lambda: warp_sum([I64_MAX, 1] + [0] * 30))
8print("20 values + 12 participating zero lanes: sum 20")
9print("Absent source rejected; representable and overflowing int64 sums distinguished")120 values + 12 participating zero lanes: sum 20
2Absent source rejected; representable and overflowing int64 sums distinguishedTo aggregate across a 256-thread block (8 warps), lane 0 of each warp deposits its warp total into __shared__ long long warp_sums[8]. A block-wide barrier __syncthreads() guarantees all 8 warps finish their writes before warp 0 reads the shared array. Warp 0's first 8 lanes load the 8 partial sums, while lanes 8 through 31 load zero. A second warp_sum folds those 8 partials, leaving the entire block total in lane 0:
1sum = warp_sum(sum);
2if (lane == 0) {
3 warp_sums[warp] = sum;
4}
5__syncthreads();
6if (warp == 0) {
7 long long block_sum = lane < 8 ? warp_sums[lane] : 0;
8 block_sum = warp_sum(block_sum);
9 if (lane == 0) {
10 block_sums[blockIdx.x] = block_sum;
11 }
12}Notice the barrier placement: __syncthreads() sits outside all warp-divergent branches. Placing a block barrier inside if (lane == 0) or if (warp == 0) triggers undefined behavior because only a fraction of the block's threads reach the barrier.
Aggregate partials across blocks: multi-pass kernels versus global atomics
Each thread in the block loads two elements: one at block_start + threadIdx.x and one 256 elements later. Consecutive threads access consecutive memory addresses, ensuring fully coalesced global memory loads.[3] A 256-thread block consumes 512 input elements and emits a single 64-bit block partial.
To reduce across thousands of blocks, why can't we just synchronize the entire grid inside one kernel? Because GPUs don't provide a general grid-wide barrier across independent thread blocks. Blocks execute dynamically across available Streaming Multiprocessors. If a kernel paused waiting for unlaunched blocks that can't fit on the SMs due to occupancy limits, the GPU would deadlock.
Two architectural strategies solve this grid-scale aggregation:
- Multi-pass hierarchical kernel launches: Each kernel pass reduces elements by a factor of 512. For 4,194,304 inputs:
14,194,304 inputs -> 8,192 partials -> 16 partials -> 1 scalar
2 pass 1 pass 2 pass 3Because the host queues all three launches in the same CUDA stream, the hardware guarantees they execute sequentially without race conditions. Each pass streams data in wide, coalesced bursts across all memory channels, avoiding write contention.
- Global atomics (
atomicAdd): In this approach, each block reduces its local tile and usesatomicAdd(&global_sum, block_sum)to update a single output location in global memory.
- When atomics make sense: If the grid has few blocks, or if you're reducing into many independent output bins (like histogramming or channel-wise normalization), atomic contention is minimal.
- When atomics crawl: When thousands of blocks all finish nearly simultaneously and attempt an
atomicAddon the exact same 64-bit address, the memory controllers in the L2 cache slice serialize all requests. The pipeline stalls waiting for read-modify-write transactions to queue up.
The hierarchical multi-pass approach eliminates serialization entirely, trading small launch overhead for clean bandwidth scaling.
Floating-point trees can disagree without a race
Integer addition is associative and commutative, so grouping and ordering don't alter the mathematical result (provided no intermediate overflows). Real numbers are also associative, but IEEE 754 floating-point addition rounds after every single operation. Floating-point addition is commutative (), but it's not associative:
The standard library example below forces 32-bit float truncation to reveal how operand ordering changes low bits on identical inputs:
1from struct import pack, unpack
2from math import fsum
3
4def f32(value):
5 return unpack("f", pack("f", value))[0]
6
7a, b, c = f32(100_000_000), f32(1), f32(-100_000_000)
8left_grouped = f32(f32(a + b) + c)
9cancel_first = f32(f32(a + c) + b)
10assert left_grouped == 0.0
11assert cancel_first == 1.0
12assert fsum([a, b, c]) == 1.0
13print("(a + b) + c:", left_grouped)
14print("(a + c) + b:", cancel_first)
15print("Higher-precision reference:", fsum([a, b, c]))1(a + b) + c: 0.0
2(a + c) + b: 1.0
3Higher-precision reference: 1.0In a parallel GPU reduction, thread block execution order is nondeterministic across SMs. If blocks complete in different orders, or if a dynamic schedule alters which block partials combine first, the low-order mantissa bits vary between runs.
This bitwise difference doesn't mean there's a race condition or a bug. It's the natural consequence of floating-point non-associativity across dynamic parallel trees.
Repeatability and numerical accuracy are two distinct properties:
- Repeatability (determinism) guarantees that running the kernel twice on the same input produces the exact same bit pattern. CUB's
DeviceReduceprovidesrun_to_rundeterminism by fixing the internal tile reduction order.[4] But bitwise repeatability doesn't prove the result is close to the true mathematical sum. - Accuracy measures how close the computed float is to infinite-precision arithmetic. A balanced pairwise summation tree is generally far more accurate than serial accumulation because it avoids adding tiny numbers to massive running totals, even if scheduling differences cause tiny bit variations across runs. When verifying float kernels, always compare against a higher-precision reference using explicit relative and absolute tolerances rather than testing for bitwise equality.
Parallel scan: Hillis-Steele step efficiency versus Blelloch work efficiency
While reduction collapses an array into one value, prefix scan distributes cumulative prefixes back to every element. Two classic parallel algorithms tackle prefix scan with different complexity trade-offs:
-
Hillis-Steele (Step-efficient): In each step (with stride ), every active thread adds the element at . It completes in only steps. However, every step performs additions across almost the entire array, yielding total work. Hillis-Steele is ideal inside a single 32-lane warp via
__shfl_up_sync: because all 32 lanes execute in parallel on the warp's ALUs in 5 steps, the extra additions don't waste instruction cycles because inactive lanes are merely masked out. -
Blelloch (Work-efficient): Blelloch scan takes steps, but performs only additions ( operations total). It splits the scan into two distinct phases in shared memory:
- Up-Sweep (Reduce): A binary reduction tree where stride doubles each round (
1, 2, 4, ...). Threads add values upward until the root (last array position) holds the grand total. - Down-Sweep (Distribute): Save the root total, overwrite the root with 0 (the additive identity), and reverse the tree with stride halving (
... 4, 2, 1). At each step, a node sends its current value to its left child, while its right child receives the sum of its old left child and its current value.
For a 512-item block, Hillis-Steele requires roughly additions, whereas Blelloch needs only additions: a reduction in shared-memory traffic and arithmetic operations.[5]
The trace below captures the exact array mutations during Blelloch exclusive scan on [3, -1, 4, 1]:
1from assets.primitives_model import block_scan, device_scan
2
3trace = []
4local, total = block_scan([3, -1, 4, 1], width=4, trace=trace)
5for phase, values in trace:
6 print(f"{phase:8}: {values}")
7assert local == [0, 3, 2, 6] and total == 7
8x = [3, -1, 4, 1, 5, -9, 2, 6]
9assert block_scan(x[4:], width=4)[0] == [0, 5, -4, -2]
10assert device_scan(x, width=4) == [0, 3, 2, 6, 7, 12, 3, 5]
11print("Device-wide:", device_scan(x, width=4))1up 1 : [3, 2, 4, 5]
2up 2 : [3, 2, 4, 7]
3root = 0: [3, 2, 4, 0]
4down 2 : [3, 0, 4, 2]
5down 1 : [0, 3, 2, 6]
6Device-wide: [0, 3, 2, 6, 7, 12, 3, 5]Propagate block offsets: multi-pass hierarchies versus decoupled look-back
A local Blelloch scan computes correct prefixes within each block, but every block starts at local offset 0. To stitch them into a device-wide prefix scan, each block must receive the cumulative sum of all preceding blocks.
In a hierarchical multi-pass scan:
- Each block scans its 512 elements, emits local prefixes, and writes its block total to a global
block_sumsbuffer. For blocks with inputs[3,-1,4,1]and[5,-9,2,6], the block totals are[7, 4]. - A second kernel scans
block_sumsto compute block offsets:exclusive_scan([7, 4]) = [0, 7]. - A third kernel broadcasts each block's offset across its threads: Block 0 adds
+0, while Block 1 adds+7to all its local prefixes, converting[0, 5, -4, -2]into[7, 12, 3, 5].
![Two four-item blocks have totals 7 and 4, whose exclusive scan yields block offsets 0 and 7. Block 0 local prefixes [0,3,2,6] receive offset +0. Block 1 local prefixes [0,5,-4,-2] receive broadcast offset +7, producing global exclusive prefixes [7,12,3,5].](/cdn/content-image/fundamentals/gpu-parallel-primitives-lab/illustrations/_generated/scan_block_offsets_dark.png?v=e454c29166a3)
Testing scan hierarchies requires exercising recursion boundaries. If your block size is 512, an input of elements produces exactly 512 block totals, which fit in a single metadata block. But elements produce 513 block totals, spilling into two metadata blocks and triggering another recursion level. Testing only small powers of two misses that structural threshold.
Decoupled Look-Back (Single-Pass Scan):
Multi-pass scan requires three kernel launches and writes all intermediate block totals to global memory. NVIDIA CUB's DeviceScan eliminates intermediate launches using decoupled look-back (Merrill & Garland 2016):[5]
- Blocks grab dynamic tile indices via an atomic counter.
- Each block computes its local reduction aggregate and publishes a status flag in global memory:
X: Uninitialized.A: Local aggregate computed and published.P: Inclusive prefix across all prior tiles computed and published.
- When block finishes its local aggregate, it looks back at tile :
- If tile is marked
P, block reads the prefix directly, adds its local aggregate, and publishes its own statusP. - If tile is marked
A, block adds tile 's aggregate and looks further back to tile , accumulating aggregates until it reaches a tile markedP.
- If tile is marked
Decoupled look-back achieves a single-pass device scan with approximately global memory reads and global memory writes, overlapping prefix propagation directly with tile computation.[6]
Online softmax carries a scale with its sum
Softmax converts an array of unnormalized logits into a probability distribution:
Subtracting the maximum prevents floating-point overflow when evaluating exponentials.
A conventional 3-pass GPU softmax makes three full trips across global memory (HBM):
- Reduction pass: find the global maximum .
- Reduction pass: compute the normalizer denominator .
- Elementwise pass: compute each probability .
When tensors are large, those three memory round trips throttle performance on memory-bandwidth-bound GPUs.
The online normalizer (Milakov & Gimelshein 2018) merges the maximum search and exponential summation into a single pass.[7] It maintains a running state tuple , where is the running maximum and is the exponential sum measured relative to that maximum.
When merging two partial states and , the combined maximum is . Before adding the sums, each denominator must be rescaled to match the new reference point:
For our running array partitioned into two halves, has and . The right half has and . When merging, the global maximum becomes 6. The left denominator shrinks by to become , while the right denominator stays unchanged. Adding them gives the exact combined denominator .

1from math import exp, inf, isclose, fsum
2from assets.primitives_model import softmax_state, merge_states, softmax, expect_error
3
4x = [3, -1, 4, 1, 5, -9, 2, 6]
5left, right = softmax_state(x[:4]), softmax_state(x[4:])
6merged = merge_states(left, right)
7assert merged[0] == 6
8assert isclose(merged[1], fsum(exp(v - 6) for v in x), rel_tol=1e-15)
9assert isclose(fsum(softmax(x)), 1.0, abs_tol=1e-15)
10assert softmax([None, 5, None]) == [0, 1, 0]
11assert softmax([-10000, 0, 10000]) == [0, 0, 1]
12assert merge_states((-inf, 0), (-inf, 0)) == (-inf, 0)
13expect_error(ValueError, lambda: softmax([None, None]))
14expect_error(ValueError, lambda: softmax([inf, 1]))
15for a, b in zip(softmax(x), softmax([v + 1000 for v in x])):
16 assert isclose(a, b, rel_tol=1e-14, abs_tol=1e-15)
17print(f"left: m={left[0]}, d={left[1]:.6f}")
18print(f"right: m={right[0]}, d={right[1]:.6f}")
19print(f"merged: m={merged[0]}, d={merged[1]:.6f}")
20print("Shift invariance, extreme logits, empty identity and masking checked")1left: m=4, d=1.424404
2right: m=6, d=1.386195
3merged: m=6, d=1.578968
4Shift invariance, extreme logits, empty identity and masking checkedHow FlashAttention builds on the online normalizer: This online state update is the foundation behind FlashAttention (Dao et al., 2022).[8] Standard attention computes , takes the full softmax , and multiplies by , materializing an attention matrix in HBM ( memory traffic).
FlashAttention loads blocks of , , and into fast SRAM (shared memory/registers). For each block of keys and values, it computes local dot products , updates the running online softmax state , and rescales the accumulated output accumulator in registers:
At the end of the sequence, it divides by the final accumulated denominator . The attention probabilities are never written to HBM, shrinking memory footprint from to and turning bandwidth-choked attention into compute-bound tensor core math.
Race conditions and verification: sanitizing shared-memory hazards
Parallel primitives push shared memory to its limit. Threads within a block constantly exchange data through shared arrays. Writing correct parallel primitives requires catching three subtle classes of data hazards:
- RAW (Read-After-Write): Thread A writes to a shared index, but Thread B reads before the write finishes.
- WAR (Write-After-Read): Thread A reads from a shared location, but Thread B overwrites that location before Thread A finishes loading.
- WAW (Write-After-Write): Multiple threads attempt to write to the same shared address without a deterministic sequence.
NVIDIA's Compute Sanitizer provides specialized tools to detect these defects at the machine-instruction level:[9]
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 1Each tool addresses a specific failure mode:
synccheckvalidates barrier usage. If threads within a block call__syncthreads()inside divergent branches (where some threads participate while others take an alternative path), the hardware can deadlock or hang.synccheckverifies that all non-exited threads in a block reach the identical barrier instruction.racechecktracks shared memory accesses dynamically. It records the cycle and warp of every load and store. If two threads access the same shared memory location without an intervening__syncthreads()or__syncwarp(),racecheckflags the exact instruction and line number.memcheckdetects out-of-bounds reads and writes in global and shared memory, plus unaligned memory accesses.
Keep two verification rules firmly separated:
- A clean sanitizer run doesn't prove arithmetic correctness. If your scan kernel forgets to add block offsets,
racecheckreports 0 hazards because there are no race conditions, but every output value in block 1 will be wrong. - A passing test run doesn't prove race-freedom. On an idle GPU running a small test, warps might serendipitously execute in order and produce the correct output. But under production load with multiple kernels competing for SM resources, that hidden race condition will corrupt output buffers. Always run both reference assertions and Compute Sanitizer tools.
Select the primitive by its output contract
Different workloads demand different output contracts:
| Needed output | Primitive | Contract to specify |
|---|---|---|
| One aggregate for an array | reduction | identity element, accumulator type, associativity |
| Cumulative offset at every index | exclusive scan | identity, input ordering, operator |
| Cumulative aggregate including index | inclusive scan | operator, boundary definition |
| One aggregate per variable-length segment | segmented reduction | segment flag array, empty segment identity |
| Winning value and source position | arg reduction | tie-breaking rule, NaN policy, index type |
| Streaming normalizer without intermediates | online softmax | running maximum, decaying sum, empty state |
For standalone collective operations on whole arrays, prefer NVIDIA CUB (cub::DeviceReduce, cub::DeviceScan, cub::BlockScan).[10] CUB is part of NVIDIA's CUDA Core Compute Libraries (CCCL). It features tuned architecture-specific policies, auto-tuning for different GPU generations, and optimized decoupled look-back implementations.
CUB's device-wide APIs use a two-step allocation pattern: query the required scratchpad size with a nullptr, allocate memory, then execute:
1void* temporary = nullptr;
2std::size_t temporary_bytes = 0;
3CHECK_CUDA(cub::DeviceReduce::Sum(
4 temporary, temporary_bytes, input, output, count, stream));
5CHECK_CUDA(cudaMalloc(&temporary, temporary_bytes));
6CHECK_CUDA(cub::DeviceReduce::Sum(
7 temporary, temporary_bytes, input, output, count, stream));When should you write a custom collective kernel instead of calling CUB?
- Kernel fusion: If reduction or scan is only one step of a larger workflow (such as online softmax inside attention, LayerNorm, or RoPE), calling CUB forces you to write intermediate tensors to HBM and launch separate kernels. Fusing the primitive directly inside your custom kernel keeps data in registers and shared memory.
- Specialized tensor layouts: If your data resides in register-tiled GEMM fragments or strided multi-dimensional buffers that CUB's linear iterators can't map, custom warp and block primitives are essential.
Run the CUDA lab and verify correctness boundaries
Download the CUDA source code into your working environment:
Compile with full optimization and line information:
1: "${GPU_ARCH:?Set GPU_ARCH to your installed GPU target, for example sm_80}"
2nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \
3 assets/primitives_lab.cu -o primitives_lab
4./primitives_lab 4194304 30Before benchmarking, run correctness sweeps across warp boundaries (31, 32, 33), block boundaries (255, 256, 257, 511, 512, 513), and recursive scan boundaries ():
1for n in 1 31 32 33 255 256 257 511 512 513 4099 262145; do
2 ./primitives_lab "$n" 1 || exit 1
3doneCPU checks you can run immediately
The companion Python model verifies these same index boundaries, checking 17 hierarchy sizes, 260 recursive scan cases, signed overflow handling, and online softmax partition merges:
1from assets.primitives_model import run_checks
2run_checks()1CPU models: 17 hierarchy sizes and 260 recursive scan cases pass
2Overflow, absent shuffle sources, invalid widths and masked/nonfinite rows checked
3No CUDA compilation, GPU synchronization, sanitizer or performance claimInterpret timing without confusing traffic and bandwidth
The benchmark measures elapsed time using CUDA events across 30 iterations after 5 warmup cycles. Allocation and initial host-to-device transfers are excluded from timing.
The benchmark reports logical byte rate:
- For reduction: input bytes divided by elapsed time.
- For scan: input bytes plus output bytes ( bytes total) divided by elapsed time.
This logical rate reflects algorithmic data volume, not physical DRAM transactions. It doesn't include intermediate scratchpad writes or cache hits in L2. Always record compiler flags, CUDA toolkit versions, GPU architecture, and profiler metrics (such as memory throughput from NCU) when reporting performance numbers.
Review questions
1. Why does warp shuffle avoid shared memory bank conflicts during intra-warp reduction?
Shuffle intrinsics exchange register contents directly over hardware crossbar interconnects within the 32-lane warp. They require zero shared memory allocation, generate no memory transactions, and eliminate block-wide __syncthreads() barriers.
2. What is the fundamental algorithmic tradeoff between Hillis-Steele and Blelloch parallel scan?
Hillis-Steele executes steps with total additions, prioritizing step efficiency at the expense of work efficiency. Blelloch splits execution into Up-Sweep reduction and Down-Sweep distribution phases taking steps but only operations, matching serial work complexity.
3. Why do floating-point parallel reductions yield non-identical results across different tile configurations?
IEEE 754 floating-point addition is non-associative: . Altering thread block dimensions, tile sizes, or reduction tree hierarchies shifts intermediate rounding boundaries, producing slight bitwise discrepancies even in strictly race-free code.
4. How does decoupled look-back eliminate multi-pass kernel launches in global prefix scans?
In decoupled look-back, blocks publish their local status and running totals into global state flags using release consistency. Downstream blocks inspect upstream flags directly, either consuming completed prefixes or accumulating partial aggregates dynamically without terminating the kernel grid.
5. Why does online softmax rescale running accumulators when updating the maximum?
Online softmax applies the algebraic identity . When encountering an element larger than the current maximum, the kernel rescales its existing exponential sum in registers by , maintaining numerical stability in a single pass without intermediate HBM writes.