Parallel Reduction
Implementing sum/max/min across millions of elements β from naive to warp-optimized, with progressive optimization steps.
Reduction β computing a single value (sum, max, min) from an array β is the simplest kernel that requires inter-thread communication. Itβs also the foundation for LayerNorm, Softmax, loss computation, and gradient aggregation. Getting reduction right teaches the core GPU optimization skills: avoiding divergence, eliminating bank conflicts, and using warp primitives.
1. The Problem
Given an array of elements, compute the sum:
On CPU: one loop, sequential. On GPU: we want time with parallel threads, but threads need to combine their partial results β this requires a tree reduction.
Performance Target
Reduction is memory-bound β we read elements and do additions. Arithmetic intensity β 1. The target is: achieve peak HBM bandwidth.
For H100 (3,350 GB/s), reducing 100M floats (400 MB): theoretical minimum = ms.
2. V0: Naive Parallel Reduction
Each thread block reduces a chunk. Within the block, use a tree pattern in shared memory:
__global__ void reduceV0(float* input, float* output, int N) {
__shared__ float sdata[256];
int tid = threadIdx.x;
int idx = blockIdx.x * blockDim.x + threadIdx.x;
sdata[tid] = (idx < N) ? input[idx] : 0.0f;
__syncthreads();
// Tree reduction: stride starts at 1, doubles each step
for (int stride = 1; stride < blockDim.x; stride *= 2) {
if (tid % (2 * stride) == 0) {
sdata[tid] += sdata[tid + stride];
}
__syncthreads();
}
if (tid == 0) output[blockIdx.x] = sdata[0];
}
Problem: tid % (2 * stride) == 0 causes warp divergence β in the first iteration, only even threads are active (50% utilization). In later iterations, even fewer threads work.
3. V1: Eliminate Warp Divergence
Replace the modulo condition with contiguous thread mapping:
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
sdata[tid] += sdata[tid + stride];
}
__syncthreads();
}
Now threads 0 to stride-1 are active β contiguous threads in the same warps. In the first iteration, the first 128 threads (4 warps) are all active with no divergence. Only the last few iterations have partial warps.
Speedup: ~1.5-2Γ over V0.
4. V2: Eliminate Bank Conflicts
V1 has bank conflicts when stride is a power of 2: threads access sdata[tid] and sdata[tid + stride], which may map to the same bank.
Fix: Reverse the reduction direction β start with large stride and halve:
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
sdata[tid] += sdata[tid + stride];
}
__syncthreads();
}
Actually V1 already does this correctly. The bank conflict issue is more subtle β it appears in V0βs increasing-stride pattern where sdata[tid] and sdata[tid + stride] with small stride hit nearby banks. V1βs decreasing-stride pattern naturally avoids the worst conflicts.
5. V3: First-Add During Load
In V0-V2, half the threads are idle from the start β they just load data and immediately become inactive. Fix: Each thread loads and adds two elements during the load phase:
__global__ void reduceV3(float* input, float* output, int N) {
__shared__ float sdata[256];
int tid = threadIdx.x;
int idx = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
// Each thread adds two elements during load
float val = 0.0f;
if (idx < N) val += input[idx];
if (idx + blockDim.x < N) val += input[idx + blockDim.x];
sdata[tid] = val;
__syncthreads();
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
sdata[tid] += sdata[tid + stride];
}
__syncthreads();
}
if (tid == 0) output[blockIdx.x] = sdata[0];
}
Speedup: ~2Γ over V2 β we halved the number of blocks needed.
6. V4: Warp Shuffle for Final Steps
The last 5 iterations (stride β€ 16) only involve a single warp. We donβt need __syncthreads() or shared memory β use warp shuffle instead:
// After shared memory reduction gets down to 32 elements:
if (tid < 32) {
float val = sdata[tid];
// Warp-level reduction using shuffle
val += __shfl_down_sync(0xFFFFFFFF, val, 16);
val += __shfl_down_sync(0xFFFFFFFF, val, 8);
val += __shfl_down_sync(0xFFFFFFFF, val, 4);
val += __shfl_down_sync(0xFFFFFFFF, val, 2);
val += __shfl_down_sync(0xFFFFFFFF, val, 1);
if (tid == 0) output[blockIdx.x] = val;
}
Why itβs faster: __shfl_down_sync is register-to-register β no shared memory access, no bank conflicts, ~1 cycle per operation.
7. V5: Full Warp Shuffle Reduction
Take it further β do the entire block reduction using warp shuffle:
__global__ void reduceV5(float* input, float* output, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float val = (idx < N) ? input[idx] : 0.0f;
// Step 1: Warp-level reduction (32 β 1)
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
}
// Step 2: Each warp's lane 0 writes to shared memory
__shared__ float warp_sums[8]; // max 8 warps per block (256 threads)
int warp_id = threadIdx.x / 32;
int lane = threadIdx.x % 32;
if (lane == 0) warp_sums[warp_id] = val;
__syncthreads();
// Step 3: First warp reduces the warp sums
if (warp_id == 0) {
val = (lane < blockDim.x / 32) ? warp_sums[lane] : 0.0f;
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
}
if (lane == 0) output[blockIdx.x] = val;
}
}
This is the production pattern: warp shuffle β shared memory (one value per warp) β warp shuffle. Minimal shared memory, minimal synchronization.
8. Two-Level Reduction: Block β Global
Each block produces one partial sum. To get the final result, we need a second reduction:
Option A: Launch a second kernel to reduce the block results (simple, always works).
Option B: Use atomicAdd for the final aggregation (one atomic per block β low contention).
// At the end of each block's reduction:
if (threadIdx.x == 0) {
atomicAdd(output, block_sum); // one atomic per block
}
For 400 blocks, this is 400 atomics β negligible overhead.
9. Performance Summary
| Version | Key optimization | Relative speedup |
|---|---|---|
| V0 | Naive tree | 1.0Γ (baseline) |
| V1 | Eliminate divergence | ~1.5Γ |
| V2 | Fix bank conflicts | ~1.7Γ |
| V3 | First-add during load | ~3.0Γ |
| V4 | Warp shuffle for tail | ~3.3Γ |
| V5 | Full warp shuffle | ~3.5Γ |
| + two-level | Block atomics | ~3.5Γ (same, less overhead) |
| Target | Peak HBM bandwidth | ~4.0Γ |
Key lesson: Most of the speedup comes from V3 (eliminating idle threads) and V5 (replacing shared memory with warp shuffle). The pattern β load efficiently, reduce in registers, minimize shared memory, use atomics for global aggregation β applies to every reduction-like operation in AI.
Self-Check
- Explain why reduction is memory-bound and what the bandwidth target is
- Implement V0 (naive) and explain the warp divergence problem
- Implement the first-add-during-load optimization (V3)
- Implement a full warp shuffle reduction (V5)
- Explain the two-level reduction pattern (block β atomic)
- Calculate theoretical minimum time for reducing 100M floats on H100
References
- NVIDIA β Optimizing Parallel Reduction (Harris) β the classic tutorial
- CUB Library β Device-wide Reduction