CUDA & the Modern GPU Stack
Session 12 • 2311CSC501J — Parallel Processing
What You'll Learn
- The CUDA model: host, device, kernels, launch config
- The thread hierarchy: thread → block → grid
- Vector add, reduction (Lab 5), image processing (Lab 3)
- How modern AI is served: Kubernetes, TensorRT, CUDA-X
"The GPU has thousands of cores. CUDA is how you finally get to command all of them by hand."
— the payoff of Unit IV
The CUDA Model: Host and Device
CUDA is C with a few extras that let the CPU hand work to the GPU. Two worlds, connected by a bus:
Host = the CPU
Runs main(), owns normal RAM, orchestrates: allocates GPU memory, copies data, launches kernels.
Device = the GPU
Runs kernels across thousands of threads at once, owns its own separate memory. Great at data parallelism (Session 06).
A kernel is a function marked __global__ that runs on the GPU. You launch it with a special syntax that says how many threads to spawn:
saxpy<<< blocks, threadsPerBlock >>>(n, a, d_x, d_y);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the launch configuration
// "run this kernel on (blocks x threadsPerBlock) threads"
Thread → Block → Grid
Threads are organized in a three-level hierarchy. Every thread knows its own coordinates and uses them to find its slice of the data:
GRID (all the threads a kernel launches)
+-----------------------------------------------+
| BLOCK 0 BLOCK 1 BLOCK 2 |
| [t0 t1 t2 t3] [t0 t1 t2 t3] [t0 ...] | threadIdx.x = 0..3
+-----------------------------------------------+
blockIdx.x=0 blockIdx.x=1 blockIdx.x=2 blockDim.x = 4
global index of a thread:
i = blockIdx.x * blockDim.x + threadIdx.x
e.g. block 2, thread 1 -> 2 * 4 + 1 = 9
threadIdx
This thread's position inside its block.
blockIdx
Which block this thread belongs to.
blockDim
How many threads per block (the block's size).
The demo 04-cuda-threads.html lets you click a thread and watch this exact formula light up.
Memory: Two Separate Worlds
The GPU cannot read your normal C arrays. Host memory and device memory are separate. Moving data across the PCIe bus is the real cost of GPU programming — so you do it as little as possible.
Every CUDA program follows the same five steps
1. cudaMalloc(&d_x, bytes); // allocate on the DEVICE
2. cudaMemcpy(d_x, h_x, bytes, H2D); // copy inputs HOST -> DEVICE
3. kernel<<<blocks, threads>>>(d_x, ...); // launch: one thread per element
4. cudaMemcpy(h_y, d_y, bytes, D2H); // copy result DEVICE -> HOST
5. cudaFree(d_x); // free device memory
H2D = cudaMemcpyHostToDevice, D2H = cudaMemcpyDeviceToHost. Learn these five steps once and every kernel in this session is just step 3 getting cleverer.
Vector Add / SAXPY — CUDA's "Hello World"
y[i] = a*x[i] + y[i] for a million elements. Every element is independent, so we give one thread to one element — the CPU's loop disappears:
__global__ void saxpy(int n, float a, const float *x, float *y) {
// this thread's unique global index
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) { // bounds check (next slide)
y[i] = a * x[i] + y[i];
}
}
// launch: enough blocks of 256 threads to cover all n elements
int threads = 256;
int blocks = (n + threads - 1) / threads; // round UP
saxpy<<<blocks, threads>>>(n, 2.0f, d_x, d_y);
No for loop over the array. The loop is replaced by launching a million threads, each doing exactly one multiply-add. That is the SIMT model of Session 06 in code.
Worked Example: Which Thread Gets Which Element?
This one line is the whole of CUDA indexing, and it's where nearly every first bug lives. N = 1000, block size 256.
__global__ void vecAdd(float *a, float *b, float *c, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) // <-- do NOT skip this
c[i] = a[i] + b[i];
}
int blocks = (n + 255) / 256; // = (1000+255)/256 = 4 (round UP)
vecAdd<<<blocks, 256>>>(a, b, c, n); // launches 4 x 256 = 1024 threads
| blockIdx.x | threadIdx.x | i = block×256 + thread | What it does |
|---|---|---|---|
| 0 | 0 | 0 | c[0] = a[0]+b[0] |
| 0 | 255 | 255 | c[255] |
| 1 | 0 | 256 | c[256] |
| 2 | 100 | 612 | c[612] |
| 3 | 231 | 999 | c[999] — the last real element |
| 3 | 232 | 1000 | guard stops it — does nothing |
| 3 | 255 | 1023 | guard stops it — does nothing |
Delete the if (i < n) and 24 threads write to c[1000] through c[1023] — memory your array does not own. Sometimes you get a segfault. Often you silently corrupt whatever the allocator put next in GPU memory, and the wrong answer surfaces three kernels later somewhere unrelated.
You always launch more threads than you need, because the block size must divide evenly and your data size won't cooperate. The guard is not optional defensive style — it is part of the pattern.
Why (n + 255) / 256 and not n / 256? Integer division rounds down: 1000/256 = 3, which launches only 768 threads and silently ignores the last 232 elements. Adding 255 first forces it to round up. The general form is (n + blockSize - 1) / blockSize and you will write it a thousand times.
Notice what this arithmetic is. Every thread computes its own slice of the data from its own ID — the same rank × chunk arithmetic as MPI in Session 02, and the same thing OpenMP does for you invisibly. Three tools, three syntaxes, one idea: figure out who you are, then work out what's yours.
Why if (i < n) Matters
Threads come in whole blocks. If n = 1000 and a block holds 256 threads, you need 4 blocks — but 4 × 256 = 1024 threads for 1000 elements. The extra 24 must do nothing.
blocks = (1000 + 256 - 1) / 256 = 4 // ceiling division, rounds UP
threads launched = 4 * 256 = 1024
elements = 1000
^^^^^^^^ threads 1000..1023 run off the end!
if (i < n) // <- this skips them. Without it, they write
// past the array and corrupt GPU memory.
Without the check
Out-of-range threads read/write memory that isn't theirs — garbage results or a crash.
With the check
Extra threads quietly do nothing. Correct every time. It's one line — never skip it.
Watch: CUDA in a Nutshell
A fast, high-energy tour of what CUDA is and why the GPU changed computing. Great warm-up before the code.
CUDA (2007) opened the GPU — built for graphics — to general-purpose parallel computing. Every AI model you use today was trained through this door.
Inside the GPU: Three Kinds of Memory
Getting speed out of a GPU is mostly about which memory you use. Closer = faster = smaller.
| Memory | Shared by | Speed | Use it for |
|---|---|---|---|
| Registers | one thread | fastest | a thread's own local variables |
| Shared | all threads in a block | ~100× faster than global | threads in a block cooperating (reduction!) |
| Global | every thread + the host | slowest (but huge) | the big input/output arrays (cudaMalloc) |
The reduction on the next slide loads data once from slow global memory into fast shared memory, does all its back-and-forth there, and writes back once. That's the whole trick.
Worked Example: Coalescing Is Worth 30×
A warp's 32 threads issue their memory request at the same instant. If those 32 addresses are consecutive, the hardware fetches them in one transaction. If they're scattered, it needs 32.
Coalesced
c[i] = a[i] * 2;
warp reads a[0..31]
= 128 consecutive bytes
= ONE transaction
100% of it used
~2,800 GB/s
Strided
c[i] = a[i*32] * 2;
warp reads a[0], a[32],
a[64] ... a[992]
= 32 transactions
3% of each one used
~90 GB/s
Same instruction count. Same number of elements. 31× the runtime. The strided version fetches 128 bytes to use 4 of them, and throws the rest away — 32 times per warp.
And here's the twist that catches every CPU programmer. Summing the rows of a row-major matrix:
// thread per ROW — feels natural
for (j=0;j<N;j++)
sum += a[tid*N + j];
Slow on a GPU. At any instant thread 0 reads a[0], thread 1 reads a[N], thread 2 reads a[2N] — scattered by N.
// thread per COLUMN
for (j=0;j<N;j++)
sum += a[j*N + tid];
Fast on a GPU. At any instant threads 0–31 read a[0..31] — consecutive. One transaction.
On a CPU (Session 05) you wanted one thread walking along a row. On a GPU you want the opposite — because the unit that must be contiguous is not "what one thread reads over time," it's "what 32 threads read at the same moment."
The rule, in one sentence: thread i should touch element i. Whenever you find yourself writing a[i * something] in a kernel, stop — you are probably about to lose an order of magnitude.
Together with warp divergence (Session 06), this is most of what separates a GPU kernel that hits 3% of peak from one that hits 90%. Neither is about the arithmetic.
Lab 5 — Parallel Reduction on the GPU
"Sum a million numbers into one" is not embarrassingly parallel — the threads must cooperate. The classic answer is the tree reduction: halve the active threads each step.
step 0: [3][1][7][0][4][1][6][3] 8 values, 4 threads active
step 1: [7][2][13][3] . . . . add partner, halve (stride 4)
step 2: [20][5] . . . . . . . halve again (stride 2)
step 3: [25] . . . . . . . . one value = block's sum (stride 1)
for (int stride = blockDim.x/2; stride > 0; stride >>= 1) {
if (tid < stride) sdata[tid] += sdata[tid + stride];
__syncthreads(); // all threads finish this level first
}
A block of 256 finishes in log₂(256) = 8 steps, not 256. __syncthreads() is the barrier that keeps every thread in step. Full code in 02-reduction.cu.
Worked Example: Trace the Reduction Tree
Sum [3, 1, 7, 0, 4, 1, 6, 3] — total 25 — with 8 threads. Serially that's 7 additions in 7 steps. In parallel it's 7 additions in 3.
for (s = blockDim.x/2; s > 0; s >>= 1) {
if (tid < s) data[tid] += data[tid + s];
__syncthreads(); // everyone finishes the step
}
start [ 3 1 7 0 | 4 1 6 3 ]
| | | |
s=4 t0: 3+4 = 7 | | | |
t1: 1+1 = 2 <---+ | | |
t2: 7+6 = 13 <------+---+ |
t3: 0+3 = 3 <--------------+
[ 7 2 13 3 | . . . . ] 4 threads active
s=2 t0: 7 + 13 = 20
t1: 2 + 3 = 5
[20 5 | . . | . . . . ] 2 threads active
s=1 t0: 20 + 5 = 25
[25 | . | . . | . . . . ] 1 thread active
DONE in 3 = log2(8) steps.
The work didn't change — the depth did. Seven additions either way. In Session 07's language: the work T₁ is 7 in both cases; the span T∞ dropped from 7 to 3.
Scale it up: summing one million numbers is 1,000,000 sequential steps — or 20. That's why reduction is the second thing anyone learns on a GPU, and why it hides inside every sum, max, dot product, loss function and normalisation you will ever run.
Now the version most textbooks show — and why it is 2× slower.
// the "obvious" version
for (s=1; s<blockDim.x; s*=2) {
if (tid % (2*s) == 0)
data[tid] += data[tid+s];
__syncthreads();
}
Step 1's active threads: 0, 2, 4, 6, 8… — every other thread. Every warp is half-on, half-off, so every warp diverges, every step.
// the version above
for (s=blockDim.x/2; s>0; s>>=1) {
if (tid < s)
data[tid] += data[tid+s];
__syncthreads();
}
Active threads: 0, 1, 2, 3… — contiguous. Whole warps switch off cleanly instead of half-diverging, and the reads stay coalesced.
Identical results. Identical additions. Roughly twice the speed. The only difference is which threads are the active ones — Session 06's warp divergence, arriving with a bill attached.
Don't drop __syncthreads(). Without it a fast thread reaches step s=2 and reads data[2] before the thread responsible for it finished step s=4. Same bug as a missing OpenMP barrier, same symptom: correct on small inputs, wrong at scale.
One caveat for real code: __syncthreads() synchronises a block, not the whole grid. Reducing across blocks needs a second kernel launch — which is exactly what Lab 5 does.
Lab 3 — Image Processing: One Thread per Pixel
Every output pixel is computed the same way and is independent — the ultimate data-parallel shard. A 512×512 image becomes 262,144 threads. Because images are 2D, we use a 2D grid:
__global__ void to_grayscale(const unsigned char *rgb,
unsigned char *gray, int w, int h) {
int col = blockIdx.x * blockDim.x + threadIdx.x; // x direction
int row = blockIdx.y * blockDim.y + threadIdx.y; // y direction
if (col < w && row < h) {
int p = row * w + col;
gray[p] = 0.299f*rgb[p*3] + 0.587f*rgb[p*3+1] + 0.114f*rgb[p*3+2];
}
}
dim3 threads(16, 16); // 16x16 = 256 threads per block
dim3 blocks((w+15)/16, (h+15)/16); // enough blocks to cover the image
Same index math as vector add — just an x and a y. This is why GPUs dominate graphics, video, and computer vision. Full code in 03-image-grayscale.cu.
The Modern GPU Stack: From Kernel to Data Center
You just wrote a kernel by hand. In production, layers on top of CUDA turn one GPU into thousands serving AI at scale:
Kubernetes
Schedules containerized GPU workloads across a cluster — which job runs on which GPU, batch training, autoscaling. The orchestration layer.
TensorRT
NVIDIA's inference optimizer: takes a trained model and makes it small and fast to serve (fuses layers, lowers precision). "Make the model cheap to run."
CUDA-X AI
The library stack you build on instead of raw kernels: cuDNN (deep learning), cuBLAS (linear algebra), RAPIDS (data science).
Almost nobody hand-writes reduction kernels in production — they call cuBLAS/cuDNN, which are hand-tuned versions of exactly what you just learned. Knowing the kernel underneath is what makes you able to reason about the whole stack.
Unit IV Wrap-Up: Three Tools, One Idea
Unit IV gave you the three programming models that run essentially all parallel code today:
| Tool | Runs on | Unit of work | Use when |
|---|---|---|---|
| OpenMP (S10) | one shared-memory machine | threads | "use all the cores on this box" |
| MPI (S11) | many machines / a cluster | processes (ranks) | "scale past one box" |
| CUDA (S12) | an NVIDIA GPU | thousands of GPU threads | "massive, regular data parallelism" |
Different syntax, one idea: split the work, run the pieces at once, combine the results. Real systems mix all three (hybrid MPI + OpenMP + CUDA on a GPU cluster).
Recap & What's Next
Key Takeaways
- Host vs device: the CPU orchestrates, the GPU runs kernels across thousands of threads.
- Thread → block → grid: every thread finds its data with
i = blockIdx.x*blockDim.x + threadIdx.x, guarded byif (i < n). - Five steps: malloc → copy up → launch → copy down → free. Data movement is the cost.
- Reduction (Lab 5) cooperates via shared memory; image processing (Lab 3) is one thread per pixel.
- The production stack — Kubernetes, TensorRT, CUDA-X — is built on exactly these kernels.
That completes Unit IV. You can now write real parallel code in all three models — shared memory (OpenMP), distributed (MPI), and the GPU (CUDA). The hard part is behind you.
Next: Unit V — Applications of Parallel Computing
Where all this power actually gets used: scientific computing, parallel databases, Big Data, and the frontier.