Below the API

CUDA Programming Fundamentals

Intermediate Advanced 2h Difficulty 4/5

Prerequisites 02, 03

You will rarely write production CUDA. You should write enough to read it fluently, to understand what your engine is doing, and to write a custom fused op when nothing else fits.


1. What is it?#

Writing functions that run on the GPU, in C++ with CUDA extensions (or in Triton, which is easier and covered in Section XIII.11).


2. Why learn it?#

Three concrete reasons:

  1. To read kernels. vLLM’s csrc/, FlashAttention’s implementation, CUTLASS — the source of truth for how things actually work.
  2. To write fused ops when your architecture has something the library doesn’t cover.
  3. To interpret profiles. Nsight’s output is meaningless without knowing what a kernel does.

3. Your first kernel#

// vecadd.cu
#include <cstdio>

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

int main() {
    int n = 1 << 20;
    size_t bytes = n * sizeof(float);

    float *h_a = (float*)malloc(bytes), *h_b = (float*)malloc(bytes),
          *h_c = (float*)malloc(bytes);
    for (int i = 0; i < n; i++) { h_a[i] = i; h_b[i] = 2*i; }

    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes); cudaMalloc(&d_b, bytes); cudaMalloc(&d_c, bytes);
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    int threads = 256, blocks = (n + threads - 1) / threads;
    vecadd<<<blocks, threads>>>(d_a, d_b, d_c, n);
    cudaDeviceSynchronize();

    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);
    printf("c[42] = %f (expect %f)\n", h_c[42], 3.0f*42);

    cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
    free(h_a); free(h_b); free(h_c);
}
nvcc -O3 -arch=sm_80 vecadd.cu -o vecadd && ./vecadd

Always check errors. The single most common CUDA beginner mistake is ignoring return codes, because errors are asynchronous and surface much later at a confusing place:

#define CUDA_CHECK(x) do { \
    cudaError_t e = (x); \
    if (e != cudaSuccess) { \
        fprintf(stderr, "CUDA error %s at %s:%d\n", cudaGetErrorString(e), __FILE__, __LINE__); \
        exit(1); } } while(0)

CUDA_CHECK(cudaMalloc(&d_a, bytes));
vecadd<<<blocks, threads>>>(d_a, d_b, d_c, n);
CUDA_CHECK(cudaGetLastError());        // catches launch errors
CUDA_CHECK(cudaDeviceSynchronize());   // catches execution errors

4. The essential language features#

__global__   runs on GPU, called from host. Must return void.
__device__   runs on GPU, called from GPU.
__host__     runs on CPU (default).
__shared__   shared memory, block-scoped
__constant__ read-only, broadcast-optimized (64 KB)
__restrict__ promises no aliasing — enables optimization

Built-in variables:
  threadIdx.{x,y,z}   thread index within the block
  blockIdx.{x,y,z}    block index within the grid
  blockDim.{x,y,z}    block size
  gridDim.{x,y,z}     grid size

Synchronization:
  __syncthreads()             barrier for all threads in the block
  __syncwarp(mask)            barrier within a warp
  __threadfence()             memory ordering

Warp primitives (very fast — data is already co-located):
  __shfl_sync(mask, val, srcLane)
  __shfl_down_sync(mask, val, delta)
  __ballot_sync(mask, pred)
  __any_sync / __all_sync
  __reduce_add_sync (Ampere+)

Atomics:
  atomicAdd, atomicMax, atomicCAS, ...

5. Kernel 2: a reduction (the fundamental pattern)#

Summing an array teaches warp primitives, shared memory, and the two-level reduction pattern that appears in every norm, softmax, and attention kernel.

__inline__ __device__ float warpReduceSum(float val) {
    for (int offset = 16; offset > 0; offset /= 2)
        val += __shfl_down_sync(0xffffffff, val, offset);
    return val;                       // lane 0 has the warp's sum
}

__inline__ __device__ float blockReduceSum(float val) {
    static __shared__ float shared[32];      // one slot per warp
    int lane = threadIdx.x % 32;
    int wid  = threadIdx.x / 32;

    val = warpReduceSum(val);                 // reduce within each warp
    if (lane == 0) shared[wid] = val;         // one value per warp to shared
    __syncthreads();

    // first warp reduces the per-warp partial sums
    val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;
    if (wid == 0) val = warpReduceSum(val);
    return val;                               // thread 0 has the block's sum
}

__global__ void reduce(const float* in, float* out, int n) {
    float sum = 0.0f;
    // grid-stride loop: handles any n with any grid size
    for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n;
         i += blockDim.x * gridDim.x)
        sum += in[i];
    sum = blockReduceSum(sum);
    if (threadIdx.x == 0) atomicAdd(out, sum);
}

Three patterns to internalize here:

  • Grid-stride loop — decouples grid size from problem size; the same kernel works for any n.
  • Warp shuffle reduction — no shared memory, no barriers, ~5 instructions.
  • Two-level reduction — warp then block. Every norm and softmax kernel does this.

6. Kernel 3: tiled matmul#

The canonical shared-memory kernel.

#define TILE 32

__global__ void matmul(const float* A, const float* B, float* C, int M, int N, int K) {
    __shared__ float As[TILE][TILE];
    __shared__ float Bs[TILE][TILE + 1];      // +1 to avoid bank conflicts

    int row = blockIdx.y * TILE + threadIdx.y;
    int col = blockIdx.x * TILE + threadIdx.x;
    float acc = 0.0f;

    for (int t = 0; t < (K + TILE - 1) / TILE; t++) {
        // cooperatively load one tile of A and one of B
        int aCol = t * TILE + threadIdx.x;
        int bRow = t * TILE + threadIdx.y;
        As[threadIdx.y][threadIdx.x] = (row < M && aCol < K) ? A[row*K + aCol] : 0.0f;
        Bs[threadIdx.y][threadIdx.x] = (bRow < K && col < N) ? B[bRow*N + col] : 0.0f;
        __syncthreads();

        #pragma unroll
        for (int k = 0; k < TILE; k++)
            acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        __syncthreads();                       // before overwriting the tiles
    }
    if (row < M && col < N) C[row*N + col] = acc;
}

This achieves maybe 20-30% of cuBLAS. The gap comes from: no tensor cores, no double buffering, one output element per thread (should be 4-8), no vectorized loads, no async copy. Closing that gap is a multi-week project — which is why you use cuBLAS.

But writing this once teaches you what every GEMM kernel is doing, which is the point.


7. Integrating with PyTorch#

// my_op.cu
#include <torch/extension.h>

__global__ void scale_kernel(float* x, float s, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) x[i] *= s;
}

void scale(torch::Tensor x, float s) {
    TORCH_CHECK(x.is_cuda(), "x must be on CUDA");
    TORCH_CHECK(x.is_contiguous(), "x must be contiguous");
    int n = x.numel();
    int threads = 256, blocks = (n + threads - 1) / threads;
    scale_kernel<<<blocks, threads>>>(x.data_ptr<float>(), s, n);
    C10_CUDA_KERNEL_LAUNCH_CHECK();
}

PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { m.def("scale", &scale); }
from torch.utils.cpp_extension import load
mod = load(name="my_op", sources=["my_op.cu"], verbose=True)
x = torch.ones(1000, device='cuda')
mod.scale(x, 3.0)

torch.utils.cpp_extension.load compiles on the fly — good for development. For production, use setup.py with CUDAExtension and ship a built wheel.


8. Debugging#

# Memory errors (out of bounds, misaligned, leaks)
compute-sanitizer --tool memcheck ./myapp

# Race conditions on shared memory
compute-sanitizer --tool racecheck ./myapp

# Uninitialized memory
compute-sanitizer --tool initcheck ./myapp

# Synchronization errors
compute-sanitizer --tool synccheck ./myapp

# printf works in kernels (slow, but works)
printf("thread %d: val=%f\n", threadIdx.x, val);

# Debug build with device-side debugging
nvcc -g -G ...     # then cuda-gdb

compute-sanitizer is your best friend. Run it on any new kernel. Out-of-bounds writes on a GPU corrupt memory silently and manifest as wrong answers somewhere else entirely.


9. Common mistakes#

No bounds check. if (i < n) — always.

Forgetting __syncthreads() after writing shared memory. Intermittent wrong answers.

__syncthreads() inside divergent code. Undefined behavior.

Ignoring error codes. Errors surface later, confusingly.

Assuming threads in a block execute simultaneously. They’re warps, scheduled independently.

Bank conflicts from unpadded [32][32] shared arrays.

Integer overflow in index arithmetic. blockIdx.x * blockDim.x can overflow int for large grids; use size_t or int64_t.

Race on atomicAdd initialization. Zero the output before launching.


10. Hands-on exercise#

A. Vector add. Write, compile, run, and verify. Sweep block size and plot achieved bandwidth. What fraction of peak do you get? This is Project 03.

B. Reduction. Implement the two-level reduction. Verify against torch.sum. Benchmark against it. How close do you get?

C. Tiled matmul. Implement it. Verify correctness. Benchmark against cuBLAS for N=1024, 2048, 4096. Compute your achieved TFLOP/s and the fraction of peak.

D. Fused RMSNorm. Write a fused RMSNorm kernel (reduction + scale) and integrate it with PyTorch. Benchmark against the unfused PyTorch version. This is a genuinely useful kernel.

E. Sanitize. Deliberately introduce an out-of-bounds write and confirm compute-sanitizer catches it. Then introduce a shared-memory race and catch it with racecheck.

F. Read real code. Read vllm/csrc/layernorm_kernels.cu and activation_kernels.cu. They’re short. Identify: the grid-stride loop, the reduction, the vectorized loads.


11. Interview questions#

  1. Write a vector-add kernel from memory, including the bounds check.
  2. Explain the two-level reduction pattern and why warp shuffles are used.
  3. What is a grid-stride loop and why use it?
  4. Why does __shared__ float tile[32][33] have a 33?
  5. Why must all threads in a block reach __syncthreads()?
  6. How would you debug a kernel producing wrong answers intermittently?
  7. Why does a hand-written tiled matmul only reach 30% of cuBLAS?

12. Further reading#

  • [FUNDAMENTAL] Kirk & Hwu, Programming Massively Parallel Processors (4th ed.)
  • [REFERENCE] CUDA C++ Programming Guide and Best Practices Guide
  • [REFERENCE] NVIDIA cuda-samples repository
  • [ESTABLISHED] “How to Optimize a CUDA Matmul Kernel” (siboehm) — an excellent step-by-step walkthrough from naive to near-cuBLAS
  • Next: 09 — Coalescing and access patterns

↑↓ navigate ↵ open