Table of Contents

CUDA

CUDA is NVIDIA's platform for running general-purpose code on GPUs. You write a function called a kernel, and the GPU runs it in thousands of threads at once. It consists of a language extension to C and C++ (compiled with nvcc), a runtime library, and a driver. Where OpenMP parallelises across CPU cores that already share your address space, and MPI runs separate processes with separate memory, CUDA targets a separate device with its own memory. You have to move data there and back explicitly.

The execution model is SIMT (single instruction, multiple threads). A kernel launch creates a grid of thread blocks, and each block contains up to 1024 threads. Threads are scheduled in groups of 32 called warps, and every thread in a warp executes the same instruction at the same time. Unlike OpenMP, where the runtime picks the thread count for you, the launch geometry is yours to choose on every call.

__global__ void hello(void) {
    printf("block %d thread %d\n", blockIdx.x, threadIdx.x);
}
 
int main(void) {
    hello<<<2, 4>>>();       // 2 blocks of 4 threads = 8 threads total
    cudaDeviceSynchronize(); // wait for the GPU before exiting
    return 0;
}

The <<<blocks, threads>>> syntax is the kernel launch. Inside the kernel, blockIdx, blockDim, and threadIdx tell each thread which piece of the work it owns. The usual pattern is int i = blockIdx.x * blockDim.x + threadIdx.x, which flattens the hierarchy back into a single global index. Compile with nvcc -o prog prog.cu.

A kernel launch is asynchronous. Control returns to the CPU immediately, before the GPU has run anything, which is why the cudaDeviceSynchronize() above is needed for the printf output to appear. This is also the source of the most common beginner bug: a kernel that fails to launch reports no error at the launch site, so a broken program can exit with status 0 and no output at all. See Error handling (CUDA).

A GPU is not faster at any individual operation. It wins by having enormous latency-hiding capacity, so it needs enough parallel work to keep thousands of threads resident. Below that threshold the CPU is usually faster, and for small problems the PCIe transfer alone can cost more than the whole computation. Whether a kernel is limited by arithmetic or by memory bandwidth is the question the Roofline model exists to answer, and on GPUs the answer is almost always memory.

Practice

Compile the hello-world above and run it:

$ nvcc -o hello hello.cu
$ ./hello
block 1 thread 0
block 1 thread 1
block 1 thread 2
block 1 thread 3
block 0 thread 0
block 0 thread 1
block 0 thread 2
block 0 thread 3

Blocks are not ordered. Nothing guarantees block 0 runs before block 1, and nothing guarantees they run at the same time either. The GPU schedules blocks onto its streaming multiprocessors in whatever order it likes, which is exactly why blocks cannot communicate with each other during a kernel.

Now delete the cudaDeviceSynchronize() line and rebuild:

$ nvcc -o hello hello.cu
$ ./hello
$

No output, and exit status 0. The process ended before the GPU finished. This is the CUDA equivalent of dropping -fopenmp, except that it fails silently instead of falling back to something correct. When a CUDA program produces nothing, check synchronisation and check cudaGetLastError() before assuming the kernel logic is wrong.

Here is a real example: SAXPY, the level-1 BLAS operation $y = \alpha x + y$, over 16 million elements.

// compile: nvcc -O2 -o saxpy saxpy.cu
// run: ./saxpy
// description: y = a*x + y on the GPU, timed with CUDA events
 
#include <stdio.h>
 
__global__ void saxpy(int n, float a, const float *x, float *y) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n)
        y[i] = a * x[i] + y[i];
}
 
int main(void) {
    int n = 1 << 24;
    size_t bytes = n * sizeof(float);
 
    float *hx = (float *)malloc(bytes);
    float *hy = (float *)malloc(bytes);
    for (int i = 0; i < n; i++) { hx[i] = 1.0f; hy[i] = 2.0f; }
 
    float *dx, *dy;
    cudaMalloc(&dx, bytes);
    cudaMalloc(&dy, bytes);
    cudaMemcpy(dx, hx, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(dy, hy, bytes, cudaMemcpyHostToDevice);
 
    int threads = 256;
    int blocks = (n + threads - 1) / threads;
 
    cudaEvent_t start, stop;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);
 
    cudaEventRecord(start);
    saxpy<<<blocks, threads>>>(n, 2.0f, dx, dy);
    cudaEventRecord(stop);
 
    cudaMemcpy(hy, dy, bytes, cudaMemcpyDeviceToHost);
    cudaEventSynchronize(stop);
 
    float ms = 0.0f;
    cudaEventElapsedTime(&ms, start, stop);
    printf("y[0]=%.1f  kernel=%.3f ms  %.0f GB/s\n",
           hy[0], ms, 3.0 * bytes / ms / 1e6);
 
    cudaFree(dx);
    cudaFree(dy);
    free(hx);
    free(hy);
    return 0;
}

The blocks calculation rounds up, so the last block is partly idle and the if (i < n) guard stops those threads from writing past the end. This guard is boilerplate in almost every CUDA kernel.

The kernel touches three arrays, reading x, reading y, and writing y, which is where the factor of 3 in the bandwidth figure comes from. Compare the number you get against your card's spec sheet. SAXPY does one multiply and one add per 12 bytes moved, so it is entirely memory-bound and should land close to peak bandwidth. If it does not, the access pattern is the first thing to look at (Coalescing (CUDA)).

Two things worth trying. Vary threads from 32 up to 1024 and watch how little it matters once you are past a couple of hundred, since the kernel is bandwidth-bound rather than occupancy-bound. Then move the cudaEventRecord calls outside the cudaMemcpy calls so the transfers are included in the timing. For a kernel this cheap, the transfers dominate completely, and the GPU version loses to a plain CPU loop.

Concepts

Overview

Qualifiers and launch syntax

__global__ void f(...)          // kernel: called from host, runs on device
__device__ int  g(...)          // device function: callable only from device code
__host__   int  h(...)          // host function (the default)
__shared__ float tile[256];     // per-block scratchpad, shared by threads in a block
__constant__ float coeff[16];   // read-only, broadcast-optimised device memory
 
f<<<blocks, threads>>>(...)                  // basic launch
f<<<blocks, threads, shmem_bytes>>>(...)     // with dynamic shared memory
f<<<blocks, threads, shmem_bytes, stream>>>  // on a specific stream
 
threadIdx.x   // thread index within its block
blockIdx.x    // block index within the grid
blockDim.x    // threads per block
gridDim.x     // blocks per grid
 
__syncthreads()   // barrier across all threads in a block

Runtime API

cudaMalloc(&ptr, bytes)              // allocate device memory
cudaMallocManaged(&ptr, bytes)       // allocate unified memory, migrated on demand
cudaFree(ptr)                        // release device memory
cudaMemcpy(dst, src, bytes, kind)    // copy; kind is cudaMemcpyHostToDevice etc.
cudaMemcpyAsync(...)                 // same, queued on a stream
 
cudaDeviceSynchronize()              // block the host until the device is idle
cudaStreamCreate(&stream)            // create a stream for concurrent work
cudaStreamSynchronize(stream)        // block until one stream drains
 
cudaEventCreate(&ev)                 // create a timing event
cudaEventRecord(ev, stream)          // mark a point in the stream
cudaEventElapsedTime(&ms, a, b)      // milliseconds between two recorded events
 
cudaGetLastError()                   // fetch and clear the last error
cudaGetErrorString(err)              // human-readable message for an error code
cudaGetDeviceProperties(&prop, dev)  // query core count, bandwidth, limits

Environment and tooling

Name Kind Description
CUDA_VISIBLE_DEVICES env Restrict which GPUs the process can see, e.g. 0,2
CUDA_LAUNCH_BLOCKING env Set to 1 to make launches synchronous, so errors surface at the launch site
-arch=sm_XX nvcc Target a specific compute capability instead of the default
--ptxas-options=-v nvcc Print per-kernel register and shared memory usage
-lineinfo nvcc Emit line numbers for profiler and sanitiser output
nvidia-smi tool Device inventory, utilisation, memory use, running processes
ncu tool Nsight Compute: per-kernel counters, occupancy, bottleneck analysis
nsys tool Nsight Systems: timeline of kernels, transfers, and host activity
compute-sanitizer tool Race and out-of-bounds checker, the CUDA equivalent of Valgrind