Skip to main content

Welcome to a Grasp lesson

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

Lesson 63

Fused Element-wise Kernels: Implementation and Performance Benefits

Recommended resources

Introduction

In our last lesson, we concluded our deep dive into GEMM optimization by benchmarking our kernel against the industry-standard cuBLAS library. We discovered that while our optimizations brought us far, the remaining performance gap is explained by advanced techniques like autotuning and, crucially, the use of specialized hardware. A key theme was that performance is often dictated by memory access. Every trip to global memory is expensive, and minimizing this traffic is paramount.

This lesson introduces kernel fusion, a fundamental technique that directly attacks this memory bottleneck. We will explore how combining multiple, simple kernels into one can yield significant performance gains by eliminating redundant data movement. You will learn not only to explain the benefits of this technique but also to implement a fused kernel for a common pattern in neural networks: adding a bias vector and applying a ReLU activation function. This concept is a cornerstone of modern high-performance AI, forming the basis for sophisticated optimizations like the FlashAttention algorithm we glimpsed previously.


The "Why" of Kernel Fusion: Avoiding the Memory Wall

Imagine a typical sequence in a neural network layer: you perform a matrix multiplication, add a bias vector, and then apply an activation function like ReLU. Without fusion, this would translate into three separate kernel launches:

  1. GEMM Kernel: Computes , writing the result matrix to global memory.
  2. Bias-Add Kernel: Reads , adds bias , and writes the result back to global memory.
  3. ReLU Kernel: Reads , applies , and writes the final result back to global memory.

The problem lies in the hand-offs between kernels. The intermediate tensors, and , are written to and read back from slow GPU DRAM. This round-trip is the performance killer.

Kernel fusion collapses these steps into a single operation. Instead of separate kernels, we write one "mega-kernel" that performs all the operations in sequence. The intermediate results, like , never leave the GPU's fast on-chip memory; they are held in registers before the final result is written out.

On the left, two separate kernels `f` and `g` are executed. Kernel `f` performs a load, a compute step, and a store to global memory. Kernel `g` then loads that intermediate result, performs its own compute, and stores the final result. On the right, the kernels are fused. A single kernel loads the initial data, performs both compute steps back-to-back using registers for the intermediate value, and executes only a single final store. The expensive store/load round-trip between `f` and `g` is eliminated.

The performance benefits are threefold:

  1. Reduced Memory Traffic: This is the most significant gain. By eliminating writes and reads of intermediate tensors to global memory, we save immense memory bandwidth and latency.
  2. Reduced Kernel Launch Overhead: Launching a kernel from the host CPU involves overhead. Fewer launches mean less time spent on this overhead.
  3. Increased Arithmetic Intensity: As we learned in the context of the Roofline model, arithmetic intensity is the ratio of floating-point operations to bytes of memory accessed (FLOPs/Byte). By drastically reducing the bytes accessed for the same number of FLOPs, fusion increases a kernel's arithmetic intensity, allowing it to move from being memory-bound towards being compute-bound and thus achieve higher performance.

The "How" of Kernel Fusion: Programmer vs. Compiler

You might wonder if the nvcc compiler can automatically fuse your separate __global__ functions. As a general rule, it cannot. Kernel fusion is typically a manual refactoring process performed by the programmer. You, the developer, are responsible for identifying sequential, memory-bound kernels and rewriting them as a single, combined kernel.

However, once you've written this fused kernel, the compiler plays a crucial role. It optimizes your code by ensuring that the intermediate variables you create are stored in the fastest possible memory: registers.

Let's study a clear explanation of this process.

How is elementwise operators fusion done by compiler?

This NVIDIA Developer Forums post provides a concise and authoritative explanation of kernel fusion, clarifying the roles of the programmer and the compiler.

Read the initial question and the detailed replies from the NVIDIA staff. Focus on these key points: The confirmation that fusion is a manual refactoring process. The simple code example showing two kernels being combined into one, saving a load and a store operation. The explanation of how the compiler optimizes the fused code by placing the intermediate variable into a register. The clarification that thread-local variables like temp are placed into registers as an optimization by default.

This manual approach is the foundation. However, the AI ecosystem has evolved to automate this. High-level frameworks like PyTorch have developed powerful JIT (Just-In-Time) compilers that can perform these fusions for you.


A Modern Approach: Fusion with torch.compile and Triton

The torch.compile feature in PyTorch is a prime example of automated fusion. It analyzes your Python code, identifies fusible patterns of operations, and generates highly optimized, fused kernels using a backend called Triton. Triton is a Python-based language created by OpenAI for writing efficient GPU kernels, which offers a higher level of abstraction than raw CUDA C++.

Let's watch a practical demonstration of this process. The speaker will take a Deep Learning Recommendation Model (DLRM), identify performance bottlenecks with the profiler, and then use torch.compile to automatically generate fused kernels, resulting in a dramatic speedup.

Lecture 18: Fusing Kernels

This lecture by Kapil Sharma provides a fantastic, real-world case study on using torch.compile to generate and inspect fused kernels.

Watch the following segments: First, see how torch.compile is applied and observe the performance improvement from applying torch.compile. Next, watch the segment where the speaker inspects the profiler trace and the generated code, starting at the torch.compile trace. He identifies that torch.compile has created a CUDA graph that launches several Triton kernels, including a pointwise fused ReLU. Finally, focus on the code inspection from this point. He examines the generated Triton kernel and explains how it performs the bias-add and ReLU operations all in one go.


Implementing a Fused Bias-Add-ReLU Kernel

The video showed us what a fused kernel generated by torch.compile looks like. Now, let's examine the code for this pointwise_add_relu kernel in both Triton and CUDA C++, using the code provided in the blog post that accompanies the video.

CUDA Mode - Fusing Kernels Talk - Kapil Sharma

This blog post provides the source code for the kernels discussed in the video, giving us a concrete implementation to study.

First, read the section "Annotated Triton Kernel". This shows the pointwise_add_relu_fusion_512 kernel written in Triton. Note the key operations: tl.load(in_out_ptr0 ...): Loads a value from the main input tensor. tl.load(in_ptr0 ...): Loads a value from the bias tensor. tmp2 = tmp0 + tmp1: Adds them. This result tmp2 exists only in a register. tmp3 = triton_helpers.maximum(0, tmp2): Performs the ReLU. tl.store(in_out_ptr0 ...): Writes the final result to memory. Next, study the equivalent CUDA kernel in the section "Cuda Kernel". You can skip the build commands and focus on the C++ code for the kernel itself. This implementation directly mirrors the logic of the Triton kernel and should feel very familiar given your background. Pay close attention to how it handles the indices.

Let's break down the logic of the CUDA kernel, as it's a perfect template for an element-wise fused operation:

__global__ void pointwise_add_relu_fusion_512(
    float* in_out_ptr0, // Input/Output tensor (e.g., shape [B, N])
    const float* in_ptr0,   // Bias tensor (e.g., shape [N])
    const int XBLOCK)       // Number of columns N
{
    const int xnumel = 65536; // Total elements B * N
    const int N = 512;        // This should match XBLOCK

    // Standard 1D global thread indexing
    const int idx = blockIdx.x * blockDim.x + threadIdx.x;

    if (idx < xnumel) {
        // 'idx' is the linear index into the [B, N] tensor
        int linear_index = idx;

        // 'bias_index' maps the linear index to the correct column for the bias
        // This effectively broadcasts the 1D bias vector across all rows
        int bias_index = idx % N;

        // Load input value
        float input_val = in_out_ptr0[linear_index];
        
        // Load corresponding bias value
        float bias_val = in_ptr0[bias_index];

        // --- FUSION HAPPENS HERE ---
        // The intermediate result of the addition is held in a register
        float intermediate_val = input_val + bias_val;
        
        // The ReLU is applied to the register value
        float final_val = max(0.0f, intermediate_val);
        // -------------------------

        // Store the final result once. No intermediate writes to global memory.
        in_out_ptr0[linear_index] = final_val;
    }
}

This simple kernel beautifully illustrates the power of fusion. The intermediate_val variable lives ephemerally in a register, completely avoiding a costly round-trip to global memory.


Quantifying the Benefits

The most direct benefit of eliminating intermediate tensors is a reduction in memory allocations and deallocations. The video provides a clear visual of this.

Lecture 18: Fusing Kernels

This short clip shows the memory profiler output before and after applying torch.compile.

Watch the segment from the memory profile comparison. Notice how the "eager mode" memory profile has many spikes corresponding to allocations for intermediate results. The torch.compile version has a much flatter, lower memory profile because these allocations have been fused away.

This improved memory behavior translates directly into speed. The blog post includes a table showing that the custom Triton kernel significantly outperforms the standard eager-mode PyTorch implementation, which executes operations one by one. This speedup is the direct result of avoiding the memory wall through fusion.


Conclusion

In this lesson, you have been introduced to kernel fusion, a cornerstone of high-performance GPU programming. You've moved from the highly-tuned, monolithic world of GEMM to the more common scenario of optimizing a sequence of smaller, memory-bound operations.

Key Takeaways:

  • Kernel fusion combines sequential kernel operations into a single kernel to eliminate round-trips to global memory for intermediate results.
  • The primary benefits are a drastic reduction in memory bandwidth consumption and the elimination of kernel launch overhead.
  • This technique increases a kernel's arithmetic intensity, allowing it to better utilize the GPU's computational resources.
  • While traditionally a manual code refactoring process in CUDA C++, modern tools like torch.compile can automate fusion, often by generating high-level Triton code.
  • A fused bias-add-ReLU kernel serves as a canonical example, where the intermediate result of the addition is held in a register before the activation function is applied.

In our next lesson, we will tackle a more complex and critical AI kernel: Layer Normalization. Implementing it efficiently will require us to combine our new understanding of fusion with the parallel reduction techniques you mastered in Module 7, demonstrating how these optimization principles build upon one another.