Day 22Module 3
in-technical-review

Warp divergence and what it costs

This course's own first draft named day 22 like this:

"Warp divergence. Why if (threadIdx.x % 2) halves throughput and if (threadIdx.x < 16) does not."

research/CURRICULUM.v0.md, day 22, corrected before anything was written

Both of those predicates diverge. A warp is 32 threads issuing one instruction together, warps are cut from consecutive threadIdx.x values, and a condition that changes inside one of those runs of 32 splits the warp however it is spelled. threadIdx.x < 16 splits the warp holding thread 16, and in a block of 256 threads it also sends 16 threads down the first path instead of 128, so it is not a fix, and it is not even the same amount of work.

A branch costs nothing when all 32 lanes agree. This page measures what it costs when they do not, across two predicates that split the warp and two that do not, with the work held identical across all four.

What happens when 32 lanes disagree

The SIMT rule is one sentence long, and the guide states both halves of it:

"A warp executes one common instruction at a time, so full efficiency is realized when all 32 threads of a warp agree on their execution path. If threads of a warp diverge via a data-dependent conditional branch, the warp executes each branch path taken, disabling threads that are not on that path."

CUDA Programming Guide 3.2.2.1, "SIMT Execution Model", https://docs.nvidia.com/cuda/cuda-programming-guide/03-advanced/advanced-kernel-programming.html (checked 2026-08-30)

Read "each branch path taken" as arithmetic. A warp where every lane takes the if runs the if and stops. A warp where one lane takes the else runs the if, then runs the else, and the lanes that are not on the current path sit there switched off.

The warp pays the sum of the paths its lanes take, not the average and not the larger of the two. One dissenting lane costs the same as sixteen.

That is warp divergence, and the second half of the rule is what makes it fixable:

"Branch divergence occurs only within a warp; different warps execute independently regardless of whether they are executing common or disjoint code paths."

Same section, same page.

So the unit that can be wasted is the warp, and the warp scheduler is happy to send neighbouring warps down completely different code. Take a block of 256 threads, 8 warps, where half the threads run a long path A and half run an equally long path B. If the split falls inside each warp, all 8 warps run both paths: 16 path-runs.

If the split falls on warp boundaries, 4 warps run A and 4 run B: 8 path-runs. The same threads did the same arithmetic, and one arrangement issued twice the instructions.

After the branch the warp reconverges and goes back to one instruction for 32 lanes.

Since Volta that is less automatic than it sounds, which independent thread scheduling covers: "Starting with the Volta architecture, Independent Thread Scheduling allows a warp to remain diverged outside of the data-dependent conditional block. An explicit __syncwarp() can be used to guarantee that the warp has reconverged for subsequent instructions." (CUDA C++ Best Practices Guide 13.1, "Branching and Divergence", https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html , checked 2026-08-30.)

For a kernel that only does arithmetic this changes nothing. For the warp shuffles and votes in the next lesson it changes everything, which is why every one of them carries a _sync in its name.

Two within-warp arrangements make all 8 warps take 2 passes, while assigning whole warps to paths needs only 8 total path-runs.

The predicate is the thing, not the branch

The intuition people bring is that branches are expensive on a GPU, so the skill is removing them. That is the wrong target. A branch every thread in the warp agrees on is an ordinary branch.

It takes one instruction and one path, with no waste. Thirty-two warps can take thirty-two different paths and none of them pays for the others.

NVIDIA writes the fix as a formula rather than as advice:

"A trivial example is when the controlling condition depends only on (threadIdx / WSIZE) where WSIZE is the warp size. In this case, no warp diverges because the controlling condition is perfectly aligned with the warps."

Best Practices Guide 13.1, same page, checked 2026-08-30

So run your predicate through that test rather than counting ifs. The mapping it tests never moves: "The way a block is partitioned into warps is always the same; each warp contains threads of consecutive, increasing thread IDs with the first warp containing thread 0" (same section). In a two-dimensional block the thread ID is x + y * blockDim.x, from the guide's Thread Hierarchy section, so a 32 by 8 block puts one row in each warp and a condition on threadIdx.y is warp-uniform while the same condition on threadIdx.x is not.

In a one-dimensional block of 256 threads, with warps cut at 0 to 31, 32 to 63 and so on, which day 21 read back out of the hardware rather than assuming:

Predicate Warps that split Threads on path A
threadIdx.x % 2 == 0 8 of 8 128
(threadIdx.x % 32) < 16 8 of 8 128
threadIdx.x < 16 1 of 8 16
(threadIdx.x / 32) % 2 == 0 0 of 8 128
blockIdx.x % 2 == 0 0 of 8 0 or 256

Row 3 is the draft's fix, and the table says both things that are wrong with it at once. It does split a warp, so it diverges. And it puts 16 threads on path A where the row above puts 128, so if it had been timed against row 1 it would have looked faster for a reason that has nothing to do with divergence: it does less work.

Row 4 is the shape you want, and you have already written its cheap cousin. Most kernels in this course end with if (i < n), which splits at most one warp in the whole grid, the one that straddles the end of the data, and guards a single store. Divergence is a cost per warp per branch, so one warp in a grid of thousands is not a cost at all.

Measuring it without changing the work

Full program in code/day22-divergence/divergence.cu. It runs on four rules.

One kernel, one variable. The predicate arrives as a runtime argument, so every row of the first table executes the same instructions in the same order and only the identity of the threads on each path changes. A separate kernel per predicate would let the compiler specialise each one and the rows would stop being comparable.

Every row does the same work. Each of the four predicates sends exactly half the grid's threads down each path, and the two paths are the same length, so the aggregate arithmetic is identical and only its grouping moves. This is the discipline day 11 exists to teach, applied to instructions rather than bytes.

One predicate function, three consumers. The kernels, the CPU reference and the compile-time checks all call the same constexpr function, so the page, the check and the hardware cannot end up describing different programs.

Events, and a warm-up per kernel. Day 9 covers why a host clock around a launch measures the launch, and why the first launch of each kernel pays its own module load.

Here is the predicate:

__host__ __device__ constexpr bool takesPathA(int mode, unsigned int tid,
                                              unsigned int block,
                                              unsigned int phase) {
    switch (mode) {
        case kLaneParity:
            return ((tid + phase) & 1u) == 0u;
        case kLaneHalf:
            return ((tid + phase) % kWarpSize) < (kWarpSize / 2u);
        case kWarpParity:
            return ((tid / kWarpSize + phase) & 1u) == 0u;
        case kBlockParity:
            return ((block + phase) & 1u) == 0u;
        default:
            return true;
    }
}

mode is a kernel argument, so it holds the same value for every thread in the grid and the switch itself never diverges. phase is 0 for the whole of part 1.

Because that function is constexpr, the claim the page rests on is checked by the compiler rather than asserted in prose. splitWarpsPerBlock walks the 8 warps of a block and counts the ones whose lanes disagree:

static_assert(splitWarpsPerBlock(kLaneParity) == kWarpsPerBlock,
              "threadIdx.x & 1 must split every warp in the block");
static_assert(splitWarpsPerBlock(kLaneHalf) == kWarpsPerBlock,
              "a threshold inside the warp splits every warp too");
static_assert(splitWarpsPerBlock(kWarpParity) == 0,
              "threadIdx.x / 32 is constant across a warp");
static_assert(splitWarpsPerBlock(kBlockParity) == 0,
              "blockIdx.x is constant across a warp");
static_assert(splitWarpsPerBlock(kAlwaysA) == 0, "no branch, no split");

A second block of static_asserts in the file checks the other half of the fairness condition, that every mode sends half the block down path A. The file does not build if either is untrue.

The kernel is one branch around two equal chains of fused multiply-adds:

__global__ void mixOneBranch(const float* __restrict__ in,
                             float* __restrict__ out, size_t n, int mode) {
    const size_t i = blockIdx.x * static_cast<size_t>(blockDim.x) + threadIdx.x;
    float x = (i < n) ? in[i] : 0.0f;

    if (takesPathA(mode, threadIdx.x, blockIdx.x, 0u)) {
        for (int k = 0; k < kIters; ++k) {
            x = fmaf(x, kMulA, kAddA);
        }
    } else {
        for (int k = 0; k < kIters; ++k) {
            x = fmaf(x, kMulB, kAddB);
        }
    }

    if (i < n) {
        out[i] = x;
    }
}

Part 2 of the program is that kernel with the if moved inside the loop, so each side is one instruction, plus a third kernel with no branch at all as the baseline.

The Best Practices Guide leaves this case to the compiler: "The compiler replaces a branch instruction with predicated instructions only if the number of instructions controlled by the branch condition is less than or equal to a certain threshold." (Section 13.2, "Branch Predication", same page, checked 2026-08-30. The threshold is not published.)

Note. Nothing here can stop the compiler from removing a branch, and it is allowed to. If all four rows of the first table come back equal, that is what happened, and the fix is to read the SASS rather than to argue with the timer. The README gives the cuobjdump -sass line, and day 46 is where that skill is taught properly.

Results

Measured. Tesla T4, driver 595.84, CUDA 12.6 (V12.6.85), built with nvcc -O3 -arch=sm_75. Captured 2026-08-30 on the project's verification node; full transcript in the page's evidence file.

GPU: Tesla T4 (compute capability 7.5)
40 SMs, warp size 32, 4096 blocks x 256 threads = 1048576 threads
4096 multiply-adds per thread, 8388608 bytes moved per launch

Part 1: one branch, 4096 multiply-adds on each side
predicate                 warps split    time (ms)   vs uniform
------------------------  -----------   ----------   ----------
threadIdx.x & 1                     8        3.272         2.02
(threadIdx.x & 31) < 16             8        3.235         2.00
threadIdx.x / 32 & 1                0        1.616         1.00
blockIdx.x & 1                      0        1.617         1.00
warps split is out of the 8 warps in a block, computed from the predicate

Part 2: the same branch inside the loop, one multiply-add on each side
kernel                          time (ms) vs no branch
----------------------------   ---------- ------------
no branch                           1.615         1.00
per-iteration, lane parity          3.703         2.29
per-iteration, warp parity          2.457         1.52

Every row above moved the same 8388608 bytes and ran the same 4096
multiply-adds per thread. Only the branch changed.

The CUDA 13.0 re-run preserved Part 1 exactly at the level that matters: both split predicates took 2.00x the uniform rows. Part 2 moved materially. The no-branch baseline fell from 1.615 to 1.093 ms, while lane parity measured 2.27x and warp parity measured 2.15x.

In that run, evaluating a uniform branch in the loop accounted for nearly all of the slowdown and splitting the warp added only another 1.06x. The full CUDA 13 table is in the second evidence file.

The predicate decides everything, and the two that look different are the same. threadIdx.x & 1 splits all 8 warps in a block and costs 2.02x. (threadIdx.x & 31) < 16 also splits all 8 and costs 2.00x. They are the same operation: both cut every warp into two halves that must be executed one after the other.

This matters because an earlier draft of this very lesson contrasted those two as though the second were the fast case. It is not, and the measurement above is what settles it. A condition on threadIdx.x is warp-uniform only when it cannot change within a group of 32 consecutive lanes.

threadIdx.x / 32 & 1 and blockIdx.x & 1 split zero warps and cost 1.00x. Both branch, both run the same arithmetic, and neither costs anything, because every lane in a warp takes the same side.

Where the branch sits changes what the timer includes. In the CUDA 12.6 run, moving the same one-line branch inside the loop took the split row from 2.02x to 2.29x. CUDA 13.0 measured a similar 2.27x, but its uniform branch was already at 2.15x. A split branch cannot be priced against the no-branch baseline without also measuring the matching uniform branch.

Part 2 also prices something that is not divergence at all. The warp-uniform per-iteration row splits no warps and still cost 1.52x the no-branch baseline on CUDA 12.6 and 2.15x on CUDA 13.0, because the predicate is evaluated and acted on 4,096 times per thread either way.

Read the two per-iteration rows against each other for the cost of splitting a warp, and both against the baseline for the cost of asking a question in a loop. That first ratio changed from 1.51x to 1.06x between these runs, so it is a measured property of the compiled kernel, not a portable constant.

Run it yourself

A free Colab T4 or any card you own. The build line is in the repo's README:

nvcc -std=c++17 -O3 -arch=sm_75 -o divergence divergence.cu

There is no Compiler Explorer embed. Size is not the reason: two 4 MiB buffers and a fraction of a second of GPU work sit well inside the 20 second run cap. The reason is that every number here is a ratio between two timings.

The free runner is a shared spot instance with no SLA, so a slow row there can be somebody else's kernel rather than yours. Reading the program on Compiler Explorer is fine; timing it is not.

Exercise

Add a fifth predicate to part 1: threadIdx.x < 16, the one at the top of this page. It needs a mode constant, a case in takesPathA, a label and kNumModes raised by one. Write down how many of the block's 8 warps it splits and how many threads it sends down path A before you build.

Put both numbers in static_asserts and let the compiler mark them. Run it, and say why the row is faster than the two split rows above it without being warp-uniform.

Time: 25 to 40 minutes. Submit: the predicted split count, your row's ratio to the threadIdx.x / 32 & 1 row, one sentence on where the speed came from, and the smallest edit to threadIdx.x < 16 that makes it warp-uniform.

Check: the correctness gate covers your row for free, because the host reference and the kernel share takesPathA, so a predicate you got wrong on one side is reported as a mismatch with the thread index that found it. The static_asserts are the real check: guess the split count wrong and the file does not compile. Then read your ratio against the uniform row, which is the number the sentence has to explain.

Hint 1

Warps are cut from consecutive threadIdx.x values: 0 to 31 is one warp, 32 to 63 the next. Write down, for each of the eight warps, whether its 32 threads agree. Then count the path-A threads in the whole block.

Hint 2

A warp that splits pays for both paths and a warp that agrees pays for one. Add up the eight warps of the block that way, for this predicate and for the 16 and the 8 of the two rows above it, and the ratio is the ratio of those totals.

Solution

One warp of the eight splits, and 16 of the 256 threads take path A. The block therefore pays 9 path-runs: warp 0 runs both paths, the other seven run path B only. Against 16 for threadIdx.x & 1 and 8 for threadIdx.x / 32 & 1, the row should land just above the uniform rows and well under the split ones.

That speed is not the absence of divergence, it is the absence of work. The row that looks nearly as good as the warp-aligned one got there by sending 112 fewer threads down path A, which makes it a different program rather than a faster one.

The smallest edit that makes it warp-uniform is threadIdx.x < 32, one whole warp, and notice what that costs: you cannot warp-align a predicate without changing which threads do which work. When the work assignment is fixed, the move is to change which thread owns which element, which is what day 24 does to a reduction.

Take this away instead of the numbers: compare two kernels only when they do the same work, and count path-runs per warp rather than branches per kernel.

Pitfalls

You made the condition simpler and it still diverges. A threshold is not warp-aligned because it is tidy. Run the predicate through the guide's test. If it depends only on threadIdx.x / 32 and blockIdx.x, no warp splits, and if it cannot, count the warps it splits before you claim a fix.

You compare two kernels that no longer do the same work. The fastest way to make a divergent kernel look fixed is to send fewer threads down the expensive path. Report the work split beside every timing, the way this page's tables do, or the comparison means nothing.

You delete every if and replace it with arithmetic. A uniform branch is one instruction and costs nothing, and the compiler may already be turning a short divergent one into predicated instructions. Branchless code that makes all 32 lanes evaluate both sides can be slower than the branch it replaced.

You expect the warp to reconverge the instant the if ends. Since Volta a warp may stay diverged past the join, so warp-synchronous code that worked on Pascal is not correct now. Use __syncwarp() when you need the lanes back together, and the _sync forms of every shuffle and vote. Day 23 and day 27 cover the rules.

You measure divergence on a memory-bound kernel and find nothing. The memory stall can overlap the extra instruction issue, so the branch adds no measured time in that kernel and expensive in the next one. Find out which kind you have before optimising, which is what day 42 and the roofline on day 30 are for.

You profile as a normal user and get nothing. Nsight Compute reports ERR_NVGPUCTRPERM, in full: The user running <tool_name/application_name> does not have permission to access NVIDIA GPU Performance Counters or the Hardware Event System on the target device. On a machine where you have root, sudo ncu works, because the stock driver default RmProfilingAdminOnly is 1. Colab and Kaggle give you neither. NVIDIA's page on the message, checked 2026-08-30: https://developer.nvidia.com/nvidia-development-tools-solutions-err_nvgpuctrperm-permission-issue-performance-counters

Go deeper

Next

Day 23 stops working around the warp and starts using it: shuffles and votes move data between lanes with no shared memory and no barrier, and every one of them carries a mask because of the reconvergence rule above.

Day 24 then compares reduction versions. The first version has a branch that splits every warp, while the second moves that branch onto a warp boundary. The ratio you measured here is the most that change can save; day 24 measures the result on a T4, and the answer is less than zero because the same edit adds bank conflicts.

The rest of module 3 keeps testing predicates this way. Reading a predicate is a working habit rather than a lesson topic, and it is one of the things a kernel engineer is paid for.