Introduction
Welcome to our first lesson on implementing fundamental parallel algorithms. In the previous modules, we've built a solid foundation, moving from the basics of GPU architecture and memory hierarchies to the intricacies of warp-level execution, including data exchange with shuffle primitives and collective decision-making with vote functions. Now, it's time to apply these concepts to build one of the most essential parallel primitives: reduction.
A reduction algorithm takes a large collection of data and "reduces" it to a single value using an associative operator like sum, maximum, or minimum. This operation is a cornerstone of many complex workloads in AI and scientific computing, from calculating the mean and variance in Layer Normalization to finding the maximum value in an activation tensor.
In this lesson, we will implement our first, naive version of a parallel sum reduction. Crucially, we will then analyze its performance from first principles, identifying two major sources of inefficiency: control flow divergence and non-coalesced memory access patterns. Understanding why this initial approach is slow is the first step toward engineering a high-performance solution.
The Concept of Parallel Reduction
Imagine you need to sum a million numbers. Serially, you would iterate through them one by one. In parallel, we can do much better. The core idea is to perform the summation in a tree-like fashion. In the first step, we pair up all the numbers and add them, reducing the problem size by half. We repeat this process—pairing up the results and adding them—until only one number, the final sum, remains.
A straightforward strategy to implement this is called interleaved addressing. In each step, an active thread at index i will fetch a value from another thread at index i + s, where s is the "stride" for that step. The stride doubles in each subsequent step, progressively combining values that are farther and farther apart.
This image visualizes the process for a small array in shared memory.

A Naive Implementation: Interleaved Addressing
Let's now translate this concept into a CUDA kernel. We'll examine a standard "first-attempt" implementation that loads data from global memory into shared memory and then performs the reduction within the thread block.
The following article provides a clear implementation and explanation of this naive approach, which the author labels "REDUCE-0".
7 Step Optimization of Parallel Reduction with CUDA - Medium
This article from Medium provides a step-by-step guide to optimizing parallel reduction. We will start with the first, most basic implementation.
Please read the introduction, "Understanding the Algorithm", to get a high-level overview. Then, focus your study on the section "REDUCE-0: Interleaved Addressing". Pay close attention to the CUDA code provided and the author's step-by-step breakdown of its logic.
Let's analyze the core of the reduce0 kernel you just studied:
// This is the reduction loop performed in shared memory
for(unsigned int s = 1; s < blockDim.x; s *= 2) {
if (tid % (2 * s) == 0) {
sdata[tid] += sdata[tid + s];
}
__syncthreads();
}
This code perfectly implements the interleaved strategy shown in the diagram.
- The loop iterates with the stride
sdoubling at each step: 1, 2, 4, 8, ... - The
ifcondition,tid % (2 * s) == 0, selects which threads will be active. Fors=1, it's threads 0, 2, 4, ... Fors=2, it's threads 0, 4, 8, ... and so on. This exactly matches the pattern of active threads in the diagram. - An active thread
tidadds the value fromselements away,sdata[tid + s], into its own accumulator,sdata[tid]. __syncthreads()is absolutely critical. It ensures that all additions in a given step are complete across the entire block before any thread moves to the next step where the strideschanges. Without it, a fast thread could race ahead and read a value before it has been updated by another thread from the current step, leading to incorrect results.
This implementation is functionally correct, but from a performance perspective, it's deeply flawed. Your interest in low-level systems and "true compute" necessitates a rigorous analysis of these flaws.
Analyzing the Inefficiencies
The poor performance of this naive kernel stems from two primary issues that violate key principles of efficient GPU programming. These issues are directly caused by the if (tid % (2 * s) == 0) logic.
1. Severe Branch Divergence
As we covered in Module 6, threads in a warp execute in a Single Instruction, Multiple Thread (SIMT) fashion. When threads within a warp take different execution paths in a conditional branch, the warp experiences divergence. The hardware must serialize the execution, running the 'if' path for the threads that took it, and then the 'else' path for the others. This nullifies the benefit of parallel execution within that warp.

Let's trace how the if condition in our kernel causes divergence:
- Step 1 (
s=1): The condition istid % 2 == 0. Within a warp of 32 threads (lanes 0-31), the 16 even-laned threads evaluate this totrue, while the 16 odd-laned threads evaluate it tofalse. The warp immediately diverges. Only half the threads are doing useful work at any moment. - Step 2 (
s=2): The condition istid % 4 == 0. Now, only 8 threads in the warp (0, 4, 8, ..., 28) are active. The other 24 threads are idle. The warp is even less utilized. - Subsequent Steps: As
sincreases, the number of active threads per warp plummets. In the final steps, only a single thread per warp is active, yet the entire warp is occupied. This is a massive waste of computational resources.
The article you read confirms this analysis.
7 Step Optimization of Parallel Reduction with CUDA - Medium
The author of the Medium article explicitly identifies divergence as a key bottleneck.
Read the section "Problems with this method". Note the identification of "highly divergent warps" as a primary computational inefficiency.
2. Strided, Non-Coalesced Access Patterns
In Module 5, we established that achieving high memory bandwidth depends on coalesced memory access, where adjacent threads access adjacent memory locations. Strided access, where threads access memory with a gap between them, leads to multiple, inefficient memory transactions.
To refresh your memory on why this is so critical, please review this short video.
Coalesce Memory Access - Intro to Parallel Programming
This video from Udacity's "Intro to Parallel Programming" course provides a clear, visual explanation of memory coalescing and the performance penalty of strided access.
Watch from the beginning to the end of the strided access example. This will reinforce the mental model of contiguous versus strided memory access and its direct impact on the number of memory transactions.
Now, let's apply this to our reduction kernel.
- The initial load from global memory,
sdata[tid] = g_in_data[i], is perfectly coalesced. Sinceiis calculated asblockIdx.x * blockDim.x + threadIdx.x, adjacent threads read adjacent elements fromg_in_data. This part is efficient. - The problem lies inside the reduction loop:
sdata[tid] += sdata[tid + s];.
While coalescing is a concept for global memory, the underlying principle of access patterns mattering still applies to shared memory, though the penalty is different (bank conflicts, which we will discuss later). The more immediate issue is the pattern of active threads.
Because of divergence, the threads that are actually performing the sdata[tid] += sdata[tid + s] operation are not contiguous. For s=1, active threads are 0, 2, 4, 6... This is a strided pattern of thread activity. When these strided threads access sdata[tid + s], they are reading from locations s elements away. This combination of strided active threads and strided memory access within shared memory is a classic anti-pattern that signals an inefficient algorithm.
The core issue is that the work is spread thinly across the block rather than being concentrated in a contiguous group of threads, which is almost always more efficient.
Conclusion
In this lesson, we took our first step into implementing a core parallel primitive. We developed a naive sum reduction kernel using an interleaved addressing strategy. While functionally correct, this approach serves as a perfect case study in what not to do for high performance.
Key Takeaways:
- Parallel Reduction is a tree-like algorithm that aggregates an array into a single value. The interleaved addressing strategy is an intuitive but inefficient way to implement it.
- The
if (tid % (2*s) == 0)logic is the main culprit for poor performance, causing severe branch divergence within warps. As the reduction progresses, thread utilization plummets, wasting GPU resources. - This divergence creates a strided pattern of active threads, which leads to inefficient, non-contiguous access to shared memory, a hallmark of a poorly structured parallel algorithm.
We have now clearly diagnosed the problems with our initial attempt. This analysis sets the stage for our next lesson, where we will directly address these inefficiencies. We will start by completely rethinking our indexing strategy to eliminate branch divergence and keep our active threads contiguous, leading to a significant and immediate performance improvement.