Skip to main content

Welcome to a Grasp lesson

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

Lesson 66

CUDA Kernel for LayerNorm Backward Pass

Recommended resources

Introduction

In our last lesson, we successfully navigated the complex calculus required to derive the analytical gradients for the Layer Normalization backward pass. You now possess the three key mathematical formulas that describe how the loss function changes with respect to the layer's input , its scale parameter , and its bias parameter .

This lesson marks the transition from theory to practice. Our objective is to take those derived equations and implement them as a high-performance CUDA kernel. This is a quintessential task for a GPU kernel engineer: translating mathematical specifications into efficient, parallelized code. We will dissect the backward pass calculation, map its components to parallel programming patterns you've already learned—like parallel reduction and vectorized memory access—and structure the implementation according to best practices for CUDA development.


From Analytical Gradients to Parallel Algorithms

Let's begin by recalling the gradient formulas. In the previous lesson, we arrived at a conceptually clear form for the input gradient, . However, for implementation, it's often beneficial to rearrange the terms to expose opportunities for parallel computation. The assignment documentation provides just such a rearranged formula.

[PDF] Assignment 3 Setting up the code Problem 1.1: Softmax Forward (20)

This document provides the mathematical and algorithmic blueprint for our CUDA implementation. We will use it as our primary guide.

First, carefully read the provided formulas for the gradients. Focus on the equation for \nabla x_i, shown as Equation 5, and the simpler equations for \nabla \gamma_i and \nabla \beta_i, shown as Equation 6.

As you can see, the formulas are:

  1. Bias Gradient:
  2. Scale Gradient:
  3. Input Gradient:

The key insight here is that all three gradients rely on reduction operations (summations) across the feature dimension. The input gradient, , is the most complex, requiring two distinct sums to be computed: and .

The note in the documentation that "speedup can then be achieved by concurrently executing two batch reduction operations" is the core of our parallel strategy.

To see how this maps to code, it's helpful to first look at a serial C implementation.

llm.c/doc/layernorm/layernorm.md at master · karpathy/llm. ...

Andrej Karpathy's llm.c project provides a clean, serial C implementation of the backward pass. This serves as an excellent conceptual bridge from the math to the parallel CUDA code we will build.

Study the C function layernorm_backward. Notice the two-pass structure within the main loop. The first pass is a loop that performs the two necessary reduction operations. The second pass iterates again to compute the final gradient for each element using the results of those reductions.

This serial layernorm_backward function perfectly illustrates the logic we need to parallelize. Our CUDA kernel will replace the explicit loops with parallel threads, and the simple += accumulation with efficient parallel reduction algorithms that you learned about in Module 7.

A computational graph showing the backward pass for a normalization layer. The red arrows trace the flow of gradients. The nodes where multiple arrows converge, such as the summations, are precisely the operations we need to parallelize using reduction algorithms.

Structuring the CUDA Implementation

Following the structure suggested by the assignment documentation, we will implement the backward pass using two separate kernels. This is a common design pattern that can improve code modularity.

  1. ker_ln_bw_dgamma_dbetta: This kernel will compute the gradients for the learnable parameters, and . Since both require a reduction over the same dimension, they can be computed efficiently together.
  2. ker_ln_bw_dinp: This kernel will compute the more complex gradient for the input, .

Kernel 1: Gradients for Scale () and Bias ()

Let's focus on ker_ln_bw_dgamma_dbetta. The task is to compute and . Each thread block will be responsible for computing the final gradient for a slice of the and vectors.

The algorithmic steps, as outlined in the documentation, follow a classic parallel reduction pattern using shared memory and warp-level primitives.

[PDF] Assignment 3 Setting up the code Problem 1.1: Softmax Forward (20)

The documentation details the implementation strategy for computing the gamma and beta gradients.

Read the section "Gamma and Beta Gradients". Pay close attention to the four main steps: Looping over rows to calculate partial gradients. Storing these partials in shared memory. Performing a reduction within the thread block using g.shfl_down. Assigning the final result.

The use of g.shfl_down is a key optimization. As you learned in Module 6, warp shuffle instructions allow threads within a warp to exchange data directly without going through shared memory, which is extremely fast.

This diagram illustrates a parallel reduction within a warp using shuffle-down operations. At each step, threads pass their partial sums to other threads in the warp, halving the number of active threads until thread 0 holds the final sum for the entire warp.

The overall process for this kernel within a single thread block would be:

  1. Load Data: Each thread loads its share of out_grad, inp, means, and vars.
  2. Partial Sums: Each thread computes a partial sum for and over the elements it's responsible for.
  3. Shared Memory Store: Threads store their partial sums into shared memory arrays.
  4. Intra-Block Reduction: A parallel reduction is performed on the shared memory arrays. This is often done in two stages:
    • First, a reduction within each warp using __shfl_down_sync.
    • Then, one thread per warp writes its warp's sum to shared memory, and a final reduction across the warp results is performed.
  5. Write Output: The thread with the final sum (e.g., threadIdx.x == 0) writes the result to global memory for gamma_grad and betta_grad.

Kernel 2: Gradient for Input ()

Now, let's tackle ker_ln_bw_dinp. The goal is to compute for each input element. The formula depends on the two sums, and .

The strategy here is analogous to the serial C code: first, compute the reductions, and then use the results to compute the final gradient.

[PDF] Assignment 3 Setting up the code Problem 1.1: Softmax Forward (20)

The documentation provides a similar high-level algorithm for the input gradient kernel.

Read the section "Input Gradients". Note the emphasis on using reinterpret_cast to float4 for vectorized memory access—a technique you saw in Module 9 for GEMM optimization. The core of the algorithm is using a blockReduce helper function.

The blockReduce mentioned here would be a function that implements the shared-memory-based parallel reduction you built in Module 7. The steps for this kernel, processed by each thread block for its assigned row, are:

  1. Parallel Reduction for and :

    • Each thread computes a partial sum for and from the elements it loads. Remember to use vectorized loads (e.g., float4) for efficiency.
    • Threads collaborate using a shared memory reduction (blockReduce) to find the total sums for and for the entire block (i.e., for the entire feature dimension).
    • A __syncthreads() is critical here to ensure all threads have finished contributing to the reduction before any thread proceeds.
  2. Compute Final Gradient:

    • After the __syncthreads(), the final sums and are available to all threads in the block (typically stored in shared memory).
    • Each thread can now compute its final value using the full formula, its local values (), and the shared sums and .
    • Each thread then writes its computed inp_grad value to global memory.

This two-phase approach (reduce, then calculate) is fundamental to parallelizing many machine learning operations.


Conclusion

In this lesson, you have translated the complex mathematics of the Layer Normalization backward pass into a concrete, high-performance parallel implementation strategy. This process is central to the work of a kernel optimization engineer.

Key Takeaways:

  • The LayerNorm backward pass is dominated by parallel reduction operations.
  • Rearranging analytical formulas can expose concurrency, such as the two independent summations needed for the calculation.
  • A practical CUDA implementation can be split into multiple kernels (e.g., one for parameter gradients, one for input gradients) for modularity.
  • Efficient reduction can be achieved using a combination of shared memory for inter-warp communication and warp shuffle instructions (__shfl_down_sync) for fast intra-warp communication.
  • The overall algorithm follows a two-phase pattern: first, perform parallel reductions to compute necessary sums, synchronize, and then have each thread compute its final output using the shared reduction results.
  • Techniques like vectorized memory access (e.g., float4) are crucial for maximizing memory bandwidth, just as they were in GEMM.

You have now implemented both the forward and backward passes for a fused Layer Normalization kernel. This is a significant milestone. In our next lesson, we will turn our attention to another essential component of the Transformer architecture: the Softmax function. We will start by analyzing its potential for numerical instability and implement a robust parallel version that addresses this issue.