CUDA Programming Series — Tutorial 03

Thread Hierarchy & Indexing

Grids, blocks, threads, warps — how to map problem dimensions to launch configurations with worked index calculations.

CUDA Thread Indexing Grids Blocks Warps dim3
Visual Layout → 1D Indexing → 2D Indexing → Launch Config → Warp Execution → Occupancy → Exercises
00

Topics We'll Cover

This tutorial dives into the CUDA thread hierarchy — the fundamental mental model for mapping your data to GPU threads. We cover 1D and 2D indexing with concrete examples, launch configurations, and warp-level execution.

Prerequisites

Tutorials 01 — GPU Architecture and 02 — Your First CUDA Kernel. You should be comfortable with the host/device model, __global__ functions, and nvcc compilation.

01

Grids, Blocks, and Threads — Visual

Every kernel launch creates a grid of blocks, and each block contains threads. This three-level hierarchy can be 1D, 2D, or 3D — you choose the dimensionality that best matches your data.

The Hierarchy

Grid
→
Block (0,0) .. Block (gx-1, gy-1)
→
Thread (0,0) .. Thread (bx-1, by-1)

1D Grid of 1D Blocks

The simplest case — a flat array of blocks, each containing a flat array of threads. Ideal for 1D data like vectors.

Grid: 4 blocks × 8 threads = 32 threads total
Block 0
T0
T1
T2
T3
T4
T5
T6
T7
Block 1
T0
T1
T2
T3
T4
T5
T6
T7
Block 2
T0
T1
T2
T3
T4
T5
T6
T7
Block 3
T0
T1
T2
T3
T4
T5
T6
T7

The dim3 Type

CUDA uses the dim3 type to specify grid and block dimensions. Unspecified dimensions default to 1.

dim3 — Specifying Dimensions
dim3 block(256);             // (256, 1, 1) — 1D block of 256 threads
dim3 block(16, 16);          // (16, 16, 1) — 2D block of 256 threads
dim3 block(8, 8, 4);          // (8, 8, 4)   — 3D block of 256 threads

dim3 grid(10);               // (10, 1, 1)  — 1D grid of 10 blocks
dim3 grid(10, 10);            // (10, 10, 1) — 2D grid of 100 blocks

kernel<<<grid, block>>>(...); // Launch with these dimensions

2D Grid of 2D Blocks

For matrix operations, a 2D grid of 2D blocks maps naturally to rows and columns.

2D Grid (2×2 blocks) of 2D Blocks (4×4 threads) = 64 threads
Block (0,0)
0,0
1,0
2,0
3,0
0,1
1,1
2,1
3,1
0,2
1,2
2,2
3,2
0,3
1,3
2,3
3,3
Block (1,0)
0,0
1,0
2,0
3,0
0,1
1,1
2,1
3,1
0,2
1,2
2,2
3,2
0,3
1,3
2,3
3,3
Block (0,1)
0,0
1,0
2,0
3,0
0,1
1,1
2,1
3,1
0,2
1,2
2,2
3,2
0,3
1,3
2,3
3,3
Block (1,1)
0,0
1,0
2,0
3,0
0,1
1,1
2,1
3,1
0,2
1,2
2,2
3,2
0,3
1,3
2,3
3,3
Key Limits

Max threads per block: 1024. Max block dimensions: (1024, 1024, 64). Max grid dimensions: (231-1, 65535, 65535). The product of block dimensions must not exceed 1024.

02

Thread Indexing — 1D Case

Every thread knows its position within its block (threadIdx.x), which block it belongs to (blockIdx.x), and the block size (blockDim.x). Combining these gives a unique global index.

The Formula

blockIdx.x
×
blockDim.x
+
threadIdx.x
=
Global Index

Worked Example

Suppose we launch a kernel with 4 blocks of 8 threads each to process an array of 32 elements.

blockIdx.x threadIdx.x blockDim.x Global Index Calculation
0 0 8 0 0 * 8 + 0 = 0
0 5 8 5 0 * 8 + 5 = 5
1 0 8 8 1 * 8 + 0 = 8
2 3 8 19 2 * 8 + 3 = 19
3 7 8 31 3 * 8 + 7 = 31

Complete 1D Vector Addition

vector_add_1d.cu
#include <stdio.h>
#include <cuda_runtime.h>

__global__ void vectorAdd(const float *a, const float *b, float *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main(void) {
    const int N = 1000000;
    size_t bytes = N * sizeof(float);

    // Allocate host memory
    float *h_a = (float *)malloc(bytes);
    float *h_b = (float *)malloc(bytes);
    float *h_c = (float *)malloc(bytes);

    // Initialise host arrays
    for (int i = 0; i < N; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    // Allocate device memory
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes);
    cudaMalloc(&d_b, bytes);
    cudaMalloc(&d_c, bytes);

    // Copy data to device
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    // Launch kernel: 256 threads per block, enough blocks to cover N
    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;  // ceil(N / blockSize)
    vectorAdd<<<gridSize, blockSize>>>(d_a, d_b, d_c, N);

    // Copy result back to host
    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

    // Verify result
    int errors = 0;
    for (int i = 0; i < N; i++) {
        if (h_c[i] != 3.0f) errors++;
    }
    printf("Vector addition: %s (%d errors)\n",
           errors == 0 ? "PASSED" : "FAILED", errors);

    // Free memory
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    free(h_a);
    free(h_b);
    free(h_c);

    return 0;
}
Why the boundary check?

We launch ceil(N / blockSize) blocks, which may create more threads than elements. The if (idx < n) guard prevents out-of-bounds memory access. This pattern is used in virtually every CUDA kernel.

03

Thread Indexing — 2D Case

For matrices, images, and 2D data, use a 2D grid of 2D blocks. Each thread computes its row and column, then converts to a row-major linear index to access the flattened array in memory.

The Formulae

Row Index

row = blockIdx.y * blockDim.y + threadIdx.y

Column Index

col = blockIdx.x * blockDim.x + threadIdx.x

row
×
width
+
col
=
Linear Index

2D Grid Mapped onto an 8×8 Matrix

A 2×2 grid of 4×4 blocks covers an 8×8 matrix. Each cell shows its (col, row) global coordinate.

Grid: dim3(2, 2) — Block: dim3(4, 4) — Matrix: 8×8
0,0
1,0
2,0
3,0
4,0
5,0
6,0
7,0
0,1
1,1
2,1
3,1
4,1
5,1
6,1
7,1
0,2
1,2
2,2
3,2
4,2
5,2
6,2
7,2
0,3
1,3
2,3
3,3
4,3
5,3
6,3
7,3
0,4
1,4
2,4
3,4
4,4
5,4
6,4
7,4
0,5
1,5
2,5
3,5
4,5
5,5
6,5
7,5
0,6
1,6
2,6
3,6
4,6
5,6
6,6
7,6
0,7
1,7
2,7
3,7
4,7
5,7
6,7
7,7
Block (0,0)
Block (1,0)
Block (0,1)
Block (1,1)

Complete 2D Matrix Addition

matrix_add_2d.cu
#include <stdio.h>
#include <cuda_runtime.h>

__global__ void matrixAdd(const float *a, const float *b, float *c,
                           int width, int height) {
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;

    if (col < width && row < height) {
        int idx = row * width + col;   // Row-major linear index
        c[idx] = a[idx] + b[idx];
    }
}

int main(void) {
    const int WIDTH  = 1024;
    const int HEIGHT = 768;
    size_t bytes = WIDTH * HEIGHT * sizeof(float);

    // Allocate host memory
    float *h_a = (float *)malloc(bytes);
    float *h_b = (float *)malloc(bytes);
    float *h_c = (float *)malloc(bytes);

    // Initialise host arrays
    for (int i = 0; i < WIDTH * HEIGHT; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    // Allocate device memory
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes);
    cudaMalloc(&d_b, bytes);
    cudaMalloc(&d_c, bytes);

    // Copy data to device
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    // 2D launch configuration
    dim3 block(16, 16);  // 256 threads per block
    dim3 grid(
        (WIDTH  + block.x - 1) / block.x,   // ceil(1024 / 16) = 64
        (HEIGHT + block.y - 1) / block.y    // ceil(768  / 16) = 48
    );
    matrixAdd<<<grid, block>>>(d_a, d_b, d_c, WIDTH, HEIGHT);

    // Copy result back
    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

    // Verify
    int errors = 0;
    for (int i = 0; i < WIDTH * HEIGHT; i++) {
        if (h_c[i] != 3.0f) errors++;
    }
    printf("Matrix addition (%dx%d): %s (%d errors)\n",
           WIDTH, HEIGHT, errors == 0 ? "PASSED" : "FAILED", errors);

    // Free memory
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    free(h_a);
    free(h_b);
    free(h_c);

    return 0;
}
04

Choosing Launch Configuration

The launch configuration (<<<grid, block>>>) determines how many threads run and how they're organised. Choosing well is critical for performance.

Block Size Guidelines

Use 128 or 256

  • Must be a multiple of 32 (warp size)
  • 128 and 256 are the most common choices
  • 256 is the safe default for most kernels
  • 512 or 1024 can limit occupancy

Why Warp-Aligned?

  • GPU executes threads in groups of 32
  • Non-multiples waste lanes in the last warp
  • e.g., blockSize=100 uses 4 warps (128 lanes) — 28 wasted
  • blockSize=128 uses 4 warps — 0 wasted

Grid Size Calculation

The grid must have enough blocks to cover all elements. The standard ceiling-division formula is:

gridSize = (N + blockSize - 1) / blockSize
N (elements) blockSize gridSize (blocks) Total threads Excess threads
1,000,000 256 3,907 999,936 + 256 = 1,000,192 192
1024 256 4 1,024 0
1000 256 4 1,024 24
100 128 1 128 28

Boundary Checking Pattern

Because the total thread count often exceeds the data size, every kernel must guard against out-of-bounds access.

boundary_check.cu — Pattern for 1D and 2D
#include <stdio.h>
#include <cuda_runtime.h>

// 1D boundary check
__global__ void kernel1D(float *data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {              // Guard against out-of-bounds
        data[idx] *= 2.0f;
    }
}

// 2D boundary check
__global__ void kernel2D(float *data, int width, int height) {
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    if (col < width && row < height) {  // Guard both dimensions
        int idx = row * width + col;
        data[idx] *= 2.0f;
    }
}

int main(void) {
    // 1D example
    const int N = 1000;
    float *d_data;
    cudaMalloc(&d_data, N * sizeof(float));
    cudaMemset(d_data, 0, N * sizeof(float));

    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;
    kernel1D<<<gridSize, blockSize>>>(d_data, N);

    // 2D example
    const int W = 1024, H = 768;
    float *d_img;
    cudaMalloc(&d_img, W * H * sizeof(float));
    cudaMemset(d_img, 0, W * H * sizeof(float));

    dim3 block2d(16, 16);
    dim3 grid2d((W + 15) / 16, (H + 15) / 16);
    kernel2D<<<grid2d, block2d>>>(d_img, W, H);

    cudaFree(d_data);
    cudaFree(d_img);
    printf("Launch configurations executed successfully.\n");

    return 0;
}
Rule of Thumb

Start with 256 threads per block. If you need more shared memory per block, reduce to 128. Only increase to 512 or 1024 if profiling shows it helps. Always use the CUDA occupancy calculator for production code.

05

Warp Execution in Practice

The GPU doesn't execute individual threads — it executes warps of 32 threads in lockstep. Understanding warp behaviour is essential for writing fast CUDA code.

Warps Within a Block

A block of 256 threads is divided into 8 warps. Threads are assigned to warps by their linear index within the block:

Block of 256 threads = 8 warps
Warp 0:
T0
T1
...
T31
Warp 1:
T32
T33
...
T63
...
...
Warp 7:
T224
T225
...
T255

Block Boundaries

Warps never span block boundaries. If your block has 100 threads, the last warp (threads 96–127) will have 4 active threads and 28 idle lanes — wasted compute cycles.

Block 0: Warps 0–3
|
Block 1: Warps 0–3
|
Block 2: Warps 0–3

Warp Divergence

When threads in the same warp take different branches of an if/else, the warp must execute both paths serially, masking inactive threads. This is called warp divergence and it halves throughput in the worst case.

warp_divergence.cu — Divergent vs. non-divergent branching
#include <stdio.h>
#include <cuda_runtime.h>

// BAD: Divergent — threads within the same warp take different paths
__global__ void divergentKernel(float *data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        if (threadIdx.x % 2 == 0) {   // Even threads in warp go here
            data[idx] = data[idx] * 2.0f;
        } else {                      // Odd threads in warp go here
            data[idx] = data[idx] + 1.0f;
        }
    }
}

// GOOD: Non-divergent — entire warps take the same path
__global__ void nonDivergentKernel(float *data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        int warpId = threadIdx.x / 32;
        if (warpId % 2 == 0) {         // Even warps go here (all 32 threads)
            data[idx] = data[idx] * 2.0f;
        } else {                      // Odd warps go here (all 32 threads)
            data[idx] = data[idx] + 1.0f;
        }
    }
}

int main(void) {
    const int N = 1024;
    size_t bytes = N * sizeof(float);

    float *h_data = (float *)malloc(bytes);
    for (int i = 0; i < N; i++) h_data[i] = 1.0f;

    float *d_data;
    cudaMalloc(&d_data, bytes);
    cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;

    // Run divergent version
    divergentKernel<<<gridSize, blockSize>>>(d_data, N);
    cudaDeviceSynchronize();

    // Reset and run non-divergent version
    cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);
    nonDivergentKernel<<<gridSize, blockSize>>>(d_data, N);
    cudaDeviceSynchronize();

    printf("Warp divergence demo complete.\n");

    cudaFree(d_data);
    free(h_data);

    return 0;
}

Visualising Divergence

In the divergent case, threads 0, 2, 4... execute the if branch while threads 1, 3, 5... are masked — then vice versa. Both passes happen; no thread is skipped.

Warp 0 (threads 0–31) — divergent if (threadIdx.x % 2)
Pass 1 — if branch (even threads active):
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Pass 2 — else branch (odd threads active):
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Green = active   Red = masked (idle)
06

Occupancy Preview

Occupancy is the ratio of active warps to the maximum warps an SM can support. Higher occupancy helps hide memory latency — but maximum occupancy isn't always the goal.

What Limits Occupancy?

Block Size

Larger blocks = fewer blocks per SM. If blockSize=1024, only 1–2 blocks can fit on an SM, leaving less scheduling flexibility.

Registers

Each thread uses registers. If your kernel uses 64 registers/thread, a 256-thread block needs 16,384 registers — a quarter of a modern SM's 65,536-register file, so at most four such blocks fit.

Shared Memory

Shared memory is partitioned among blocks. If each block uses 32 KB and the SM has 96 KB, only 3 blocks can be resident.

Occupancy vs. Block Size

Block Size Warps/Block Max Blocks/SM Active Warps Occupancy (of 48)
64 2 16 32 67%
128 4 12 48 100%
256 8 6 48 100%
512 16 3 48 100%
1024 32 1 32 67%

Example for a GPU with max 48 warps/SM and max 16 blocks/SM. Register and shared memory constraints not shown — they can further reduce these numbers.

Coming in Tutorial 04

The memory hierarchy — global, shared, local, constant, and texture memory — is the single biggest factor in CUDA performance. Occupancy matters, but memory access patterns matter more. We'll cover this in depth next.

07

Exercises

Exercise 1: 2D Image Brightness Adjustment

Write a kernel that increases the brightness of each pixel by a given amount. Use a 2D grid to match the image dimensions. Clamp values to [0, 255].

brightness.cu — Complete compilable exercise
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>

__global__ void adjustBrightness(unsigned char *image,
                                  int width, int height,
                                  int adjustment) {
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;

    if (col < width && row < height) {
        int idx = row * width + col;
        int newVal = image[idx] + adjustment;

        // Clamp to [0, 255]
        if (newVal > 255) newVal = 255;
        if (newVal < 0)   newVal = 0;

        image[idx] = (unsigned char)newVal;
    }
}

int main(void) {
    const int WIDTH  = 1920;
    const int HEIGHT = 1080;
    const int BRIGHTNESS_INCREASE = 50;
    size_t bytes = WIDTH * HEIGHT * sizeof(unsigned char);

    // Create a test image (grayscale, all pixels at 100)
    unsigned char *h_image = (unsigned char *)malloc(bytes);
    for (int i = 0; i < WIDTH * HEIGHT; i++) {
        h_image[i] = 100;
    }

    // Allocate and copy to device
    unsigned char *d_image;
    cudaMalloc(&d_image, bytes);
    cudaMemcpy(d_image, h_image, bytes, cudaMemcpyHostToDevice);

    // Launch with 2D configuration
    dim3 block(16, 16);  // 256 threads per block
    dim3 grid(
        (WIDTH  + block.x - 1) / block.x,
        (HEIGHT + block.y - 1) / block.y
    );
    adjustBrightness<<<grid, block>>>(d_image, WIDTH, HEIGHT,
                                           BRIGHTNESS_INCREASE);

    // Copy back and verify
    cudaMemcpy(h_image, d_image, bytes, cudaMemcpyDeviceToHost);

    int errors = 0;
    for (int i = 0; i < WIDTH * HEIGHT; i++) {
        if (h_image[i] != 150) errors++;  // 100 + 50 = 150
    }
    printf("Brightness adjustment (%dx%d): %s (%d errors)\n",
           WIDTH, HEIGHT, errors == 0 ? "PASSED" : "FAILED", errors);

    cudaFree(d_image);
    free(h_image);

    return 0;
}

Exercise 2: Compare 1D vs 2D Launch Configurations

Process the same 1920×1080 image using both a 1D and 2D launch configuration. Verify both produce identical results. Think about which is more natural for image data.

compare_launch.cu — 1D vs 2D for the same problem
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <cuda_runtime.h>

// 1D kernel — treats the image as a flat array
__global__ void brighten1D(unsigned char *image, int totalPixels, int adj) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < totalPixels) {
        int val = image[idx] + adj;
        if (val > 255) val = 255;
        if (val < 0)   val = 0;
        image[idx] = (unsigned char)val;
    }
}

// 2D kernel — uses row/col indexing
__global__ void brighten2D(unsigned char *image, int w, int h, int adj) {
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    if (col < w && row < h) {
        int idx = row * w + col;
        int val = image[idx] + adj;
        if (val > 255) val = 255;
        if (val < 0)   val = 0;
        image[idx] = (unsigned char)val;
    }
}

int main(void) {
    const int W = 1920, H = 1080;
    const int TOTAL = W * H;
    const int ADJ = 30;
    size_t bytes = TOTAL * sizeof(unsigned char);

    // Host arrays
    unsigned char *h_orig    = (unsigned char *)malloc(bytes);
    unsigned char *h_result1 = (unsigned char *)malloc(bytes);
    unsigned char *h_result2 = (unsigned char *)malloc(bytes);

    for (int i = 0; i < TOTAL; i++) {
        h_orig[i] = (unsigned char)(i % 256);
    }

    unsigned char *d_image;
    cudaMalloc(&d_image, bytes);

    // --- 1D Launch ---
    cudaMemcpy(d_image, h_orig, bytes, cudaMemcpyHostToDevice);
    int blockSize1D = 256;
    int gridSize1D = (TOTAL + blockSize1D - 1) / blockSize1D;
    brighten1D<<<gridSize1D, blockSize1D>>>(d_image, TOTAL, ADJ);
    cudaMemcpy(h_result1, d_image, bytes, cudaMemcpyDeviceToHost);

    // --- 2D Launch ---
    cudaMemcpy(d_image, h_orig, bytes, cudaMemcpyHostToDevice);
    dim3 block2D(16, 16);
    dim3 grid2D((W + 15) / 16, (H + 15) / 16);
    brighten2D<<<grid2D, block2D>>>(d_image, W, H, ADJ);
    cudaMemcpy(h_result2, d_image, bytes, cudaMemcpyDeviceToHost);

    // Compare results
    int match = (memcmp(h_result1, h_result2, bytes) == 0);
    printf("1D vs 2D results: %s\n", match ? "MATCH" : "DIFFER");

    cudaFree(d_image);
    free(h_orig);
    free(h_result1);
    free(h_result2);

    return 0;
}
Challenge

Modify the brightness kernel to handle RGB images (3 channels per pixel). Each thread should process all 3 channels of a single pixel. How does this change your indexing?

08

Summary & Next Steps

What We Covered

Key Takeaways

Index = Block * BlockDim + Thread

This is the most important formula in CUDA. Every kernel starts with computing the global thread index from the built-in variables.

Think in Warps, Not Threads

The warp is the true unit of execution. Keep block sizes as multiples of 32. Avoid divergent branches within a warp.

Next Tutorial

Up Next — Tutorial 04

Memory Hierarchy — global, shared, local, constant, and texture memory. Learn how memory access patterns dominate CUDA performance, and how to use shared memory to achieve near-peak bandwidth.