Skip to main content

Welcome to a Grasp lesson

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

Lesson 26

Kernel Bounds Checking for Uneven Input Sizes

Recommended resources

Introduction

In our last lesson, you mastered the crucial skill of calculating unique global indices for threads in both 1D and 2D grids. This allows us to assign a specific piece of work—like an element in a vector or a pixel in an image—to each thread. However, our examples so far have assumed that the size of our data is a perfect multiple of the number of threads in a block. In real-world scenarios, this is rarely the case.

This lesson addresses that practical reality. We will explore what happens when the number of data elements doesn't align perfectly with our thread block size and how to write robust kernels that handle these situations safely. By the end of this lesson, you will be able to implement the essential technique of "bounds checking" to ensure your kernels always operate within the valid memory range of your data, preventing crashes and data corruption. This is a fundamental skill for writing correct and reliable GPU code.


The Problem with Uneven Workloads

Let's revisit our vector addition example. We have a vector of N elements and we launch blocks of threadsPerBlock threads. In the previous lesson, we might have calculated the number of blocks to launch like this:

int numBlocks = N / threadsPerBlock;

This works perfectly if N is, for example, 4096 and threadsPerBlock is 256, because 4096 / 256 = 16. But what if N is 4097? Using integer division, 4097 / 256 results in 16. We would launch 16 blocks, for a total of 16 * 256 = 4096 threads. The final element of our vector at index 4096 would never be processed.

To solve this, we need to ensure we always launch enough blocks to cover all N elements. The standard C++ integer arithmetic trick for this is to calculate the ceiling of the division, which ensures we round up:

int numBlocks = (N + threadsPerBlock - 1) / threadsPerBlock;

Let's re-run our N = 4097 example:
(4097 + 256 - 1) / 256 is 4352 / 256, which equals 17. We now launch 17 blocks, for a total of 17 * 256 = 4352 threads. This is more than enough to cover all 4097 elements.

However, this solution introduces a new, more dangerous problem. We have launched 4352 threads, but our data arrays only have 4097 elements. The threads with global IDs from 4097 to 4351 will attempt to read from A[4097] and B[4097] and write to C[4097] (and beyond). This is a classic out-of-bounds memory access, a severe bug that can lead to program crashes or, worse, silent data corruption.


The Solution: Kernel Bounds Checking

The solution is simple yet powerful: inside the kernel, after each thread calculates its unique global ID, we add a conditional check to ensure that ID is within the valid bounds of our data arrays.

The structure of the kernel becomes:

  1. Calculate the thread's unique global index i.
  2. Check if i is less than the total number of elements N.
  3. Only if the check passes, perform the computation.

This way, the "extra" threads we launched simply do nothing and exit gracefully, without touching invalid memory.

The following video provides a clear, step-by-step explanation of this two-part solution: calculating the padded grid size and then implementing the in-kernel bounds check.

CUDA Crash Course (v2): Vector Addition

This segment from Nick's "CUDA Crash Course" on vector addition perfectly illustrates the problem and solution.

Please watch two key parts: First, watch the section on calculating grid size. This explains the integer arithmetic for ceiling division and why it's necessary. Next, watch the implementation of the bounds check. Pay close attention to the if (tID < n) condition and the explanation for why it prevents out-of-bounds access for the extra threads launched.

The resulting kernel for vector addition looks like this:

__global__ void vectorAdd(const float *A, const float *B, float *C, int N)
{
    // Calculate the unique global thread ID
    int i = blockIdx.x * blockDim.x + threadIdx.x;

    // Bounds check: only threads with a valid ID perform the work
    if (i < N)
    {
        C[i] = A[i] + B[i];
    }
}

This pattern is fundamental to almost all real-world CUDA programming. It decouples the launch configuration from the exact problem size, allowing for flexible and robust kernels.

The following article provides a concise written summary of both the grid calculation and the bounds check.

Tutorial: Your First CUDA Program - Vector Addition - eunomia

This tutorial from eunomia.dev provides an excellent, compact reference for the concepts we've just discussed.

Please read the two main sections: Focus on Dissecting the Kernel Function, which highlights the thread indexing formula and explains why the if (i < numElements) check is "crucial." Then, read Launching the Kernel, which shows the ceiling division formula used to determine the blocksPerGrid.


A Deeper Look: The Hardware Perspective

Given your interest in low-level systems, it's worth asking what this if statement actually compiles down to at the hardware level. The GPU executes threads in groups of 32 called warps. All threads in a warp execute the same instruction at the same time (this is the SIMT model we'll cover in detail later).

When a warp encounters a conditional branch like our bounds check, a few things can happen:

  • If all 32 threads pass the condition (i < N), they all proceed to execute the code inside the if block.
  • If all 32 threads fail the condition, the entire warp skips the code inside the if block. This is what happens to our "extra" threads.
  • If some threads pass and some fail (which can happen at the boundary of our data), the warp experiences divergence. The threads that passed will execute the if block while the others are "masked off" and idle.

This "masking" is handled by the hardware. The if statement is compiled down to a setp (set predicate) instruction, which sets a per-thread flag based on the condition, followed by a conditional bra (branch) instruction that uses this predicate. Threads that are masked off do not perform the memory accesses or computations within the conditional block, ensuring safety.

The video you watched earlier briefly shows this at the assembly level. Watching this short segment will connect the C++ code directly to the underlying machine instructions.

CUDA Crash Course (v2): Vector Addition

Let's revisit the "CUDA Crash Course" video to see the compiled output.

Watch the brief analysis of the assembly code, which shows how the bounds check is implemented: First, see the PTX assembly, which is an intermediate assembly language for CUDA. Notice the setp.ge (set predicate greater or equal) and bra (branch) instructions. Then, see the final SASS machine code, which shows a similar pattern of a set predicate instruction followed by an exit branch if the condition is met.


Generalizing to 2D Grids

This bounds-checking principle extends naturally to multi-dimensional grids. In the previous lesson, you learned to calculate a global row and col for 2D data. To safely handle a matrix of size Height x Width, you simply apply a check for each dimension.

A 2D CUDA Grid of thread blocks. To safely process a matrix, each thread must check if its computed (row, col) coordinates fall within the matrix dimensions.

A 2D matrix addition kernel would look like this:

__global__ void matrixAdd(const float *A, const float *B, float *C, int Height, int Width)
{
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;

    // Bounds check for both dimensions
    if (row < Height && col < Width)
    {
        int index = row * Width + col;
        C[index] = A[index] + B[index];
    }
}

The logic is identical: we launch a grid large enough to cover the entire matrix and then have each thread check whether it falls within the valid boundaries before accessing any data. The resource CUDA Thread Basics shows this exact pattern in its example for matrix addition.


Conclusion

You have now learned one of the most fundamental and practical aspects of writing production-ready CUDA code. By over-provisioning threads at launch and using an in-kernel bounds check, you can write kernels that are both correct and flexible, handling arbitrary data sizes without risking disastrous out-of-bounds memory errors.

Key Takeaways:

  • Real-world data sizes are rarely perfect multiples of the thread block size.
  • To ensure all elements are processed, calculate the number of blocks using ceiling division: (N + threadsPerBlock - 1) / threadsPerBlock.
  • This approach intentionally launches more threads than are strictly necessary.
  • A conditional bounds check, if (thread_id < N), must be the first step in a kernel's logic to prevent these extra threads from accessing invalid memory.
  • This pattern translates at the hardware level to predicated execution, where inactive threads are "masked off" and do not execute memory operations.
  • The principle extends directly to multi-dimensional grids by checking the bounds for each dimension (e.g., if (row < Height && col < Width)).

In our next lesson, we will continue building robust code by focusing on systematic error handling for CUDA API calls and kernel launches. While bounds checking prevents a major class of runtime bugs, proper error handling will allow you to catch setup and launch failures immediately, which is crucial for debugging complex GPU applications.