1. What is it?#
Two related properties of how well a kernel uses the SM.
Occupancy = active warps / maximum possible warps. Determines how well memory latency is hidden.
Warp divergence = threads in a warp taking different branches, forcing serial execution of both paths.
Occupancy 100%: 64 warps resident, always one ready → latency hidden
Occupancy 25%: 16 warps resident, often all stalled → latency exposed
No divergence: all 32 lanes do useful work
Full divergence: 32 branches × 1 useful lane each = 1/32 efficiency2. Why do they exist?#
Occupancy exists because GPUs hide latency with parallelism rather than avoiding it with caches (Section I.09). More resident warps = more chances that one has its data ready.
Divergence exists because a warp is physically one instruction stream with 32 lanes (Section VI.02). Different lanes can’t execute different instructions; they can only be masked off.
3. Simple analogy#
Occupancy: a call centre. If each agent spends 90% of their time on hold waiting for a database, you need 10 agents to keep one conversation active at all times. More agents (warps) means fewer moments where everyone is on hold.
Divergence: a squad marching in lockstep. Order: “those with red badges, turn left; others, turn right.” The squad must do both movements sequentially, with half standing still each time. Two movements’ worth of time for one movement’s worth of progress.
4. Tiny example#
Divergence, measured:
// NO DIVERGENCE: the branch is uniform within each warp
__global__ void uniform_branch(float* x, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
if ((i / 32) % 2 == 0) x[i] = expensive_a(x[i]); // whole warps take the same path
else x[i] = expensive_b(x[i]);
}
}
// FULL DIVERGENCE: alternate lanes take different paths
__global__ void divergent_branch(float* x, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
if (i % 2 == 0) x[i] = expensive_a(x[i]); // lanes within a warp differ
else x[i] = expensive_b(x[i]);
}
}The divergent version takes ~2x as long, because each warp executes both branch bodies with half its lanes masked.
Measure with:
ncu --metrics smsp__thread_inst_executed_per_inst_executed.ratio ./bench
# 32.0 = no divergence; 16.0 = 2-way; 1.0 = fully divergent5. Technical explanation#
Occupancy arithmetic#
Limits per SM (Hopper):
max warps 64
max blocks 32
registers 65,536 per partition × 4 partitions
shared memory up to 228 KB
occupancy = min(
64,
32 × warps_per_block,
(65536 / (regs_per_thread × 32)) × 4 [approximately],
(228KB / shared_per_block) × warps_per_block
) / 64Get the inputs from nvcc --ptxas-options=-v:
ptxas info: Used 64 registers, 16384 bytes smem, 380 bytes cmem[0]Why occupancy matters — and when it doesn’t#
HIGH occupancy helps when:
- the kernel is memory-latency-bound (many dependent loads)
- there is little instruction-level parallelism per thread
HIGH occupancy does NOT help when:
- the kernel is bandwidth-saturated (more warps just means more waiting)
- the kernel has high ILP (each thread has independent work in flight)
- the kernel is compute-boundVolkov’s classic result: a matmul kernel at 33% occupancy, using many registers per thread to hold a large accumulator tile, beat a 100%-occupancy version. More work per thread meant more independent instructions in flight, which hides latency just as effectively as more warps.
So: occupancy is a means, not a goal. Target 40-60% and optimize for actual throughput. Modern GEMM and attention kernels typically run at 25-50% occupancy by design.
Reducing register pressure (if you need to)#
__global__ void __launch_bounds__(256, 4) my_kernel(...) { ... }
// ^ ^
// max threads/block min blocks/SM
This tells the compiler to limit registers so at least 4 blocks fit. But if the kernel
genuinely needs more registers, it will spill to local memory (which is global memory),
usually making things worse. Check lmem in the ptxas output.
Divergence patterns to avoid#
BAD: if (threadIdx.x % 2) alternate lanes diverge
BAD: if (data[i] > threshold) data-dependent, unpredictable
BAD: for (j = 0; j < lengths[i]; j++) variable trip count per lane
BAD: switch (type[i]) many-way divergence
OK: if (threadIdx.x < 32) warp-aligned
OK: if (blockIdx.x % 2) block-uniform
OK: if (i < n) only the last warp divergesFixing divergence#
1. WARP-ALIGN the condition
if (threadIdx.x / 32 == 0) instead of if (threadIdx.x % 2 == 0)
2. SORT/PARTITION the data so similar work lands in the same warp
This is what MoE implementations do: sort tokens by expert, then each
warp handles tokens for one expert.
3. PREDICATE instead of branch (for short bodies the compiler does this)
x = cond ? a : b; → a select instruction, no divergence
4. USE WARP PRIMITIVES to redistribute work
__ballot_sync to find which lanes have work, then compact
5. ACCEPT IT if the branch bodies are cheap
Divergence on two 3-instruction bodies costs 3 instructions. Fine.Point 2 is the important one for inference. MoE routing sends different tokens to different experts — maximally divergent if handled naively. Production MoE kernels sort tokens by expert id first, turning divergence into a grouped GEMM (Section XIII.03).
6. Under the hood#
Nsight Compute’s “Warp State Statistics” tells you why warps stalled:
Stall Reason Meaning Fix
─────────────────────────────────────────────────────────────────────────
Long Scoreboard waiting on global memory more occupancy, better access
Short Scoreboard waiting on shared memory reduce bank conflicts
Barrier at __syncthreads() fewer barriers, more work between
MIO Throttle memory pipe saturated reduce memory instructions
LG Throttle local/global pipe saturated vectorize
Math Pipe Throttle ALU saturated you're compute bound (good!)
Not Selected ready but another warp chosen fine, means you have parallelism
No Instruction instruction cache miss unusual; huge unrolled loops“Long Scoreboard” dominating with low occupancy is the classic “add more warps” signal. “Math Pipe Throttle” dominating means you’re compute-bound and doing well.
7. Performance implications#
Occupancy 12% → 50% on a latency-bound kernel: 2-4x faster
Occupancy 50% → 100% on the same kernel: 1.1-1.3x
Occupancy increase on a bandwidth-saturated kernel: ~0x
Full divergence eliminated: up to 32x on that region
Register spilling eliminated: 2-5x8. Production implications#
- Read occupancy in profiles, don’t chase it. 25-50% is normal for good kernels.
- Check for register spilling in any custom kernel:
lmemshould be 0. - Divergence in inference shows up in: MoE routing, variable-length sequence handling, sampling with per-request parameters, and early-exit schemes. Each has a sort-or-group fix.
- When evaluating a new kernel, look at warp stall reasons before anything else. They tell you what to fix.
9. Common mistakes#
Maximizing occupancy as a goal. It’s a means.
Using __launch_bounds__ to force occupancy and causing spills.
Ignoring divergence in data-dependent code.
Assuming the last-warp divergence from if (i < n) matters. It affects one warp out of
thousands.
Not checking stall reasons. They’re the diagnosis; occupancy is a symptom.
10. Hands-on exercise#
A. Measure divergence. Implement both kernels from section 4 with a genuinely expensive body.
Measure the ratio and thread_inst_executed_per_inst_executed. Confirm ~2x.
B. Occupancy sweep. Take a memory-latency-bound kernel. Artificially vary its register usage
(via __launch_bounds__ or extra local variables) and plot occupancy vs achieved bandwidth.
Where does the curve flatten?
C. Volkov’s result. Write a kernel where each thread computes 1 output element and one where each computes 8 (using more registers). Compare occupancy and throughput. Does the lower- occupancy version win?
D. Stall reasons. Profile three different kernels (a memory-bound elementwise, a GEMM, and a reduction) and compare their warp stall distributions. Explain each.
E. MoE divergence. Simulate MoE routing: 1024 tokens, 8 experts, random assignment. Implement (i) a naive kernel where each thread branches on its token’s expert, and (ii) a version that sorts tokens by expert first. Measure both.
11. Interview questions#
- Define occupancy. What limits it?
- Is higher occupancy always better? Explain with an example.
- What is warp divergence and what’s the worst-case cost?
- Give three ways to reduce divergence.
- Why is MoE routing divergence-prone, and how do production kernels handle it?
- What is register spilling and how do you detect it?
- You profile a kernel and see “Long Scoreboard” as the dominant stall reason at 15% occupancy. What do you do?
12. Further reading#
- [ESTABLISHED] Volkov, “Better Performance at Lower Occupancy” (GTC 2010)
- [REFERENCE] Nsight Compute warp state documentation
- [REFERENCE] CUDA Best Practices Guide, “Execution Configuration Optimizations”
- Next: 11 — Tensor cores