Chapter 11 · Section 11.4 · 60 min

Parallel reductions and scans

Add up ten million copies of 0.1 in float32, one after another, and you get 1,087,937, not 1,000,000. Add the same numbers in a different order, pairing them up in a tree, and you get 1,000,000 to every digit float32 can show. Neither number is a bug. Floating-point addition rounds after every step, so the order of additions changes the answer, and the tree order, which happens to be the natural order on a GPU, is more accurate.

Every kernel of §11.3 gave each thread its own output. A vector add, a matrix scale, and a transpose never ask two threads to agree on anything. A sum does. A billion inputs must become one number, so threads have to combine their work, first through the registers of a warp, then through the shared memory and barriers of a block, and finally across blocks that cannot wait for each other. This is where the tools of §11.3 start working together, and where the order of operations, which never mattered for a vector add, starts to change the answer.

Summing is the simplest example of a reduction, combining many values into one with an associative operation, and its sibling the scan (prefix sum) produces every partial result along the way. Reductions are inside every softmax, LayerNorm, loss, and gradient norm in a model, and inside every all-reduce across GPUs. Scans are less visible but just as basic. They compute the offsets when tokens are routed to experts, the cumulative probabilities of top-p sampling, the positions in packed sequences, and, most strikingly, they turn a linear recurrence, a computation that looks hopelessly sequential, into one that runs in logarithmic depth. That last trick is what makes modern recurrent architectures trainable on GPUs.

This section builds both from the ground up. We derive tree reductions and their cost, see why the way a block pairs up its elements decides whether it diverges and conflicts (§11.1 and §11.3 again), implement warp shuffles, quantify why parallel sums are more accurate and how to make them deterministic, merge means, variances, and softmax statistics, handle segmented reductions, and then derive the Blelloch work-efficient scan with a proof of correctness. It ends with scans over operators other than addition, including the one that parallelizes ht=atht−1+bth_t = a_t h_{t-1} + b_t.

Tree reductions

To sum nn numbers sequentially takes n−1n - 1 additions, each depending on the previous one, so even with infinitely many processors it takes n−1n - 1 steps. Pair them up instead. In the first step, n/2n/2 processors each add one pair. In the next, n/4n/4 add pairs of those results, and so on, until one value remains after ⌈log⁡2n⌉\lceil\log_2 n\rceil steps.

Two quantities describe a parallel algorithm. Its work W(n)W(n) is the total number of operations, and its depth D(n)D(n) is the length of the longest chain of operations that must happen one after another. The tree reduction has W(n)=n−1W(n) = n - 1, the same as the sequential sum, and D(n)=⌈log⁡2n⌉D(n) = \lceil \log_2 n\rceil. Brent's theorem says that with pp processors an algorithm can run in time at most W/p+DW/p + D, so the tree finishes in about n/p+log⁡2nn/p + \log_2 n steps, essentially perfect speedup until pp approaches n/log⁡2nn/\log_2 n.

In plain words, a tree reduction does no more work than a loop, and its depth is only logarithmic, so it uses as many processors as you have.

The cell runs the tree the way a GPU block does, with sequential addressing. At each step the first half of the active elements adds the second half. The next subsection shows why this particular pairing is the right one.

Python · cell 1

Pairing elements in shared memory

Inside a block, the tree runs in shared memory, with one thread per pair and a barrier between levels, since each level reads sums the previous level wrote. Which thread adds which pair looks like a detail. It is not, and the three obvious choices show why (Harris, 2007).

  • Interleaved addressing. At the level with stride s=1,2,4,…s = 1, 2, 4, \dots, thread tt is active if tt is a multiple of 2s2s, and adds x[t + s] into x[t]. Active threads are scattered, so every warp contains both active and idle threads. Every warp diverges (§11.1), and most lanes do nothing while still occupying the warp.
  • Strided indexing. Make the active threads contiguous instead, thread t<n/(2s)t < n/(2s) adding x[2st + s] into x[2st]. Now whole warps go idle together, but lane tt reads word 2st2st, a stride of 2s2s words, and by the bank rule of §11.3 that is a 2s2s-way conflict, up to 32-way.
  • Sequential addressing. Thread t<ht < h adds x[t + h] into x[t], with h=n/2,n/4,…,1h = n/2, n/4, \dots, 1. Active threads are contiguous, so warps never diverge until fewer than 32 threads remain, and consecutive lanes read consecutive words, so there are no conflicts.
__global__ void block_sum_shared(const float* x, float* block_sums, int64_t n) {
    extern __shared__ float sdata[];                   // blockDim.x floats, a power of two
    int t = threadIdx.x;
    int64_t i = (int64_t)blockIdx.x * blockDim.x + t;
    sdata[t] = (i < n) ? x[i] : 0.0f;                  // pad with the identity
    __syncthreads();
    for (int h = blockDim.x / 2; h > 0; h >>= 1) {     // sequential addressing
        if (t < h) sdata[t] += sdata[t + h];           // threads 0..h-1, consecutive words
        __syncthreads();                               // outside the if, so every thread arrives
    }
    if (t == 0) block_sums[blockIdx.x] = sdata[0];
}
// block_sum_shared<<<blocks, 256, 256 * sizeof(float)>>>(x, block_sums, n);

The barrier sits outside the if, as §11.3 required, so idle threads still reach it. The three pairings are Harris's kernels 1 to 3, and his sequence continues. Kernel 4 adds two elements as each thread loads, so no thread is idle at the first level. Kernel 5 unrolls the last six levels, where only one warp is active, and drops their barriers. (That relied on the lanes of a warp running in lockstep, which is no longer guaranteed since Volta, so modern code finishes with the warp shuffles below, or at least __syncwarp().) Kernel 6 unrolls the whole loop for a block size known at compile time. Kernel 7 has each thread sum many elements before the tree starts. On a G80 those steps took a sum of 4 million integers from about 2 to about 63 GB/s of the chip's 86 GB/s, thirty times faster with the same arithmetic.

Core

Divergence and bank conflicts of three pairings

Implement pairing_costs(n, scheme) for a block of n threads (a power of two, at least 64) reducing n values in shared memory. scheme is "interleaved", "strided", or "sequential", as defined above. Return a list with one tuple per level, (active_warps, divergent_warps, conflict), where a warp of 32 consecutive threads is active if at least one of its threads is active, divergent if some but not all of its threads are active, and conflict is the worst bank-conflict degree, over the active warps, of the words that the active lanes of that warp write (x[t], x[2st], or x[t] respectively), with 32 banks and 1 meaning conflict-free. Use s=1,2,…,n/2s = 1, 2, \dots, n/2 for the first two schemes and h=n/2,…,1h = n/2, \dots, 1 for the third, so there are log⁡2n\log_2 n levels.

Hidden tests, hints, and AI help, from $1.99 a month.

From a block to the whole GPU

A real kernel adds three refinements. Harris (2007) walked a reduction kernel through a sequence of such optimizations, each a large speedup over the last, and the modern version looks like this.

Each thread first sums many elements by itself. A grid-stride loop (§11.3) has each thread accumulate a private partial sum over its share of the input, sequentially, in a register. This keeps the work at nn while cutting the tree to the number of threads, and it makes the loads perfectly coalesced. It is Brent's theorem applied on purpose.

Warps reduce with shuffles. Threads of a warp can read each other's registers directly with __shfl_down_sync, without shared memory or barriers. Five shuffle-and-add steps (offsets 16, 8, 4, 2, 1) reduce 32 values to one.

Blocks combine through a second pass or atomics. Lane 0 of each warp writes its result to shared memory, one warp reduces those, and each block then either writes its partial sum to an array for a second, tiny kernel, or adds it to the output with atomicAdd. Blocks cannot wait for each other inside a kernel (§11.3), which is why the last level needs one of these two.

__inline__ __device__ float warp_reduce_sum(float v) {
    for (int offset = 16; offset > 0; offset >>= 1)
        v += __shfl_down_sync(0xffffffff, v, offset);  // lane i adds the value held by lane i + offset
    return v;                                          // lane 0 now holds the sum of all 32 lanes
}

__global__ void sum_kernel(const float* x, float* block_sums, int64_t n) {
    __shared__ float warp_sums[32];                    // one slot per warp, up to 1,024 threads
    float v = 0.0f;
    for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x; i < n;
         i += (int64_t)gridDim.x * blockDim.x)
        v += x[i];                                     // private, sequential, coalesced
    v = warp_reduce_sum(v);
    int lane = threadIdx.x % 32, warp = threadIdx.x / 32;
    if (lane == 0) warp_sums[warp] = v;
    __syncthreads();
    if (warp == 0) {
        int n_warps = (blockDim.x + 31) / 32;
        v = (lane < n_warps) ? warp_sums[lane] : 0.0f;
        v = warp_reduce_sum(v);
        if (lane == 0) block_sums[blockIdx.x] = v;     // deterministic; a second launch sums these
    }
}

The shuffle's semantics are precise. __shfl_down_sync(mask, v, delta) returns, to lane ii, the value of v in lane i+δi + \delta, and lanes with i+δ≥32i + \delta \ge 32 get their own value back. After the step with offset 16, lane i<16i < 16 holds vi+vi+16v_i + v_{i+16}, after offset 8 lane i<8i < 8 holds the sum of four values, and after offset 1 lane 0 holds all 32. The other lanes hold partial garbage, which is fine. The variant __shfl_xor_sync(mask, v, m) exchanges values between lanes ii and i⊕mi \oplus m (bitwise exclusive or), and five steps with m=16,8,4,2,1m = 16, 8, 4, 2, 1 leave the full sum in every lane, a butterfly all-reduce, useful when every thread needs the result, as in the normalization of a softmax.

How fast can it be? A float32 sum does one addition per 4-byte element, an arithmetic intensity of 1/41/4 FLOP per byte, about a thousand times below the H100's ridge point (§11.2). So a reduction is memory-bound, and the only figure of merit is achieved bandwidth. Summing 10910^9 float32 values, 4 GB, takes at least 4×109/3.35×1012≈1.24\times10^{9}/3.35\times10^{12} \approx 1.2 ms on an H100. Every refinement above, coalesced grid-stride loads, shuffles instead of barriers, one write per block, exists to bring a reduction close to that bound, which is about the speed of a plain memory copy. In production you rarely write one. CUB's BlockReduce and DeviceReduce, and torch.sum, already do all of this.

Core

Warp shuffles, simulated

Simulate a warp's registers as an array of 32 values. Implement shfl_down(v, delta) and shfl_xor(v, m) with the exact CUDA semantics described above, then warp_reduce(v, mode) that applies the five steps with offsets 16, 8, 4, 2, 1, adding each lane's received value to its own, and returns the list of the five 32-lane arrays after each step. With mode="down" the total must end in lane 0, and with mode="xor" in every lane.

Hidden tests, hints, and AI help, from $1.99 a month.

Numerically safe parallel sums

Back to the puzzle. Every floating-point addition rounds its result to the nearest representable number, introducing a relative error of at most uu, the unit roundoff (u=2−24≈6×10−8u = 2^{-24} \approx 6\times10^{-8} in float32, half the machine epsilon of §0.9). In sequential summation, the first element passes through n−1n - 1 roundings, and the classical bound (Higham, 1993) is

∣S^−S∣  ≲  (n−1) u∑i∣xi∣(sequential),∣S^−S∣  ≲  ⌈log⁡2n⌉ u∑i∣xi∣(tree),\left|\hat S - S\right| \;\lesssim\; (n - 1)\,u \sum_{i} |x_i| \quad\text{(sequential)}, \qquad \left|\hat S - S\right| \;\lesssim\; \lceil\log_2 n\rceil\,u \sum_i |x_i| \quad\text{(tree)},

where SS is the exact sum, S^\hat S the computed one, and the bounds hold to first order in uu. In a tree each element passes through only log⁡2n\log_2 n roundings.

In plain words, the error of a sum grows with the number of additions each input passes through, which is nn in a loop and log⁡2n\log_2 n in a tree.

The sequential bound is also the typical behavior when the partial sum grows large. Once the running total of our 0.1s passes 219≈524,0002^{19} \approx 524{,}000, neighboring float32 numbers are 2−4=0.06252^{-4} = 0.0625 apart, so each added 0.1, which is 1.6 gaps, is rounded to two gaps, 0.125. That 25 percent excess, repeated over millions of additions, is what pushes the total so far past a million. Kahan summation fixes the sequential loop by carrying a compensation term that recovers the low-order bits each addition loses, with an error bound of about 2u∑∣xi∣2u\sum|x_i| independent of nn, at four times the operations. On GPUs the tree, plus float32 accumulation for low-precision inputs, is usually enough.

Python · cell 2

The tree is several hundred times more accurate than the loop here, as accurate as Kahan's compensated loop. For the low-precision formats of training the lesson is sharper. A bfloat16 number has only 8 bits of significand, so a bfloat16 accumulator stops changing once the total is about 256 times the typical addend. Every reduction in a mixed-precision model (the softmax denominator, the LayerNorm statistics, the loss, gradient norms, the accumulators of matmuls) is therefore done in float32, even when inputs and outputs are 16-bit (§8.5).

Determinism

Because the order matters, a reduction whose order varies gives results that vary. atomicAdd of per-block partials adds them in whatever order blocks happen to finish, so the last bits change from run to run. Changing the number of blocks or threads, as a library might for a different input size, changes the tree. This is why some PyTorch operations are nondeterministic unless you ask for deterministic algorithms (§0.9). A deterministic reduction fixes the tree. Each block writes its partial sum to its own slot, and a second pass combines the slots in a fixed order, at the cost of a second launch.

A subtler version matters for inference. Some kernels choose how to split a reduction based on the batch size, so the same request can produce slightly different logits depending on what else is in the batch. Thinking Machines Lab (He, 2025) traced much of the run-to-run variation of LLM inference servers to exactly this lack of batch invariance, and showed that kernels with fixed reduction orders make sampling at temperature 0 reproducible.

Merging statistics, not just sums

A LayerNorm needs a mean and a variance, and a numerically safe one cannot be computed as mean⁡(x2)−μ2\operatorname{mean}(x^2) - \mu^2, which cancels catastrophically when the mean μ\mu is much larger than the standard deviation σ\sigma (§5.2 showed this and fixed it with Welford's sequential update). To parallelize it, each thread or warp summarizes its chunk as a triple (na,μa,Ma)(n_a, \mu_a, M_a), the count, the mean, and Ma=∑(xi−μa)2M_a = \sum (x_i - \mu_a)^2, and two summaries are merged by Chan, Golub & LeVeque's formula. With n=na+nbn = n_a + n_b and δ=μb−μa\delta = \mu_b - \mu_a,

μ=μa+δ nbn,M=Ma+Mb+δ2 nanbn.\mu = \mu_a + \delta\,\frac{n_b}{n}, \qquad M = M_a + M_b + \delta^2\,\frac{n_an_b}{n}.

In plain words, the combined mean moves toward the second chunk's mean in proportion to its size, and the combined spread is the two spreads plus a term for how far apart the two means are.

The merge is associative, so it can be applied in a tree like any sum. That is how fused LayerNorm kernels split a row across the threads of a block.

The softmax denominator is a reduction of the same kind. To sum exje^{x_j} over a row without overflow, each chunk keeps its maximum mm and its sum ℓ=∑jexj−m\ell = \sum_j e^{x_j - m} relative to that maximum, and two chunk summaries combine by

(ma,ℓa)⊕(mb,ℓb)=(m,  ℓaema−m+ℓbemb−m),m=max⁡(ma,mb),(m_a, \ell_a) \oplus (m_b, \ell_b) = \big(m,\; \ell_a e^{m_a - m} + \ell_b e^{m_b - m}\big), \qquad m = \max(m_a, m_b),

with identity (−∞,0)(-\infty, 0). In plain words, both partial sums are rescaled to the larger of the two maxima before they are added, so every exponent stays at most zero. The operator is associative (and commutative), so a row's softmax statistics reduce in a tree, across the lanes of a warp, or as a stream of tiles, like any sum. This is the online softmax. §12.2 derives it and proves the merge exact, §11.6 uses it to stream rows too long for one block, and FlashAttention is built on it.

∂ Why the merge formula is exact

Let the combined set be A∪BA \cup B with n=na+nbn = n_a + n_b. The mean of the union is the size-weighted mean of the means, μ=(naμa+nbμb)/n=μa+(μb−μa)nb/n\mu = (n_a\mu_a + n_b\mu_b)/n = \mu_a + (\mu_b - \mu_a)n_b/n, which is the first formula.

For the spread, write each deviation from μ\mu as a deviation from the chunk mean plus the chunk mean's offset, xi−μ=(xi−μa)+(μa−μ)x_i - \mu = (x_i - \mu_a) + (\mu_a - \mu) for i∈Ai \in A. Squaring and summing over AA, the cross term vanishes because ∑i∈A(xi−μa)=0\sum_{i\in A}(x_i - \mu_a) = 0, so ∑A(xi−μ)2=Ma+na(μa−μ)2\sum_{A}(x_i - \mu)^2 = M_a + n_a(\mu_a - \mu)^2, and likewise for BB. Now μa−μ=−δnb/n\mu_a - \mu = -\delta n_b/n and μb−μ=δna/n\mu_b - \mu = \delta n_a/n, so

M=Ma+Mb+naδ2nb2n2+nbδ2na2n2=Ma+Mb+δ2nanb(nb+na)n2=Ma+Mb+δ2nanbn.M = M_a + M_b + n_a\frac{\delta^2 n_b^2}{n^2} + n_b\frac{\delta^2 n_a^2}{n^2} = M_a + M_b + \delta^2\frac{n_an_b(n_b + n_a)}{n^2} = M_a + M_b + \delta^2\frac{n_an_b}{n}.

Every quantity involved is a deviation or a difference of means, never a large sum of squares, which is why the formula avoids the cancellation of the naive one. The biased variance is M/nM/n.

Interview

A tree of mean and variance summaries

Implement merge(a, b) for summaries a = (n_a, mean_a, M_a) and b, using the formula above (a summary with n=0n = 0 is the identity), and tree_mean_var(x, leaf=32) that summarizes consecutive chunks of leaf elements directly (count, mean, and sum of squared deviations from the chunk mean), merges the summaries pairwise in a tree, and returns (mean, var) with the biased variance. It must stay accurate when the data has a huge offset.

Hidden tests, hints, and AI help, from $1.99 a month.

Segmented reductions

Many reductions in ML are not over a whole tensor but over groups of varying size laid end to end. Tokens routed to the same expert in a mixture-of-experts layer (§13.2), documents packed into one sequence (§4.4), the bags of an embedding-bag lookup, the neighbors of each node in a graph network. A standard representation is offsets, as in a sparse CSR matrix. Segment jj is values[offsets[j]:offsets[j + 1]], so offsets has one more entry than there are segments, and an empty segment has equal consecutive offsets.

On a GPU there are three strategies. One block per segment is simple but balances badly when segment sizes vary a lot. Sorting by segment and running a reduce-by-key over the flattened array balances perfectly. And a segmented scan uses the scan machinery below with an operator that restarts at each segment boundary. Two tempting NumPy shortcuts are wrong in instructive ways. np.add.reduceat returns a wrong value, not zero, for an empty segment, and differencing a global prefix sum, coj+1−cojc_{o_{j+1}} - c_{o_j}, cancels catastrophically when an earlier segment is large, the same disease as mean⁡(x2)−μ2\operatorname{mean}(x^2) - \mu^2.

Interview

A segmented sum

Implement segment_sum(values, offsets), where values has shape (n,) or (n, d) and offsets is a nondecreasing integer array with offsets[0] == 0 and offsets[-1] == n. Return an array of shape (S,) or (S, d) with the sum of each segment, zero for empty segments. Avoid a Python loop over elements (a loop over segments is acceptable but not needed), and do not difference a global prefix sum.

Hidden tests, hints, and AI help, from $1.99 a month.

Prefix sums

A scan applies an associative operator ⊕\oplus with identity ee cumulatively. The inclusive scan of (x0,…,xn−1)(x_0, \dots, x_{n-1}) is yi=x0⊕⋯⊕xiy_i = x_0 \oplus \cdots \oplus x_i, and the exclusive scan is yi=x0⊕⋯⊕xi−1y_i = x_0 \oplus \cdots \oplus x_{i-1}, with y0=ey_0 = e. For addition, these are np.cumsum and np.cumsum shifted right by one with a leading 0. The exclusive form is the one that computes offsets. If xix_i is the number of items segment ii will write, the exclusive scan says where each segment starts, which is how a GPU compacts an array, sorts by radix, or lays out the tokens bound for each expert.

A sequential scan does n−1n - 1 operations in n−1n - 1 dependent steps. The question is how to do it in parallel without doing much more work.

Hillis and Steele, fast but wasteful

The first idea (Hillis & Steele, 1986, also known as the Kogge-Stone pattern) is to double the reach at each step. For d=1,2,4,…d = 1, 2, 4, \dots, every element adds the element dd positions to its left, yi←yi−d⊕yiy_i \leftarrow y_{i-d} \oplus y_i for i≥di \ge d. After the step with reach dd, yiy_i holds the sum of the 2d2d elements ending at ii (or all of them, if there are fewer), so after ⌈log⁡2n⌉\lceil\log_2 n\rceil steps it is the inclusive scan.

The depth is log⁡2n\log_2 n, as low as it gets, but each step does nearly nn additions, so the work is about nlog⁡2nn\log_2 n, a factor of log⁡2n\log_2 n more than the loop. With 10910^9 elements that is 30 times the work, and on a GPU work costs energy and, for large arrays, memory traffic. Hillis-Steele is the right choice when processors are plentiful relative to elements, as within a single warp, where shuffles do each step in one instruction.

__inline__ __device__ float warp_inclusive_scan(float v) {
    int lane = threadIdx.x % 32;
    for (int d = 1; d < 32; d <<= 1) {
        float up = __shfl_up_sync(0xffffffff, v, d);   // lane i receives v from lane i - d
        if (lane >= d) v += up;                        // the first d lanes keep their value
    }
    return v;                                          // lane i now holds x_0 + ... + x_i
}

A block scan is built from this in three steps, the same shape as the block reduction. Each warp scans its 32 values, one warp scans the warp totals, and every thread adds the total of the warps before its own.

Blelloch's work-efficient scan

Blelloch (1990) showed how to scan with O(n)O(n) work and O(log⁡n)O(\log n) depth, using two passes over a balanced binary tree whose leaves are the input. Assume nn is a power of two (pad with the identity otherwise), and work in place on an array x.

The up-sweep is the tree reduction of the first part of this section, done in place. For levels l=0,1,…,log⁡2n−1l = 0, 1, \dots, \log_2 n - 1, with stride 2l+12^{l+1}, every block of 2l+12^{l+1} elements adds its left half's last element into its right half's last element,

x[i+2l+1−1]  ←  x[i+2l−1]⊕x[i+2l+1−1],i=0, 2l+1, 2⋅2l+1,…x\big[i + 2^{l+1} - 1\big] \;\leftarrow\; x\big[i + 2^{l} - 1\big] \oplus x\big[i + 2^{l+1} - 1\big], \qquad i = 0,\ 2^{l+1},\ 2\cdot 2^{l+1}, \dots

Afterward, the last position of every block of size 2l+12^{l+1} holds the total of that block, and x[n-1] holds the total of everything.

The down-sweep walks back down. Set x[n-1] to the identity. Then for levels l=log⁡2n−1l = \log_2 n - 1 down to 0, in every block of 2l+12^{l+1} elements, let L=i+2l−1L = i + 2^l - 1 (the end of the left half) and R=i+2l+1−1R = i + 2^{l+1} - 1 (the end of the right half), and do

t←x[L],x[L]←x[R],x[R]←x[R]⊕t.t \leftarrow x[L], \qquad x[L] \leftarrow x[R], \qquad x[R] \leftarrow x[R] \oplus t.

When it finishes, x holds the exclusive scan. Step through it on 8 or 16 elements in the widget, and compare with Hillis-Steele's work.

How does a parallel scan compute every prefix sum in logarithmic depth?
Each row is the array after one parallel step, and every operation in a row happens at the same time. Blelloch's scan sweeps up a tree and back down, the Hillis-Steele scan doubles its reach each step.
Loading interactive figure…

Count the cost. The up-sweep does n/2+n/4+⋯+1=n−1n/2 + n/4 + \dots + 1 = n - 1 operations, and so does the down-sweep, so W(n)=2(n−1)W(n) = 2(n - 1), about twice the loop and far less than nlog⁡2nn\log_2 n. Each sweep has log⁡2n\log_2 n levels, so D(n)=2log⁡2nD(n) = 2\log_2 n.

In plain words, the up-sweep computes the sum of every subtree, and the down-sweep hands each subtree the sum of everything to its left.

∂ Why the down-sweep produces the exclusive scan

Think of the array as the leaves of a complete binary tree. Each tree node at level l+1l + 1 covers a block of 2l+12^{l+1} consecutive leaves, and we identify the node with the array position RR at the right end of its block. Its left child is identified with position LL, the right end of the block's left half, and its right child with RR again.

After the up-sweep, position RR of each block holds the sum of the block, and position LL holds the sum of the left half. This is the in-place tree reduction, by induction on ll.

The down-sweep invariant. Define the prefix of a node as the ⊕\oplus of all leaves strictly to the left of its block. We claim that just before the down-sweep processes level ll, for every block of size 2l+12^{l+1}, position RR holds that block's prefix, and position LL still holds the sum of the left half (the down-sweep has not touched it yet).

The claim holds at the top, because the root's block is the whole array, its prefix is the identity, and that is what we wrote into x[n-1]. Now suppose it holds at level ll for a block with prefix PP and left-half sum SLS_L. The step sets x[L]←Px[L] \leftarrow P and x[R]←P⊕SLx[R] \leftarrow P \oplus S_L, written x[R]⊕tx[R] \oplus t with t=SLt = S_L. The left child's block starts where the parent's starts, so its prefix is PP. The right child's block starts after the left half, so its prefix is P⊕SLP \oplus S_L. (The order, prefix first, matters for a non-commutative operator such as the affine-map composition at the end of this section. For addition either order works, which is why the code writes t + x[R].) Both children now hold their prefixes at their right-end positions, and their own left halves still hold the up-sweep sums, which is the claim at level l−1l - 1.

At the bottom, every block is a single leaf, and each position holds the ⊕\oplus of all elements strictly before it, which is the exclusive scan. Only associativity was used (never commutativity, with the prefix-first ordering), so the same algorithm scans with any associative operator.

Python · cell 3
Interview

A work-efficient exclusive scan

Implement exclusive_scan(x) with the Blelloch up-sweep and down-sweep, returning (y, n_ops), the exclusive prefix sum (same length and dtype as x) and the number of additions performed. Arrays whose length is not a power of two must be padded with zeros (the identity) to the next power of two, scanned, and truncated, and n_ops counts the additions on the padded array. Length 0 returns an empty array and 0, and length 1 returns [0]. Integer inputs must give exact integer results.

Hidden tests, hints, and AI help, from $1.99 a month.

Scanning arrays larger than a block

A block can scan a few thousand elements in shared memory. For a large array, the standard scan-then-propagate scheme takes three steps. Each block scans its own chunk and records the chunk's total. A second, small scan computes an exclusive scan of the chunk totals. A third pass adds each chunk's offset to all of its elements. The work stays linear, but the array crosses HBM about four times (read and write in the first pass, read and write in the third), and a scan, like a sum, is memory-bound. Merrill & Garland (2016) reduced this to the minimum, one read and one write, with a single-pass scan in which each block, after computing its local total, looks back at the published totals of its predecessors (decoupled look-back) rather than waiting for a separate kernel. This is how NVIDIA's CUB library scans at close to memory-copy speed.

Python · cell 4

Scans of other operators, and linear recurrences

Nothing in the Blelloch scan used the fact that ⊕\oplus is addition, only that it is associative. Running maximum, running product, and logical or are scans. So is the segmented sum, with elements that are pairs (f,v)(f, v) of a flag (1 at the start of a segment) and a value, and the operator

(f1,v1)⊕(f2,v2)=(f1∨f2,  v2 if f2 else v1+v2),(f_1, v_1) \oplus (f_2, v_2) = \big(f_1 \lor f_2,\; v_2 \text{ if } f_2 \text{ else } v_1 + v_2\big),

which restarts the sum wherever a new segment begins and is associative (check it on three elements). And so are operators on richer states, like the (m,ℓ)(m, \ell) pairs of the online softmax.

The most consequential example is the linear recurrence

ht=at ht−1+bt,h0=0,h_t = a_t\, h_{t-1} + b_t, \qquad h_0 = 0,

with ata_t and btb_t given for t=1,…,Tt = 1, \dots, T. It looks inherently sequential, since every state needs the previous one. But each step is an affine map h↦ath+bth \mapsto a_t h + b_t, and affine maps compose into affine maps. Applying (a1,b1)(a_1, b_1) and then (a2,b2)(a_2, b_2) gives h↦a2(a1h+b1)+b2h \mapsto a_2(a_1 h + b_1) + b_2, so define

(a1,b1)∙(a2,b2)=(a1a2,  a2b1+b2).(a_1, b_1) \bullet (a_2, b_2) = (a_1 a_2,\; a_2 b_1 + b_2).

This operator is associative because function composition is associative, and its identity is (1,0)(1, 0). The inclusive scan of the pairs (at,bt)(a_t, b_t) under ∙\bullet gives, at position tt, the composed map from h0h_0 to hth_t, whose second component is hth_t itself (since h0=0h_0 = 0). So all TT states can be computed with depth O(log⁡T)O(\log T) and work O(T)O(T), instead of TT dependent steps. The same holds when hth_t is a vector and ata_t acts elementwise, as in a diagonal state-space model, and, at higher cost, when ata_t is a matrix.

In plain words, each step of a linear recurrence is a "multiply then add" rule, two such rules combine into one rule of the same kind, and a scan combines them all in a logarithmic number of rounds.

Python · cell 5

This is why linear recurrences have come back. An LSTM or GRU (§3.3) cannot be parallelized over time, because its state passes through a nonlinearity at every step. If the recurrence is kept linear in the state, with the gates computed from the input alone, training becomes a scan and parallelizes over the sequence (Martin & Cundy, 2018). Linear attention can be written as such a recurrence with a matrix-valued state, and selective state-space models such as Mamba make ata_t and btb_t depend on the input token and evaluate the recurrence with a hardware-aware parallel scan that keeps the expanded state in on-chip memory instead of writing it to HBM, the keep-it-on-chip argument behind kernel fusion (§11.7) applied to a scan. Many of these models split the sequence into chunks, process each chunk with dense matrix operations that suit tensor cores, and carry the state from chunk to chunk with exactly the operator above, which is how parallel scans for recurrences are made fast on real GPUs.

In PyTorch

PyTorch exposes the common scans and segmented reductions directly. The block checks a segmented sum done with index_add_, a running maximum with cummax, the linear recurrence by scan against a loop, and shows that the grouping of float32 additions changes the result and that a bfloat16 accumulator stalls.

🔥 In PyTorchfor a machine with PyTorch

Read more

Watch next

🎬 Lecture 9 Reductions · GPU MODEMark Saroufim builds a parallel reduction kernel through a sequence of optimizations, with code you can run.
🎬 Lecture 20: Scan Algorithm · GPU MODEIzzat El Hajj, a coauthor of the PMPP textbook, derives parallel scan algorithms and compares their work and depth.
🎬 Lecture 24: Scan at the Speed of Light · GPU MODENVIDIA's CUB developers on how production scans reach full memory bandwidth with a single pass and decoupled look-back.