Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 68

Numerically Stable Softmax with Two-Pass Parallel Reduction

Recommended resources

Introduction

In our previous lesson, we established why a naive implementation of the softmax function is prone to numerical overflow and how the max-subtraction trick provides a robust, mathematically equivalent solution. You now understand the what and the why of numerical stability for softmax. Today, we transition from theory to practice, focusing on the how.

Our goal is to implement a numerically stable softmax kernel in CUDA. We will build upon the parallel reduction primitive you mastered in Module 7, applying it in a two-pass algorithm. This approach directly translates the stable softmax formula into a parallel execution model where each thread block collaborates to process a single row of the input matrix. We will first find the maximum value in a row, and then, in a second pass, calculate the normalization factor and the final probabilities.


Algorithm Strategy: A Two-Pass Parallel Reduction

For an input matrix of shape , a common and effective parallelization strategy is to launch one thread block for each of the rows. The threads within each block then work together to compute the softmax for their assigned row.

The numerically stable softmax formula is:

This naturally breaks down into a multi-step process for each row:

  1. Find the maximum value: .
  2. Calculate the normalization factor (denominator): .
  3. Compute the final output values: Divide each numerator by the normalization factor.

We will implement this using two distinct parallel reduction phases within a single CUDA kernel. This approach is a significant step up from a naive implementation where each thread would redundantly read the entire row multiple times.

Let's watch a brief segment that introduces this reduction-based approach.

How to write a fast Softmax kernel

The video "How to write a fast Softmax kernel" by Simon Oz explains the core idea of using parallel reductions for softmax.

Watch the section from this timestamp. The key insight here is that both finding the maximum and calculating the sum are associative operations, which makes them perfect candidates for the tree-like parallel reduction algorithms you've already learned.


Kernel Structure and Pass 1: Finding the Maximum

We will launch our kernel with M blocks and a chosen block_size (e.g., 256 or 512). Inside the kernel, the first step for each block is to find the maximum value of its assigned row.

The process is as follows:

  1. Local Reduction: Each thread in the block processes a subset of the row elements. It iterates through its assigned elements and finds a local maximum (local_max).
  2. Block-wide Reduction: The threads then collaborate to "reduce" their local_max values into a single global_max for the entire row. This is done using shared memory.

Let's review how a block-wide reduction using shared memory works. The following resource provides an excellent, detailed walkthrough.

Learning CUDA by optimizing softmax: A worklog | Maharshi's blog

This blog post by Maharshi Pandya clearly illustrates the logic of a parallel reduction.

Please read the section titled "Shared memory and reductions". Pay close attention to the example with 8 threads that shows how the smem array is updated in logarithmic steps. This is the exact algorithm we will use for both of our reduction passes.

With this reduction pattern in mind, the first half of our kernel looks like this in pseudocode:

__global__ void softmax_kernel(float* input, float* output, int M, int N) {
    // Each block processes one row
    int row = blockIdx.x;
    int tid = threadIdx.x;

    // Allocate shared memory for the reduction
    __shared__ float smem[BLOCK_SIZE];

    // --- PASS 1: Find Maximum Value ---

    // 1. Local Reduction
    float local_max = -INFINITY;
    for (int i = tid; i < N; i += blockDim.x) {
        local_max = max(local_max, input[row * N + i]);
    }

    // 2. Block-wide Reduction
    smem[tid] = local_max;
    __syncthreads(); // Ensure all local_max values are in shared memory

    // The reduction loop from the article goes here...
    for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
        if (tid < stride) {
            smem[tid] = max(smem[tid], smem[tid + stride]);
        }
        __syncthreads();
    }

    // The global max for the row is now in smem[0]
    float global_max = smem[0];
    __syncthreads(); // Ensure all threads see the final max
    
    // ... Pass 2 and Final Computation will follow
}

Pass 2: Calculating the Normalization Factor

After Pass 1, global_max (the row's maximum value) is available to all threads in the block. Now we can proceed to the second reduction pass to compute the denominator, which we'll call norm.

The logic is identical to Pass 1, but with two key differences:

  • The operation is summation (+) instead of max.
  • The value each thread accumulates locally is .

The pseudocode for this second pass continues from where we left off:

// (Continued from Pass 1)
// float global_max = smem[0];
// __syncthreads();

// --- PASS 2: Find Normalization Factor ---

// 1. Local Reduction
float local_norm = 0.0f;
for (int i = tid; i < N; i += blockDim.x) {
    local_norm += expf(input[row * N + i] - global_max);
}

// 2. Block-wide Reduction for the sum
smem[tid] = local_norm;
__syncthreads(); // Ensure all local_norm values are in shared memory

// Reduction loop for summation...
for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
    if (tid < stride) {
        smem[tid] += smem[tid + stride];
    }
    __syncthreads();
}

// The global norm for the row is now in smem[0]
float global_norm = smem[0];
__syncthreads(); // Ensure all threads see the final norm

Final Computation and Write-Out

With both global_max and global_norm computed and available to all threads, the final step is straightforward. We perform a third and final traversal of the row, where each thread calculates the final softmax value for its assigned elements and writes them to global memory.

// (Continued from Pass 2)
// float global_norm = smem[0];
// __syncthreads();

// --- Final Computation and Write ---
for (int i = tid; i < N; i += blockDim.x) {
    int index = row * N + i;
    output[index] = expf(input[index] - global_max) / global_norm;
}

This completes the kernel. While it involves three loops over the data, it's considered a "two-pass reduction algorithm" because its core parallel logic consists of two block-wide reduction operations. This implementation is vastly more efficient than a naive one, as it minimizes redundant reads from global memory and effectively parallelizes the two most expensive parts of the computation.


Optimizations and Further Thoughts

The implementation we've designed is a solid, understandable, and correct parallel algorithm. However, in the world of high-performance computing, the optimization journey is never truly over.

Online Softmax:
One major optimization is to fuse the two reduction passes into one. This is known as the "online softmax" method. In a single pass over the data, it's possible to maintain a running estimate of both the maximum and the normalization factor, applying a correction to the norm whenever a new maximum is found.

How to write a fast Softmax kernel

The "How to write a fast Softmax kernel" video explains this advanced, single-pass technique.

Watch the segment from this timestamp. Notice how it introduces a "fix" to the divisor estimate whenever a new maximum value is encountered. This eliminates one full pass over the data, further reducing memory bandwidth usage.

Warp-Level Primitives:
Another powerful optimization is to use warp-level shuffle instructions (__shfl_down_sync) for the reduction. As you learned in Module 6, these allow threads within a warp to exchange data directly through registers, avoiding the overhead of writing to and reading from shared memory. A highly optimized kernel would first perform a reduction within each warp using shuffles, and then use shared memory only to reduce the results from each warp.

This diagram illustrates a warp-level reduction using `__shfl_down_sync`. Data is passed between threads in a warp in logarithmic steps, similar to the shared memory reduction, but much faster as it happens within registers.
In contrast, a block-level reduction requires communication across multiple warps, which necessitates the use of shared memory as an intermediate storage and synchronization point.

You can explore a kernel that uses these shuffle instructions in the resource Learning CUDA by optimizing softmax: A worklog under "Kernel 4 - Shuffle instructions".


Conclusion

In this lesson, you have translated the theoretical concept of a numerically stable softmax into a concrete, parallel CUDA kernel. You designed a block-per-row strategy and correctly applied the parallel reduction primitive twice: once to find the maximum value, and a second time to find the normalization factor.

Key Takeaways:

  • A numerically stable softmax can be implemented in CUDA using a two-pass parallel reduction algorithm.
  • The strategy of assigning one block per row allows for efficient intra-block collaboration.
  • Pass 1 performs a max reduction using shared memory to find the row's maximum value.
  • Pass 2 performs a sum reduction using shared memory to calculate the normalization factor based on the result of Pass 1.
  • A final pass computes the output values and writes them to global memory.
  • This approach is a robust and performant baseline, which can be further optimized with advanced techniques like online softmax and warp-level primitives.

With your understanding of kernel fusion, normalization, and now softmax, you are building a solid toolkit for implementing the core components of modern AI models. In our next lesson, we will explore another critical concept for performance: the use of lower-precision data types like FP16 and BFloat16 in mixed-precision computing.