100 Days of CUDA

pmpp chapter 3 · Multidimensional Grids and Data
PMPP · Chapter 03 · Multidimensional Grids and Data

Dear reader: These notes were created with the help of AI, with me cherry-picking the parts of the book I found most relevant. I also reviewed the content to make sure no AI hallucinations slipped through. I hope you find them useful. Happy reading! :)

Programming Massively Parallel Processors: Chapter 3, Multidimensional Grids and Data

  1. Grid and Block Organization
  2. Dynamic Grid Sizing
  3. Built-in Variables Inside a Kernel
  4. CUDA Limits
  5. Mapping Threads to Data
  6. Linearizing Multidimensional Arrays
  7. 3D Grids and Data
  8. BLAS: Basic Linear Algebra Subprograms

1. Grid and Block Organization

The key idea of this chapter is that CUDA organizes threads in a two-level hierarchy that can be up to three-dimensional, and your job is to map that hierarchy onto the shape of your data.

The Two-Level Hierarchy

  1. A grid is a 3D array of blocks - The grid is the top level: every kernel launch creates exactly one grid.

  2. A block is a 3D array of threads - Blocks are the second level, and every block in a grid has the same dimensions.

  3. Unused dimensions are simply set to 1 - A "1D grid" is really a 3D grid with y = 1 and z = 1.

  4. Dimensions are specified with the dim3 type

dim3 grid(x, y, z);
dim3 block(x, y, z);
kernel<<<grid, block>>>();

Execution Configuration

The <<<...>>> syntax between the kernel name and its arguments is the execution configuration:

kernel<<<gridDim, blockDim>>>();

Example:

kernel<<<32, 128>>>();

The 1D Shortcut

For one-dimensional launches you do not need dim3 at all. Instead of:

dim3 grid(16, 1, 1);
dim3 block(256, 1, 1);
kernel<<<grid, block>>>();

you can simply write:

kernel<<<16, 256>>>();

CUDA assumes y = 1 and z = 1 for both the grid and the block.

Summary: A kernel launch creates a grid of blocks, each block a (up to 3D) array of threads. dim3 describes both levels, and plain integers are a convenient shortcut for the 1D case.


2. Dynamic Grid Sizing

A common and important pattern: fix the block size, and let the number of blocks scale with the input. This makes the same kernel work for any input size.

kernel<<<ceil(n / 256.0), 256>>>();

Examples:

Summary: Hard-coding the grid size ties your kernel to one input size. Computing the block count with ceil(n / blockSize) makes the launch configuration scale automatically, at the small cost of a few extra threads that must be masked off with a bounds check.


3. Built-in Variables Inside a Kernel

Inside a kernel, CUDA automatically provides a set of built-in variables that tell each thread where it lives in the hierarchy:

Variable Meaning Set by
gridDim Number of blocks in the grid (.x .y .z) The execution configuration
blockDim Number of threads per block (.x .y .z) The execution configuration
blockIdx This block's index within the grid The runtime, per block
threadIdx This thread's index within its block The runtime, per thread

Properties:

Summary: Every thread runs the same kernel code, and these built-in variables are what let each thread figure out which piece of the data is its own.


4. CUDA Limits

The hierarchy is not unlimited. Both grids and blocks have hard caps you need to respect.

Grid Limits

Note the asymmetry: the x dimension is enormous, while y and z are much smaller.

Block Limits

Valid examples:

(512, 1, 1)   // 512 threads
(8, 16, 4)    // 512 threads
(32, 16, 2)   // 1024 threads

Invalid:

(32, 32, 2)   // 2048 threads > 1024

Rules Worth Remembering

Summary: 1024 threads per block is the hard ceiling, the grid's x dimension is effectively unbounded while y and z cap at 65,535, and every block in a grid is the same shape.


5. Mapping Threads to Data

This is the heart of the chapter: the point of multidimensional grids is to make the thread organization mirror the data organization.

Choosing a Grid Shape

Choose the grid layout to match the structure of the data:

Data Grid
Vectors (1D) 1D grid
Images, matrices (2D) 2D grid
Volumes, simulations (3D) 3D grid

Computing Global Coordinates

Each thread typically processes one data element, and it finds that element by combining blockIdx, blockDim, and threadIdx into global coordinates:

row = blockIdx.y * blockDim.y + threadIdx.y;
col = blockIdx.x * blockDim.x + threadIdx.x;

Notice the convention: y maps to rows and x maps to columns. Mixing these up is a classic source of bugs.

Worked Example: A 2D Image

Process a 62 × 76 image with 16 × 16 blocks:

Here is the catch: the edge blocks hang over the boundary of the image, so some threads have no pixel to process. Every thread must check bounds before touching memory:

if (row < height && col < width) {
    // safe to process pixel (row, col)
}

Summary: Match the grid dimensionality to the data, compute each thread's global coordinates from blockIdx * blockDim + threadIdx, and always guard with a bounds check because ceil() rounds the grid up past the edge of the data.


6. Linearizing Multidimensional Arrays

Here is a surprise for newcomers: even though grids and blocks are multidimensional, device memory is flat. Your 2D and 3D arrays have to be flattened by hand.

Why Flattening Is Necessary

index = row * width + col;

Row-Major vs. Column-Major

There are two conventions for laying out a 2D array in flat memory:

Row-major (C/C++, CUDA) Column-major (Fortran, MATLAB)
Rows are stored consecutively. Columns are stored consecutively.
index = row * width + col; index = col * height + row;

Note: CUDA, like C/C++, uses row-major layout by default. Keep this in mind when interfacing with libraries or languages that assume column-major.

Summary: Multidimensional arrays are an illusion the programmer maintains: memory is 1D, so you compute row * width + col yourself, and CUDA follows C's row-major convention.


7. 3D Grids and Data

Everything from the 2D case extends naturally to 3D. The mental model: a 3D array is a stack of 2D planes, and the z dimension selects the plane.

Each thread computes three global coordinates:

int plane = blockIdx.z * blockDim.z + threadIdx.z;
int row   = blockIdx.y * blockDim.y + threadIdx.y;
int col   = blockIdx.x * blockDim.x + threadIdx.x;

The flattened row-major index adds one more term for the plane:

int index = plane * rows * cols + row * cols + col;

Two things to remember:

if (plane < depth && row < rows && col < cols) {
    // safe to access element (plane, row, col)
}

Summary: A 3D array is just a stack of 2D planes in flat memory. The indexing pattern generalizes directly: one extra coordinate, one extra term in the index formula, and one extra bounds check.


8. BLAS: Basic Linear Algebra Subprograms

Matrix and vector operations are so common that they were standardized decades ago into BLAS, and it is worth knowing the vocabulary because GPU libraries (like cuBLAS) are organized around it.

The Three BLAS Levels

Level Operations Example Formula
1 Vector-vector Vector addition, dot product y = αx + y
2 Matrix-vector Multiplying a matrix by a vector y = αAx + βy
3 Matrix-matrix Matrix multiplication C = αAB + βC

Note: Matrix multiplication is a Level 3 BLAS operation, making it a key workload for CUDA.

Summary: BLAS classifies linear algebra into three levels: vector-vector, matrix-vector, and matrix-matrix. The higher the level, the more computation per data element, which is exactly what GPUs thrive on; that is why matrix multiplication (Level 3) is such a central CUDA workload.

‹ Ch02: Heterogeneous Data-Parallel ComputingCh04: Compute Architecture and Scheduling ›