← All workbooks

DD2360 · HT26 · complete self-study workbook

Applied GPU Programming

24 modules covering the whole course, from GPU architecture to Tensor Cores, written to replace the lectures rather than summarize them. Every idea is built from the ground up, so you can learn it alone. Plan about 90 minutes per module.

1 · ReadThe concept sections, in order. Each builds on the one before.
2 · FollowThe worked example with a pen, before looking at each step's answer.
3 · AnswerExercises out loud before opening. Recall Explain Apply
4 · RunThe lab on Colab. Predict first, then compare with the solution.
Read this first

The route to an A

From the course's grading table (Logistics, slide 11): an A needs all three of (1) a pass on all 4 assignments, (2) all 3 bonus exercises, and (3) an A on the final project. Every other combination caps you lower: missing one bonus gives at most C, and a B project gives a B.

RequirementWhat it takesWhat decides itWorkbook support
Pass all 4 assignmentsDue Nov 6, Nov 20, Dec 4, Dec 18. No late submissions unless approved in advance.Correct code, and answers you can defend without AIModules 5–9 (A1); Modules 12–15 (A2); 16–19 (A3); 20–23 (A4)
All 3 bonus exercisesSubmitted inside Assignments 2, 3 and 4, then one oral examination that you sign up forWhether you can explain your bonus code liveModule 11 and 24 drills; Module 15 prepares bonus 1 (iPIC3D mover)
Project grade AProposal Nov 13, report Jan 5, presentation Jan 7 or 8 (optional for others, required for A/B). Your individual llm.c project is approved.Depth of analysis on ≥ 3 topics, measured and explainedModules 14 (tiling), 20 (Tensor Cores), 21 (BF16), 22 (cuBLAS); defence drill in 24

Timeline

  1. Assignment 1 due · TA help session
  2. Project proposal due · cluster access request
  3. Assignment 2 (with bonus 1) due
  4. TA help session
  5. Assignment 3 (with bonus 2) due · TA help session
  6. Assignment 4 (with bonus 3) due · sign up for the oral exam
  7. Final project report due
  8. Project presentation

Dates are from the Logistics slides ("tentative"). Check Canvas announcements for changes.

What an A answer looks like

The assignments are pass/fail, so the grade is decided by the oral exam and the project. Both reward the same habit: every claim is measured, and every measurement is explained with a model.

  • C-level: "float was faster than double."
  • A-level (illustrative numbers): "Float was 9.6× faster on the T4. Copies halved because there are half the bytes, and the kernel sped up about 14× because Turing's FP64 rate is 1/32 of FP32. It didn't reach 32× because the naive kernel is memory-bound (AI ≈ 0.25), which ncu's SpeedOfLight confirms at 85% DRAM throughput."

The second answer uses Modules 3, 4 and 8 together. That is what the Apply exercises in every module train.

This week's checklist

Your hardware

Your GPU: RTX 3050 6GB Laptop

Ampere architecture, compute capability 8.6. Compile with -arch=sm_86. The workbook's worked examples use the Colab T4; use this table to redo them for your own GPU. Laptop GPUs ship with different power limits and clocks, so the starred values are approximate: confirm them with the Module 3 device-query lab and fill in your own.

PropertyYour RTX 3050 LaptopColab T4Cluster H100 (full)What it changes
Compute capability8.6 (sm_86)7.59.0Compile flag
SMs × FP32 cores/SM20 × 128 = 2,56040 × 64132 × 128Half the SMs of a T4: fewer blocks fill it (Modules 2, 8)
Max warps / SM48 (1,536 threads)3264Redo Module 8's occupancy calculations with 48
Max blocks / SM161632
Shared memoryup to 100 KB/SM, 99 KB/block opt-in64 KB228 KBLarger tiles possible (Module 14)
Memory6 GB GDDR6, 96-bit, ≈ 168 GB/s*16 GB, 320 GB/s80 GB, 3.35 TB/sMemory-bound kernels about half T4 speed
L2 cache2 MB*4 MB50 MB
FP32 peak≈ 5–8 TFLOP/s* (depends on clock)8.167Compute it: 2,560 × 2 × your boost clock
FP64 rate1/64 of FP321/321/2Double precision is very slow: A1 Q2 float vs double gap is larger than on a T4
Tensor Cores3rd gen: FP16, BF16, TF32, INT8, 2:4 sparsityFP16, INT8 (no BF16)+ FP8BF16 project work can be developed locally (Modules 20–21)
Machine balance≈ 30–45 FLOP/byte*≈ 25≈ 20Even more kernels are memory-bound (Module 4)

Memory budget (6 GB, minus what the desktop uses)

  • Assignments 1–2: fine. The largest A1 matmul in double needs about 0.67 GB.
  • QuEST at 28 qubits: needs 4 GiB for the state vector, which is tight. Log out of the graphical session or close browsers, and check nvidia-smi for free memory first. If it fails, run 28 qubits on Colab or the cluster and say so in the report.
  • llm.c GPT-2 124M training: about 2 GB for FP32 weights, gradients and Adam state, plus activations. Start with -b 4 -t 1024 (batch 4, sequence 1024) and lower -b if you run out of memory. BF16 roughly halves activation memory.

Fedora setup checklist

Getting trustworthy timings on a laptop

  • Plug in the charger and choose the performance power profile (powerprofilesctl set performance). On battery, the GPU clocks down sharply.
  • Warm up (run once untimed), then take the median of 5–10 runs. Watch for thermal throttling with nvidia-smi -q -d PERFORMANCE,CLOCK.
  • On hybrid-graphics laptops, keep the display on the integrated GPU so the NVIDIA GPU's memory and time are all yours.
  • In every report, name the GPU, driver, CUDA version and power mode. For the final project numbers, prefer the cluster for stability, and use the laptop for development.
Module 1 · Lecture 1.1, 1.2 · Book §1.1

Two ways to build a processor

Why CPUs and GPUs are designed so differently, and why real programs use both.

  • After this module you can: define latency and throughput, explain where a CPU and a GPU spend their transistors, and decide which processor fits a given workload.

1.1 Two measures of speed

Picture moving people across a city. A sports car takes one passenger in 10 minutes. A bus takes 60 passengers in 30 minutes. Which is faster? It depends on what you measure:

  • Latency is the time to finish one task. The car wins (10 vs 30 minutes).
  • Throughput is the number of tasks finished per unit of time. The bus wins: 2 passengers/minute against 0.1.

Processors face the same trade-off. A chip has a fixed budget of transistors, power and area. You can spend it on making each instruction stream finish quickly, or on running as many streams as possible at once. You cannot maximize both.

1.2 The CPU: built for latency

A CPU runs operating systems, browsers and compilers: code with lots of branches, pointer chasing and dependencies, where step n+1 needs the result of step n. For that code, the only way to go fast is to make one thread fast. So a CPU core spends most of its area on things that are not arithmetic:

  • Large caches (often tens of MB) so that most memory accesses never go to slow DRAM.
  • Branch prediction to guess which way an if will go and start executing before it is known.
  • Out-of-order execution to find independent instructions further ahead and run them while waiting on others.
  • High clock speed (4–5 GHz) and a handful of powerful cores (8–64).

1.3 The GPU: built for throughput

A GPU was first built for graphics: compute the colour of millions of pixels, each independently, 60 times per second. Nobody cares how long one pixel takes, only that all of them are done in 16 ms. So a GPU spends its area the opposite way:

  • Thousands of simple arithmetic units (2,560 on a T4, 16,896 on an H100).
  • Tiny control logic: one instruction decoder drives 32 threads at once (Module 2), and there is no branch prediction or out-of-order execution.
  • Small caches per thread. Instead of avoiding memory stalls, the GPU hides them by always having other work ready (Module 2).
  • Very high memory bandwidth (320 GB/s on a T4, about 3.35 TB/s on an H100, versus about 100 GB/s for a desktop CPU), because feeding thousands of units needs a wide pipe.
CPU die control large cache GPU die small caches + control per row arithmetic units

1.4 Heterogeneous computing

The textbook's central idea is that real applications contain both kinds of code: serial control logic (reading input, making decisions) and large data-parallel loops (the same operation on millions of elements). The best design uses each processor for what it is good at. In CUDA this is explicit:

  • The host (CPU) runs main(), does the serial parts and decides what the GPU does.
  • The device (GPU) runs kernels: functions executed by thousands of threads in parallel.
  • They have separate memories connected by a PCIe link (16–64 GB/s), so data has to be copied between them. This cost appears in every assignment.
Worked example

Problem. An image filter does 100 floating-point operations per pixel on a 4K image (3840 × 2160 ≈ 8.3 million pixels). A CPU core does about 4×10⁹ simple operations per second, and the T4 about 8×10¹² FP32 operations per second. Estimate the time on one CPU core and on the GPU, ignoring memory.

  1. Total work: 8.3×10⁶ pixels × 100 = 8.3×10⁸ operations.
  2. One CPU core: 8.3×10⁸ / 4×10⁹ ≈ 0.21 s.
  3. GPU at peak: 8.3×10⁸ / 8×10¹² ≈ 0.1 ms. That is 2,000× faster in theory.
  4. Reality check: the image is 8.3×10⁶ × 4 bytes ≈ 33 MB. Copying it over PCIe at ~12 GB/s takes ≈ 2.8 ms each way, about 50× longer than the computation. So the copies decide the real speed-up unless the data stays on the GPU for many operations.

Lesson. The GPU's raw speed only pays off when there is enough work per byte moved. You will measure exactly this in Assignment 1.

Exercises

RecallDefine latency and throughput in one sentence each.

Latency is the time from start to finish of a single task. Throughput is how many tasks are completed per unit of time.

RecallName three things a CPU core spends its area on that are not arithmetic.

Large caches, branch prediction and out-of-order execution logic (also: register renaming, deep pipelines for high clock speeds).

ExplainWhy can a GPU get away with small caches?

Because it tolerates memory latency instead of avoiding it. When some threads wait for memory, the hardware runs other threads that are ready. With enough threads, the arithmetic units stay busy even though each individual access is slow.

ExplainWhy must data be copied between CPU and GPU, and why does that matter for performance?

On a discrete GPU, host RAM and device memory are physically separate, joined by PCIe. PCIe bandwidth (tens of GB/s) is 10–100× lower than GPU memory bandwidth, so a program that copies a lot and computes little is limited by the copies, not the GPU.

ApplyClassify each as CPU- or GPU-suited: (a) compiling a C program, (b) multiplying two 4096×4096 matrices, (c) walking a linked list, (d) training a neural network, (e) checking a password against a hash once.

(a) CPU: branchy, irregular, many dependencies. (b) GPU: huge amount of independent arithmetic. (c) CPU: each step depends on the previous pointer, so there is no parallelism. (d) GPU: dominated by matrix multiplications. (e) CPU: a single small task; launching a GPU kernel and copying data would take longer than the work.

ApplyA kernel does 10 operations per 4-byte element on 100 million elements. Is the PCIe copy or the kernel likely to dominate on a T4?

Data: 400 MB in, perhaps 400 MB out. At ~12 GB/s that is ≈ 33 ms each way. Work: 10⁹ operations ≈ 0.13 ms at peak, and even memory-bound the kernel reads/writes 800 MB at 320 GB/s ≈ 2.5 ms. The copies dominate by about 25×.

Module 2 · Lecture 1.2

Warps, SIMT and hiding latency

How thousands of slow threads add up to a fast processor.

  • After this module you can: explain what a warp is, the difference between SIMD and SIMT, how latency hiding works (with numbers), and what warp divergence costs.

2.1 The problem: memory is slow

Reading from GPU global memory takes roughly 400–800 clock cycles. An arithmetic instruction takes about 4. If a thread computed, then waited 500 cycles for its next number, its arithmetic unit would be idle more than 99% of the time. A CPU fights this with big caches. A GPU uses a different trick.

2.2 Warps: the unit of execution

The GPU groups threads into bundles of 32 called warps. A warp is the real unit of scheduling: the hardware fetches and decodes one instruction and all 32 threads execute it, each on its own data. Threads 0–31 of a block form warp 0, threads 32–63 warp 1, and so on.

This is why GPU control logic is so cheap: one decoder serves 32 threads. It is also why block sizes should be multiples of 32. A block of 48 threads makes 2 warps, the second only half full, wasting 16 lanes on every instruction.

2.3 SIMD versus SIMT

  • SIMD (single instruction, multiple data), used in CPU vector units like AVX: one thread issues an instruction that operates on a vector register of 8 or 16 values. The programmer or compiler must pack data into vectors explicitly.
  • SIMT (single instruction, multiple threads), NVIDIA's model: you write code for one scalar thread, and every thread gets its own ID and registers. The hardware groups 32 threads into a warp and runs them together. You get SIMD efficiency while writing simple scalar code.

2.4 Latency hiding by switching warps

Each SM holds many warps at once (up to 32 on a T4, 64 on an H100). Every cycle, the warp scheduler picks a warp whose next instruction is ready and issues it. When warp 0 issues a memory load, it simply waits; the scheduler runs warp 1, then warp 2, and so on. By the time it cycles back, warp 0's data has arrived.

warp 0warp 1warp 2warp 3 computing waiting on memory → time; the SM always has a warp to run

Why is switching free? On a CPU, switching threads means saving and restoring registers to memory. On a GPU, every resident warp keeps its own registers in the SM's huge register file (65,536 32-bit registers per SM). Nothing is saved or restored; the scheduler just picks a different warp next cycle.

Consequence: oversubscribe. You launch far more threads than there are cores (often 10–100× more), so each SM always has spare warps to switch to. A kernel with too few threads cannot hide latency, no matter how good the code is.

2.5 How many warps are enough? (Little's law)

Queueing theory gives a rule of thumb: the amount of work that must be in flight equals latency × throughput. If a load takes 500 cycles and the SM can issue one instruction per cycle per scheduler, you need about 500 independent instructions in flight per scheduler to never stall. Each warp contributes only a few independent instructions before it waits, so you need many warps. That is the intuition behind occupancy (Module 8).

2.6 Warp divergence

All 32 threads of a warp share one instruction stream. What happens with if (x > 0) A(); else B(); when some threads have x > 0 and others don't? The warp runs both paths one after the other: first A with the "else" threads masked off (idle), then B with the "if" threads masked. The results are correct; the cost is time.

  • Divergence only matters within a warp. Different warps taking different branches cost nothing extra.
  • Bounds checks like if (i < n) diverge in at most one warp (the last), so they're essentially free.
Worked example

Problem. A block has 128 threads. The kernel does if (threadIdx.x % 2 == 0) A(); else B(); where A takes 100 cycles and B takes 60. How long does a warp take, and how would you fix it?

  1. Warps per block: 128 / 32 = 4.
  2. Inside each warp, threads 0, 2, 4 … go to A and 1, 3, 5 … go to B. Both paths are needed in every warp.
  3. Each warp therefore takes 100 + 60 = 160 cycles instead of 100 or 60.
  4. Fix: branch on something constant across a warp, e.g. if ((threadIdx.x / 32) % 2 == 0). Now warps 0 and 2 run only A (100 cycles) and warps 1 and 3 only B (60). The work is the same but with no masked lanes. (You may need to remap which data each thread handles to keep the result the same.)

Exercises

RecallHow many threads are in a warp, and which threads of a block form warp 2?

32. Warp 2 is threads 64–95 (for a 1D block).

ExplainWhy is switching warps free on a GPU but expensive on a CPU?

All resident warps keep their registers in the SM's register file permanently, so switching just means issuing from a different warp. A CPU core has one set of registers per hardware thread (one or two), so a context switch must save and reload state through memory.

ExplainSIMD vs SIMT: what is the difference for the programmer?

With SIMD you explicitly write vector operations (or rely on the compiler to vectorize). With SIMT you write normal scalar code for one thread using its index, and the hardware runs 32 such threads together.

ApplyA block has 100 threads. How many warps, and how many lanes are wasted?

⌈100/32⌉ = 4 warps = 128 lanes, so 28 lanes do nothing. A block size of 96 or 128 would be better.

ApplyA kernel launches 40 blocks of 32 threads on a T4 (40 SMs). Why will it be slow even if each thread's code is optimal?

Each SM gets 1 block = 1 warp. When that warp waits on memory, the SM has nothing else to run, so latency is fully exposed. You need many warps per SM (dozens) to hide latency.

ApplyDoes if (i < n) c[i] = a[i] + b[i]; cause expensive divergence?

No. Only the single warp that straddles n has some threads in and some out, and the "else" path is empty. Every other warp is uniformly true (or uniformly false).

Lab: see warps in action

  1. Write a kernel launched with <<<1, 96>>> where each thread prints threadIdx.x, threadIdx.x / 32 (warp ID) and threadIdx.x % 32 (lane ID).
  2. Predict: will the lines come out in thread order? Will each warp's 32 lines stay together?
  3. Run it, then add cudaDeviceSynchronize() after the launch if nothing prints.
Solution and expected output
#include <cstdio>
__global__ void who() {
  int t = threadIdx.x;
  printf("thread %2d  warp %d  lane %2d\n", t, t / 32, t % 32);
}
int main() { who<<<1, 96>>>(); cudaDeviceSynchronize(); }

What you should see: lines in groups of 32 with the same warp ID, usually in lane order inside a group, because a warp executes the printf together. The order of the warps (0, 1, 2) is not guaranteed and may differ between runs. Never write code that depends on warp or block order.

Module 3 · Lecture 1.3, 1-GPU-Architecture

Inside the GPU: SMs and the memory hierarchy

The physical layout you are programming for, and why it shapes every optimization in this course.

  • After this module you can: describe the GPU → SM → core hierarchy, list the memory levels with their scope and speed, compute core counts and bandwidth from specs, and explain what MIG means for you on the cluster.

3.1 The building block: the Streaming Multiprocessor

A GPU is mostly copies of one unit, the Streaming Multiprocessor (SM). Each SM is like a small processor of its own, containing:

  • CUDA cores (FP32/INT32 units), plus FP64 units, special-function units (sin, exp, sqrt) and, on newer GPUs, Tensor Cores for matrix math.
  • Warp schedulers, usually 4 per SM, each issuing for its own set of warps.
  • A register file of 65,536 32-bit registers, shared by all resident threads.
  • Shared memory / L1 cache: a fast on-chip memory (64 KB on a T4, up to 228 KB on an H100) that blocks on this SM can use.

When you launch a kernel, the hardware hands out thread blocks to SMs. A block runs entirely on one SM and never moves. An SM can host several blocks at once if it has enough registers, shared memory and warp slots. More SMs therefore means more blocks running at once. This is why code with many blocks automatically gets faster on bigger GPUs.

GPUArchitecture (compute cap.)SMsFP32 cores / SMTotal FP32 coresMax warps / SMMemory bandwidth
Tesla T4 (Colab)Turing (7.5)40642,56032320 GB/s (GDDR6)
A100Ampere (8.0)108646,912641.6–2 TB/s (HBM2e)
H100 SXMHopper (9.0)13212816,896643.35 TB/s (HBM3)

The compute capability (7.5, 9.0) identifies the instruction set. It is what you pass to the compiler: nvcc -arch=sm_75 for a T4, -arch=sm_90 for an H100. Compiling for the wrong one gives "no kernel image is available" errors, or silently runs nothing if you don't check errors.

3.2 The memory hierarchy

The closer memory is to the cores, the faster and smaller it is, and the fewer threads can see it. Every performance technique in this course is about keeping data in the faster levels.

LevelWho can see itSizeApprox. latencyHow you use it
RegistersOne thread≤ 255 per thread~1 cycleLocal scalar variables (automatic).
Shared memoryAll threads of one blocktens of KB per SM~20–30 cycles__shared__ arrays (tiling, later lectures).
L1 cacheOne SMshares space with shared mem~30 cyclesAutomatic.
L2 cacheWhole GPU4 MB (T4) – 50 MB (H100)~200 cyclesAutomatic.
Global memoryAll threads + host via copies16–80 GB~400–800 cyclescudaMalloc.
Host RAMCPUmany GBmicroseconds via PCIecudaMemcpy.
Register pressure. The 65,536 registers are shared by all resident threads. If your kernel uses 64 registers per thread, an SM fits at most 65,536 / 64 = 1,024 threads. With 128 registers per thread, only 512. Fewer resident threads means fewer warps to hide latency with. This trade-off reappears in Module 8.

3.3 MIG on the school cluster

The cluster's H100s are partitioned with Multi-Instance GPU (MIG): one physical card is split into isolated slices, each with its own SMs, memory and L2 share. You get a slice (for example 16 SMs and around 10 GB), not 132 SMs. Run nvidia-smi to see what you got, and compare cluster results fairly: a MIG slice can be slower than a full T4 for some kernels.

Worked example

Problem. Compute the T4's peak FP32 throughput and memory bandwidth from its specs: 2,560 cores at a boost clock of 1.59 GHz, 256-bit memory bus, GDDR6 at an effective 10 Gbit/s per pin.

  1. Each core can do one fused multiply-add (FMA) per cycle, which counts as 2 FLOPs.
  2. Peak FP32 = 2,560 × 2 × 1.59×10⁹ ≈ 8.1×10¹² FLOP/s = 8.1 TFLOP/s.
  3. Bandwidth = bus width × data rate = 256 bits × 10×10⁹ /s = 2.56×10¹² bits/s ÷ 8 = 320 GB/s.
  4. Ratio: 8.1×10¹² / 320×10⁹ ≈ 25 FLOPs per byte. A kernel must do about 25 operations per byte it reads to be limited by compute rather than memory. You'll use this number in Module 4.

Exercises

RecallHow many FP32 cores does an H100 SXM have, and how do you get the number?

132 SMs × 128 FP32 cores per SM = 16,896.

RecallWhich memory level is shared by all threads of one block, and only those?

Shared memory.

ExplainTwo threads in different blocks need to exchange data. Can they use shared memory? What can they use?

No: shared memory is private to one block (blocks may even be on different SMs). They must go through global memory, and safely only across kernel launches or using atomic operations, because there's no guarantee about when another block runs.

ExplainWhy does a program with many blocks run faster on an H100 than a T4 without code changes?

Blocks are independent and distributed to SMs by the hardware. The H100 has 132 SMs with more cores each, so more blocks run at once. Code that assumes nothing about how many blocks run simultaneously scales automatically.

ApplyA kernel uses 96 registers per thread with blocks of 256 threads. How many blocks fit on one SM by registers alone?

Registers per block = 96 × 256 = 24,576. 65,536 / 24,576 = 2.67, so 2 blocks (512 threads = 16 warps). On a T4 (max 32 warps) that caps occupancy at 50%.

ApplyYour code works on Colab but prints garbage on the cluster. You compiled with -arch=sm_75. What's going on?

The H100 is sm_90. Without a compatible binary or embedded PTX the kernel fails to launch, and without error checks you just read uninitialized output. Compile with -arch=sm_90 and always check cudaGetLastError().

Lab: query your GPU and derive its peaks

  1. Extend the Tutorial 1 device-query program to print: name, compute capability, SM count, warpSize, maxThreadsPerBlock, maxThreadsPerMultiProcessor, regsPerMultiprocessor, sharedMemPerMultiprocessor, l2CacheSize and totalGlobalMem.
  2. Predict the values for a T4 using the tables above before you run it.
  3. Run it on Colab, and on the cluster when you have access.
Solution and expected output
#include <cstdio>
#include <cuda_runtime.h>
int main() {
  cudaDeviceProp p; cudaGetDeviceProperties(&p, 0);
  printf("%s  cc %d.%d\n", p.name, p.major, p.minor);
  printf("SMs %d  warp %d  maxThr/block %d  maxThr/SM %d\n",
         p.multiProcessorCount, p.warpSize, p.maxThreadsPerBlock, p.maxThreadsPerMultiProcessor);
  printf("regs/SM %d  smem/SM %zu KB  L2 %d MB  mem %.1f GB\n",
         p.regsPerMultiprocessor, p.sharedMemPerMultiprocessor/1024,
         p.l2CacheSize/(1024*1024), p.totalGlobalMem/1e9);
}

Expected on a T4: Tesla T4 cc 7.5, 40 SMs, warp 32, 1024 threads/block, 1024 threads/SM (= 32 warps), 65536 regs/SM, 64 KB shared/SM, 4 MB L2, about 15.6 GB memory.

Some fields (like memoryClockRate) are deprecated in CUDA 13 and may not compile, which is why the solution avoids them.

Module 4 · Lecture 1 · Book §1.2–1.4

How fast can it possibly go?

Two back-of-the-envelope tools that predict performance before you write any code.

  • After this module you can: apply Amdahl's law, compute arithmetic intensity for a kernel, decide whether it is memory- or compute-bound, and estimate its best possible runtime.

4.1 Amdahl's law: the serial part limits you

Split a program's original runtime into a fraction s that stays serial and a fraction 1 − s that you speed up by a factor p. The new runtime is s + (1 − s)/p of the original, so:

speedup = 1 / ( s + (1 − s) / p ) and as p → ∞, speedup → 1 / s

The serial part never gets faster, so eventually it is all that is left. With 10% serial code, an infinitely fast GPU gives at most 10×. With 1%, at most 100×.

Serial fraction sp = 10p = 100p = 1000Limit (1/s)
0.501.8×2.0×2.0×2×
0.105.3×9.2×9.9×10×
0.019.2×50×91×100×

On a GPU, host↔device copies behave like serial time, since the GPU computes nothing useful during them unless you overlap them (streams, a later lecture).

4.2 Two ceilings: compute and memory

A kernel is limited by whichever runs out first:

  • Compute ceiling: the GPU can't do more than its peak FLOP/s (8.1 TFLOP/s FP32 on a T4).
  • Memory ceiling: it can't move more than its peak bandwidth (320 GB/s on a T4).

Which one you hit depends on the kernel's arithmetic intensity:

AI = floating-point operations ÷ bytes moved to/from global memory (FLOP/byte)

The time can't be less than either bound, so a good estimate of the best possible time is:

t_min = max( FLOPs / peak_FLOPs , bytes / peak_bandwidth )

The crossover is the machine balance = peak FLOP/s ÷ peak bandwidth (≈ 25 FLOP/byte on a T4, from Module 3). Kernels with AI below it are memory-bound: only moving fewer bytes helps. Above it they are compute-bound: only doing fewer or faster operations helps. Plotting attainable FLOP/s against AI gives the roofline model, which Nsight Compute can draw for your kernels.

≈25arithmetic intensity (FLOP/byte) → compute ceiling 8.1 TFLOP/s memory ceiling 320 GB/s vector add tiled matmul FLOP/s
Worked example

Problem. Vector add on N = 2²⁴ (16.8 million) floats on a T4. Compute the arithmetic intensity and the minimum possible kernel time.

  1. FLOPs: one addition per element, so N = 1.68×10⁷.
  2. Bytes: read a[i] and b[i], write c[i], 4 bytes each, so 12N = 2.01×10⁸ bytes.
  3. AI = N / 12N = 1/12 ≈ 0.083 FLOP/byte. That is 300× below the balance point of 25, so it is very memory-bound.
  4. Compute bound: 1.68×10⁷ / 8.1×10¹² ≈ 2 µs. Memory bound: 2.01×10⁸ / 3.2×10¹¹ ≈ 630 µs.
  5. t_min = max(2 µs, 630 µs) = 630 µs. In practice you'll measure 700–800 µs, since the GPU reaches about 80–90% of peak bandwidth.

Lesson. For memory-bound kernels, compute speed is irrelevant: the 8 TFLOP/s are idle 99.7% of the time. This is why vector add shows the copy times dominating in your Assignment 1 chart.

Exercises

RecallState Amdahl's law and its limit.

speedup = 1 / (s + (1−s)/p), and as p → ∞ the speedup approaches 1/s.

ExplainWhy does making the arithmetic faster not help a memory-bound kernel?

Its time is set by how fast data arrives. The arithmetic units already wait for data most of the time; making them faster only makes them wait longer. You must reduce the bytes moved (reuse data in shared memory or registers, use smaller types) instead.

ApplyA program spends 20% of its time in code you can't parallelize. With a 50× faster parallel part, what overall speedup do you get? What's the limit?

1 / (0.2 + 0.8/50) = 1 / 0.216 ≈ 4.6×. The limit is 1/0.2 = 5×.

ApplySAXPY computes y[i] = a*x[i] + y[i] in float. What is its arithmetic intensity, and is it memory- or compute-bound on a T4?

2 FLOPs (multiply and add) per element. Bytes: read x and y, write y, so 12 bytes. AI = 2/12 ≈ 0.17, far below 25, so memory-bound.

ApplyNaive n×n matmul (float) reads 2n per output element from global memory and does 2n FLOPs. Ideal matmul reads each input once. Compare their AI for n = 1024.

Naive: 2n FLOPs per 2n × 4 bytes = 0.25 FLOP/byte, memory-bound. Ideal: 2n³ FLOPs per 3n² × 4 bytes (read A and B, write C) = n/6 ≈ 171 FLOP/byte for n = 1024, compute-bound. Data reuse is worth almost 700× in AI, which is why tiling (a later lecture) matters.

Module 5 · Lecture 2.1, 2.2.1

The CUDA workflow and moving data

The skeleton every CUDA program follows, and what each call really does.

  • After this module you can: use the three function qualifiers correctly, write the allocate → copy → launch → copy → free sequence, add error checking, and explain which calls are synchronous.

5.1 Three questions

Every CUDA program must answer three questions:

  1. Where does each function run? CPU or GPU, set by qualifiers.
  2. Where does each piece of data live? Host memory or device memory, set by how you allocate it.
  3. What does each thread do? Determined by the thread's index (Module 6).

5.2 Function qualifiers

QualifierRuns onCalled fromRules
__global__GPUCPUA kernel. Must return void. Launched with name<<<grid, block>>>(args). Arguments are copied by value to the GPU.
__device__GPUGPUHelper functions called from kernels, e.g. scale() and distance(). Can return values.
__host__CPUCPUThe default for normal functions.
__host__ __device__BothBothCompiled twice. Useful for helpers shared by a CPU reference and the GPU kernel.

5.3 Two separate memories

The CPU and GPU have separate address spaces. A pointer returned by malloc points to host memory; one from cudaMalloc points to device memory. They're both just float* to the compiler, so mixing them up compiles fine and fails at runtime. That's why everyone uses the naming convention h_ for host and d_ for device.

  • Dereferencing a device pointer on the CPU (d_out[0] in main) usually crashes with a segmentation fault.
  • Passing a host pointer to a kernel gives an "illegal memory access" error when the kernel runs.

5.4 The five steps

// 1. allocate on the GPU
float *d_a, *d_c;
cudaMalloc(&d_a, bytes);              // note: address of the pointer
cudaMalloc(&d_c, bytes);

// 2. copy inputs host → device       (dst, src, size, direction)
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);

// 3. launch the kernel (returns immediately!)
myKernel<<<blocks, threads>>>(d_a, d_c, n);

// 4. copy results device → host (waits for the kernel to finish first)
cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

// 5. free
cudaFree(d_a); cudaFree(d_c);

cudaMalloc takes &d_a (a pointer to your pointer) because it needs to write the device address into your variable. Its return value is reserved for an error code.

5.5 Synchronous or asynchronous?

The GPU has a work queue called a stream. The CPU puts operations in it, and the GPU does them in order.

  • Kernel launches are asynchronous: the CPU queues the kernel and continues immediately, often before the kernel even starts.
  • cudaMemcpy is synchronous: it waits until everything before it in the stream is finished, then does the copy, then returns. So step 4 above automatically waits for the kernel.
  • cudaDeviceSynchronize() blocks the CPU until all queued GPU work is done. You need it when nothing else waits: before stopping a timer, before checking for kernel errors, and before exiting if the kernel uses printf.
The kernel.cu bug. The lecture's kernel.cu launches a kernel that prints, then calls cudaFree and returns. Nothing waits for the kernel, so the program can exit before the GPU's print buffer is flushed and you see no output. Adding cudaDeviceSynchronize(); after the launch fixes it.

5.6 Error checking

Every CUDA API call returns a cudaError_t. If you ignore it, failures are silent and you debug garbage output for hours. Wrap every call:

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

CHECK(cudaMalloc(&d_a, bytes));
myKernel<<<g, b>>>(…);
CHECK(cudaGetLastError());        // catches bad launch configurations
CHECK(cudaDeviceSynchronize());   // catches errors during kernel execution

Kernel launches return nothing, so there are two checks: cudaGetLastError() right after the launch catches configuration errors (too many threads per block), and the synchronize catches errors while the kernel ran (out-of-bounds accesses).

Context creation. The first CUDA call in a program sets up the GPU context, which takes 100–200 ms. In the Lecture 2 profiles that is why cudaMalloc takes 99.9% of API time. Call cudaFree(0) once at the start to pay this cost before you start timing.
Worked example: find the bugs
int main() {
  float h_x[1000000];                                     // A
  float *d_x;
  cudaMalloc(d_x, 1000000 * sizeof(float));               // B
  cudaMemcpy(h_x, d_x, 1000000*sizeof(float), cudaMemcpyHostToDevice);  // C
  square<<<1000000/256, 256>>>(d_x);                       // D
  printf("%f\n", d_x[0]);                                 // E
}
  1. A: 4 MB on the stack can overflow it (the default is often 8 MB, sometimes less). Use malloc.
  2. B: it should be cudaMalloc(&d_x, …). Passing the pointer's value means cudaMalloc can't set it. (The compiler catches this one since the argument type is wrong.)
  3. C: the arguments are swapped. It is (dst, src), so it should be cudaMemcpy(d_x, h_x, …).
  4. D: 1,000,000 / 256 = 3906 blocks covers 999,936 elements, so the last 64 are skipped. Round up and add a bounds check in the kernel.
  5. E: it dereferences a device pointer on the host. Copy back to a host buffer first. There is also no cudaFree and no error checking.

Exercises

RecallWhich qualifier marks a function that the CPU calls and the GPU runs? What must it return?

__global__, and it must return void.

RecallWhat is the argument order of cudaMemcpy?

Destination, source, number of bytes, direction (e.g. cudaMemcpyHostToDevice).

ExplainWhy does the program in Lecture 2 work without cudaDeviceSynchronize(), while kernel.cu doesn't?

In 2-first-cuda-code.cu the cudaMemcpy back to the host waits for the kernel. kernel.cu has no copy back, so nothing waits and the program may exit before the kernel's printf output appears.

ExplainWhy do you need both cudaGetLastError() and cudaDeviceSynchronize() to check a kernel?

The launch is asynchronous. cudaGetLastError() reports errors detected when queuing (an invalid configuration); errors that happen while the kernel runs only surface after it finishes, which is what synchronizing waits for.

ApplyYou time a kernel with a CPU timer around the launch line only, and always get about 5 µs no matter the size. What's wrong?

You only timed queuing the launch. Add cudaDeviceSynchronize() before stopping the timer (or use CUDA events / nsys).

ApplyWrite a __host__ __device__ helper and say why it's useful for Assignment 1.

__host__ __device__ float add(float a, float b) { return a + b; }. The CPU reference and the GPU kernel then share the same code, so any mismatch comes from the hardware, not from two different implementations.

Module 6 · Lecture 2.2.2, 2.2.3

Launching kernels and finding your thread

How a launch creates threads, and how each thread works out which data is its own.

  • After this module you can: describe the grid/block/thread hierarchy, compute global indices in 1D and 2D, size grids for any problem, respect hardware limits, and use vector types.

6.1 The thread hierarchy

A launch kernel<<<G, B>>> creates a grid of G blocks, each with B threads: G × B threads in total, all running the same kernel code. Why two levels instead of one flat list of threads?

  • Blocks are independent and can run in any order on any SM. This is what lets the same code scale from 40 SMs to 132.
  • Threads within a block can cooperate: they run on the same SM, can share __shared__ memory and can synchronize with __syncthreads(). Threads in different blocks can't.

Inside the kernel, each thread reads four built-in variables (each has .x .y .z):

VariableMeaningRange
threadIdxThread's position within its block0 … blockDim − 1
blockIdxBlock's position within the grid0 … gridDim − 1
blockDimThreads per block (same for all blocks)set at launch
gridDimBlocks in the gridset at launch

6.2 The 1D global index

Think of blocks as rows of seats in a cinema. Your seat number in the whole cinema is (row × seats per row) + your seat in the row:

int i = blockIdx.x * blockDim.x + threadIdx.x;
blockDim.x = 4. Thread 2 of block 1 → i = 1·4 + 2 = 6

6.3 Sizing the grid: round up, then guard

N is rarely a multiple of the block size. Integer division rounds down, so N / TPB blocks would leave the tail unprocessed. Always round up, and then make the extra threads in the last block do nothing:

__global__ void vecAdd(const float *a, const float *b, float *c, int n) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i >= n) return;              // guard: the last block may have spare threads
  c[i] = a[i] + b[i];
}
int TPB = 256;
int blocks = (n + TPB - 1) / TPB;   // ceiling division
vecAdd<<<blocks, TPB>>>(d_a, d_b, d_c, n);

Why does (n + TPB − 1) / TPB round up? Adding TPB − 1 pushes any remainder over the next multiple. For n = 1000, TPB = 256: (1000 + 255)/256 = 1255/256 = 4, while 1000/256 = 3.

Choosing TPB: use a multiple of 32 (Module 2), usually 128 or 256. Too small (32) and you hit the per-SM block limit before filling the warp slots; 1024 leaves less flexibility for the scheduler. 256 is a good default.

6.4 Hardware limits

  • At most 1024 threads per block in total (x·y·z ≤ 1024). A block of (32, 32) is allowed; (64, 32) is not.
  • Grid size: x up to 2³¹ − 1 blocks, y and z up to 65,535.
  • Exceeding a limit doesn't crash. The launch silently fails with "invalid configuration argument", which you only see by checking cudaGetLastError().

6.5 2D grids for 2D data

For matrices and images, use 2D blocks and grids with the dim3 type. Map x to columns and y to rows:

__global__ void scale2D(float *m, int W, int H, float s) {
  int c = blockIdx.x * blockDim.x + threadIdx.x;   // column  0..W-1
  int r = blockIdx.y * blockDim.y + threadIdx.y;   // row     0..H-1
  if (c >= W || r >= H) return;                    // guard BOTH dimensions
  m[r * W + c] *= s;                                // row-major: row × width + column
}
dim3 block(16, 16);                                 // 256 threads
dim3 grid((W + 15) / 16, (H + 15) / 16);            // round up each dimension
scale2D<<<grid, block>>>(d_m, W, H, 2.0f);

Why x → column? Threads in a warp are numbered x first: threads (0,0), (1,0), (2,0) … are consecutive lanes. With x as the column, the 32 lanes of a warp access 32 adjacent floats, which the hardware combines into a few large memory transactions. This is coalescing. Swapping x and y makes the lanes access addresses W floats apart, which can be many times slower.

6.6 Vector types

CUDA provides small structs such as int2, int3, float2, float3, float4, double2 with fields .x .y .z .w. Build them with make_float3(a, b, c). dim3 is a vector of unsigned ints where unspecified fields default to 1, so dim3 b(256) means (256, 1, 1). float4 is aligned to 16 bytes and can be loaded in one instruction, a common trick in memory-bound kernels.

Slide 23 typo. The slide swaps the descriptions. BPG = blocks per grid and TPB = threads per block, launched as <<<BPG, TPB>>>.
Worked example

Problem. Multiply A (1024 × 8191) by B (8191 × 8197) with 16×16 blocks, one thread per element of C (1024 × 8197). Size the grid and count the threads.

  1. C has M = 1024 rows and P = 8197 columns. x → columns, y → rows.
  2. grid.x = ⌈8197 / 16⌉ = ⌈512.3⌉ = 513. grid.y = ⌈1024 / 16⌉ = 64.
  3. Blocks = 513 × 64 = 32,832. Threads = 32,832 × 256 = 8,404,992.
  4. Useful threads = M × P = 1024 × 8197 = 8,393,728. The difference, 11,264, are threads in the last column of blocks whose column ≥ 8197. They exit at the guard: 8197 = 512·16 + 5, so 11 of the 16 columns in each of those 64 × 16 = 1,024 rows are idle, and 11 × 1,024 = 11,264. ✓

This is exactly Assignment 1 Q2, part 3. Being able to reproduce the derivation is more important than the number.

Exercises

RecallWhat is the maximum number of threads per block?

1024 in total, across all three dimensions.

ExplainWhy does CUDA have blocks at all, rather than one flat array of threads?

Blocks are the unit the hardware places on SMs. Independence between blocks lets the same code run on any number of SMs, while threads within a block can cooperate through shared memory and __syncthreads().

ExplainWhy should x map to the column index of a row-major matrix?

Consecutive lanes of a warp differ in threadIdx.x. If x is the column, they access consecutive addresses, so the loads coalesce into a few wide transactions. If x is the row, each lane is W elements apart and needs its own transaction.

ApplyN = 263,149 and TPB = 256. How many blocks, threads launched, and idle threads?

⌈263149/256⌉ = 1028 blocks, 1028 × 256 = 263,168 threads, and 263,168 − 263,149 = 19 idle threads.

ApplyAn image is 1000 × 600 (W × H) with 16×16 blocks. Give the grid and the number of idle threads.

dim3 grid(63, 38), since ⌈1000/16⌉ = 63 and ⌈600/16⌉ = 38. Threads = 63·38·256 = 612,864, so 612,864 − 600,000 = 12,864 are idle.

ApplyWhat happens with <<<1, 2048>>>? How would you notice?

2048 > 1024, so the launch fails and the kernel never runs. Nothing crashes; cudaGetLastError() returns "invalid configuration argument", and otherwise you just get uninitialized output.

ApplyIn a 2D kernel someone wrote idx = c * H + r. What does that compute, and is it a bug?

That is column-major indexing. It's only correct if the matrix is stored column-major (as in Fortran or cuBLAS). For a row-major C array it reads the wrong elements, and even when correct it makes warp accesses strided, so it's slower.

Lab: map threads to data

  1. Run cuda_demo.cu with N = 16 and 8 threads per block. Note that block 1 may print before block 0.
  2. Change N to 20 and keep 8 threads per block. Predict: how many blocks, and which global indices exist but skip the work?
  3. Write a 2D kernel that prints (r, c, idx) for a 5 (rows) × 7 (columns) matrix with 4×4 blocks. Predict the grid first.
Solution and expected output

Step 2: ⌈20/8⌉ = 3 blocks = 24 threads. Indices 20–23 (block 2, threads 4–7) fail if (i < n) and print nothing. The printed results stop at index 19.

__global__ void show(int W, int H) {
  int c = blockIdx.x * blockDim.x + threadIdx.x;
  int r = blockIdx.y * blockDim.y + threadIdx.y;
  if (c >= W || r >= H) return;
  printf("block(%d,%d) thread(%d,%d) -> r=%d c=%d idx=%d\n",
         blockIdx.x, blockIdx.y, threadIdx.x, threadIdx.y, r, c, r * W + c);
}
int main() {
  int W = 7, H = 5;
  dim3 block(4, 4), grid((W + 3) / 4, (H + 3) / 4);   // grid = (2, 2)
  show<<<grid, block>>>(W, H);
  cudaDeviceSynchronize();
}

Step 3: grid = (2, 2) = 4 blocks × 16 = 64 threads. Exactly 35 lines print (5 × 7), idx from 0 to 34, in no particular block order. Block (1,1) prints only 3 × 1 = 3 lines: columns 4–6 of row 4.

Module 7 · Lecture 2.3

Walkthrough: the distance kernel

Read a complete CUDA program line by line, then find what's wrong with it.

  • After this module you can: explain every line of the lecture's first program, predict its output, find its latent bugs, and explain why GPU and CPU floating-point results can differ.

7.1 What it computes

For N = 64 points evenly spaced on [0, 1], x_i = i/(N − 1), compute the distance to a reference point ref = 0.5: |x_i − 0.5|. One thread per point.

#define N 64
#define TPB 32

__device__ float scale(int i, int n) {          // GPU helper: index → position in [0,1]
  return ((float)i) / (n - 1);
}
__device__ float distance(float x1, float x2) {  // GPU helper: |x2 - x1| written as sqrt of square
  return sqrt((x2 - x1) * (x2 - x1));
}
__global__ void distanceKernel(float *d_out, float ref, int len) {
  const int i = blockIdx.x * blockDim.x + threadIdx.x;   // which point am I?
  const float x = scale(i, len);
  d_out[i] = distance(x, ref);                            // write my result
}

int main() {
  const float ref = 0.5f;
  float *h_out = (float *)malloc(N * sizeof(float));     // host buffer for results
  float *d_out = 0;
  cudaMalloc(&d_out, N * sizeof(float));                 // device buffer
  distanceKernel<<<N / TPB, TPB>>>(d_out, ref, N);       // 2 blocks × 32 threads
  cudaMemcpy(h_out, d_out, N * sizeof(float), cudaMemcpyDeviceToHost);  // waits for kernel
  for (int i = 0; i < N; i++) std::cout << i << " = " << h_out[i] << std::endl;
  cudaFree(d_out); free(h_out);
}

Note what's not there: no input copy to the GPU. The kernel computes its inputs from its index, so only the output is copied back.

7.2 The output

The values go 0.5, 0.484, … down to 0.0079 at i = 31 and back up to 0.5 at i = 63, the V shape of |x − 0.5|. There's no exact zero because 0.5 lies between x₃₁ = 31/63 and x₃₂ = 32/63.

Look closely: 31 = 0.00793651 but 32 = 0.00793654. Mathematically both are 1/126. They differ in the last digits because 32/63 and 31/63 round differently in 32-bit float, and those rounding errors are visible once you subtract 0.5 (subtracting nearly equal numbers amplifies relative error).

7.3 Why floats differ between CPU and GPU

A float stores about 7 significant decimal digits, so nearly every operation rounds. IEEE-754 guarantees that a single add, multiply, divide or sqrt is correctly rounded on both CPU and GPU. Results can still differ because:

  • FMA: the GPU compiler fuses a*b + c into one instruction with one rounding instead of two. More accurate, but different.
  • Operation order: floating-point addition isn't associative: (a + b) + c can differ from a + (b + c). Parallel reductions sum in a different order from a CPU loop.
  • Fast math functions: with --use_fast_math, or with intrinsics like __sinf, results are approximate.
  • CPU precision: the CPU compiler may keep intermediates in higher precision or vectorize differently.

So validate GPU results with a tolerance: fabs(gpu − cpu) <= 1e-6 * fabs(cpu) or similar, never with ==.

Worked example: change N and watch it break

Problem. Set N = 100 and keep TPB = 32. What happens, and what is the correct fix?

  1. The launch is <<<100/32, 32>>> = <<<3, 32>>>, so 96 threads.
  2. Elements 96–99 are never written. cudaMalloc doesn't zero memory, so h_out[96..99] contain whatever was there, possibly zeros, which makes the bug easy to miss.
  3. Naive fix: launch ⌈100/32⌉ = 4 blocks = 128 threads. Now threads 100–127 write d_out[100..127], past the end of the array. That's out-of-bounds memory corruption, which may or may not crash.
  4. Correct fix: both changes together: (N + TPB - 1) / TPB blocks and if (i >= len) return; as the kernel's first line after computing i.

Run compute-sanitizer ./demo on the naive fix: it reports each invalid write, a useful tool for every assignment.

Exercises

RecallWhy doesn't the program copy anything host → device?

The kernel generates its own inputs from the thread index (via scale), and ref is passed by value as a kernel argument. Only results need to come back.

ExplainWhy don't we need cudaDeviceSynchronize() in this version?

cudaMemcpy device → host is synchronous: it waits for the kernel to finish before copying.

ExplainWhy is there no zero in the output even though ref = 0.5 is in [0, 1]?

The points are i/63, and 0.5 = 31.5/63 falls exactly between two of them. The closest points are 31/63 and 32/63, both 1/126 ≈ 0.0079 away.

ApplyRewrite distance to be cheaper. Would the results change?

return fabsf(x2 - x1); avoids a multiply and a sqrt. The results would be the same or differ only in the last bit, since sqrt of a square can round slightly differently from the exact absolute value.

ApplyIn Assignment 1 your CPU and GPU vector add results match exactly. Is that consistent with 7.3?

Yes. Each element is a single float addition, which IEEE-754 requires to be correctly rounded on both, with no ordering or FMA involved. Differences appear once there are chains of operations, like matmul's sum over k.

Lab: break it and catch it

  1. Compile 2-first-cuda-code.cu with N = 100, TPB = 32. Print the last 6 results.
  2. Change the launch to 4 blocks but no guard, and run compute-sanitizer ./demo.
  3. Add the guard and the rounded-up block count. Re-run the sanitizer.
Solution and expected output

Step 1: indices 94 and 95 print correct values (≈ 0.449 and 0.460). 96–99 print garbage or 0, where they should be ≈ 0.470, 0.480, 0.490, 0.500.

Step 2: the sanitizer reports Invalid __global__ write of size 4 for threads 100–127 in block 3, giving the address and the line of d_out[i] = ….

Step 3: ERROR SUMMARY: 0 errors, and index 99 prints 0.5.

Module 8 · Colab Tutorial 1, Lecture 2 notebook

Profiling: Nsight Systems, Nsight Compute and occupancy

Measure instead of guessing, and know which tool answers which question.

  • After this module you can: choose between nsys and ncu, read their key tables, add NVTX ranges, time code correctly, and compute theoretical occupancy by hand.

8.1 Two tools, two questions

ToolQuestion it answersCommands
Nsight Systems (nsys)Where does my program's time go? A timeline of CPU calls, copies and kernels.nsys profile --stats=true ./app
nsys nvprof ./app (nvprof-style summary)
Nsight Compute (ncu)Why is this kernel slow? Occupancy, memory throughput, roofline, per-line stats.ncu --section=Occupancy ./app
ncu --section=SpeedOfLight ./app
ncu --list-sections

Rule of thumb: start with nsys to find the biggest cost. Only if it's a kernel do you dig in with ncu. ncu replays each kernel several times to collect counters (the "3 passes" in Tutorial 1), so it's slow and its timings aren't meaningful.

8.2 Reading nsys output

  • cuda_api_sum: CPU-side time in each CUDA call. Remember context creation inflates the first call (Module 5).
  • cuda_gpu_kern_sum: GPU time per kernel: total, instances, average, min, max. You'll use this table for Assignment 1 Q3 (QuEST's top kernels).
  • cuda_gpu_mem_time_sum / _size_sum: time and bytes per copy direction. Divide bytes by time to get your effective PCIe bandwidth.

In Lecture 2's profile, the distance kernel took 2,464 ns and the copy back 1,920 ns, but cudaMalloc took 183 ms. Almost all of the program's time is start-up overhead, which tells you the problem is far too small for a GPU.

8.3 NVTX: labelling your code

#include <nvtx3/nvToolsExt.h>
nvtxRangePushA("H2D copy");
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
nvtxRangePop();

Profile with nsys profile --trace=cuda,nvtx --stats=true ./app. The nvtx_sum table and the timeline then show your named phases. Ranges are measured on the CPU, so a range around a kernel launch alone measures only the launch, the same trap as with timers.

8.4 Timing correctly

For the assignments' stacked bar charts you need CPU timers around each phase. Two rules: synchronize before reading the timer, and exclude one-off start-up costs.

double now_ms() { using namespace std::chrono;
  return duration<double, std::milli>(steady_clock::now().time_since_epoch()).count(); }

cudaFree(0);                         // create the context before timing
double t0 = now_ms();
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
double t1 = now_ms();
kernel<<<g, b>>>(…);
cudaDeviceSynchronize();             // without this you time only the launch
double t2 = now_ms();

For more reliable numbers, run each size a few times and report the median; the first launch of a kernel is often slower (module loading).

8.5 Occupancy

occupancy = active warps per SM ÷ maximum warps per SM (max = 32 on T4, 64 on H100)

Theoretical occupancy is the best the resource limits allow. An SM's resident blocks are limited by whichever runs out first:

  1. Warp slots: max warps per SM ÷ warps per block.
  2. Block slots: max blocks per SM (16 on a T4, 32 on an H100).
  3. Registers: 65,536 ÷ (registers per thread × threads per block).
  4. Shared memory: shared memory per SM ÷ shared memory per block.

Achieved occupancy is what was measured while the kernel ran. It's lower than theoretical when there aren't enough blocks to fill the GPU, when blocks finish unevenly, or when the kernel is so short that start-up and tail dominate.

Worked example: compute occupancy by hand

Problem 1. The Lecture 2 demo: <<<2, 32>>> on a T4. ncu reported 50% theoretical, 3.12% achieved. Explain both.

  1. Warps per block = 32/32 = 1.
  2. Warp-slot limit: 32/1 = 32 blocks. Block-slot limit: 16. Registers and shared memory: tiny kernel, not limiting.
  3. The tightest limit is 16 blocks × 1 warp = 16 warps; 16/32 = 50% theoretical. Tiny blocks run out of block slots before warp slots.
  4. But only 2 blocks exist for 40 SMs. A busy SM has 1 warp: 1/32 = 3.12% achieved. The fix is more work, not tuning.

Problem 2. A kernel uses 256 threads per block and 80 registers per thread on a T4. What's the theoretical occupancy?

  1. Warps per block = 8. Warp-slot limit: 32/8 = 4 blocks. Block-slot limit: 16.
  2. Register limit: 80 × 256 = 20,480 per block; 65,536 / 20,480 = 3.2, so 3 blocks.
  3. Tightest: 3 blocks × 8 warps = 24 warps; 24/32 = 75%. (Real hardware allocates registers in chunks, so ncu may report slightly lower.) Check registers per thread with nvcc --resource-usage.
Is higher occupancy always better? No. Occupancy is a means of hiding latency. Once there are enough warps to keep memory busy (often around 50%), more doesn't help, and some of the fastest kernels deliberately use many registers per thread at low occupancy. Treat low occupancy as a clue, not a verdict.

Exercises

RecallWhich tool shows a timeline of the whole program, and which analyzes one kernel?

Nsight Systems (nsys) for the timeline; Nsight Compute (ncu) for one kernel.

ExplainWhy shouldn't you use kernel times from ncu in your bar charts?

ncu replays each kernel several times with hardware counters, flushes caches and serializes work, so its durations aren't representative. Use nsys or synchronized CPU timers for timing.

ExplainWhat's the difference between theoretical and achieved occupancy?

Theoretical is the maximum resident warps allowed by the resource limits. Achieved is the average actually observed, which drops when there's too little work, uneven blocks or a short kernel.

ApplyAssignment 1 Q1 with N = 512, TPB = 256 on a T4: theoretical and roughly achieved occupancy?

8 warps per block: warp-slot limit 4 blocks = 32 warps → 100% theoretical. But only 2 blocks exist, so 2 of the 40 SMs have 8 warps each: achieved is low (ncu typically shows ~15–25% on the active SMs, since the kernel is very short). With N = 263,149 there are 1028 blocks, ~26 per SM, so achieved rises a lot (often 70–90%).

ApplyA kernel uses 48 KB of shared memory per block on a T4 with 64 KB per SM. What's the most blocks per SM, and what does it mean for 128-thread blocks?

64/48 → 1 block per SM. With 128 threads = 4 warps, occupancy is 4/32 = 12.5%. Reduce shared memory per block or use larger blocks.

Lab: profile your Assignment 1 vector add

  1. Add NVTX ranges around the H2D copy, the kernel (with sync) and the D2H copy.
  2. Run nsys profile --trace=cuda,nvtx --stats=true ./vectorAdd 16777216. From the memory tables, compute your effective PCIe bandwidth.
  3. Run ncu --section=SpeedOfLight ./vectorAdd 16777216. Predict first: which of "Memory Throughput" and "Compute Throughput" will be near 100%?
Solution and expected output

Step 2: H2D moves 2 × 64 MB = 134 MB. On Colab expect roughly 10–25 ms, so 5–13 GB/s effective (pageable host memory is slower than PCIe's peak). The kernel takes about 0.6–0.8 ms, so copies are about 95% of the total, matching your stacked bar chart.

Step 3: Memory (DRAM) throughput around 85–95%, compute throughput in the low single digits, confirming Module 4's prediction that vector add is purely memory-bound.

Module 9 · 2-CUDA-Computation

First parallel patterns: map, stencil, matrix multiply

Three shapes of computation that cover most of what you'll write in this course.

  • After this module you can: recognise map, stencil and matmul patterns, write each as a kernel with correct boundaries, explain why stencils need two buffers, and count the reads that later optimizations remove.

9.1 Map: each output depends on one input

Vector add, scaling and the distance kernel are all maps: output i depends only on input i. One thread per element, no communication. Maps are always memory-bound unless the per-element work is heavy.

9.2 Stencil: each output depends on its neighbours

The 1D heat equation models heat diffusing along a rod. Each step, a point moves toward the average of its neighbours:

u_new[i] = u[i] + α · ( u[i−1] − 2·u[i] + u[i+1] ) stable for α ≤ 0.5
__global__ void heatStep(const float *u, float *u_new, int n, float alpha) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i >= n) return;
  if (i == 0 || i == n - 1) { u_new[i] = u[i]; return; }    // fixed boundary values
  u_new[i] = u[i] + alpha * (u[i-1] - 2.0f * u[i] + u[i+1]);
}
// host: iterate, swapping buffers between steps
for (int t = 0; t < steps; t++) {
  heatStep<<<blocks, TPB>>>(d_u, d_unew, n, 0.25f);
  float *tmp = d_u; d_u = d_unew; d_unew = tmp;              // swap pointers, not data
}

Three things matter:

  • Boundaries: thread 0 has no u[-1]. Decide a rule (fixed values here) and guard it.
  • Two buffers: if a thread wrote into u while its neighbour still needs the old value, the result would depend on which thread runs first, and threads have no guaranteed order. Read from one array, write to another.
  • Synchronizing between steps: threads in different blocks can't sync with each other inside a kernel, so each time step is a separate launch. Launches in the same stream run in order, so step t+1 always sees step t's results.

The same pattern is a 1D convolution with mask (α, 1−2α, α). General convolution uses a mask of any width.

9.3 2D Jacobi: the 5-point stencil

__global__ void jacobi(const float *in, float *out, int W, int H) {
  int c = blockIdx.x * blockDim.x + threadIdx.x;
  int r = blockIdx.y * blockDim.y + threadIdx.y;
  if (c < 1 || r < 1 || c >= W - 1 || r >= H - 1) return;   // skip boundary
  out[r*W + c] = 0.25f * (in[(r-1)*W + c] + in[(r+1)*W + c]
                        + in[r*W + c - 1] + in[r*W + c + 1]);
}

Each input value is read by 4 different threads (its 4 neighbours). That reuse is wasted when every read goes to global memory. Loading a tile into shared memory once and reading it 4 times from there is the classic optimization in a later lecture.

9.4 Matrix multiply

C (M×P) = A (M×N) · B (N×P). Element C[r][c] is the dot product of row r of A and column c of B. One thread per element of C, with a 2D grid as in Module 6:

__global__ void matmul(const float *A, const float *B, float *C, int M, int N, int P) {
  int c = blockIdx.x * blockDim.x + threadIdx.x;
  int r = blockIdx.y * blockDim.y + threadIdx.y;
  if (r >= M || c >= P) return;
  float sum = 0.0f;
  for (int k = 0; k < N; k++)
    sum += A[r * N + k] * B[k * P + c];
  C[r * P + c] = sum;
}
  • FLOPs: each of the M·P outputs does N multiplies and N adds: 2·M·N·P in total.
  • Global reads: each output reads N elements of A and N of B: 2·M·N·P reads, plus M·P writes.
  • Reuse: each element of A is read P times (by every thread in its row of C) and each element of B M times. A only has M·N distinct elements, so almost all reads are repeats. Tiling in shared memory removes them.
  • Access patterns: within a warp, B[k*P + c] is coalesced (c varies by lane), and A[r*N + k] is the same address for all lanes in a row, a broadcast. Both are efficient; the issue is the volume of reads, not their pattern.
Worked example: count the work

Problem. A is 128 × 256 and B is 256 × 32 (Assignment 1 Q2, part 2). Count FLOPs, global reads, threads and blocks with 16×16 blocks.

  1. M = 128, N = 256, P = 32. FLOPs = 2·128·256·32 = 2,097,152.
  2. Global reads = 2·M·N·P = 2,097,152 elements (8 bytes each in double = 16.8 MB of traffic, for 256 KB + 64 KB of distinct input).
  3. Grid = (⌈32/16⌉, ⌈128/16⌉) = (2, 8) = 16 blocks × 256 = 4,096 threads, all doing work (M·P = 4,096).
  4. Occupancy prediction: 16 blocks on 40 SMs means at most one 8-warp block per SM, so achieved occupancy will be low (around 25% or less). The problem is too small for the GPU.

Exercises

RecallGive the FLOP count and global read count for naive matmul.

Both are 2·M·N·P (plus M·P writes).

ExplainWhy must the Jacobi kernel use two arrays?

Threads run in no guaranteed order. Updating in place means some threads read neighbours that are already updated and others the old values, so the result is nondeterministic and isn't the Jacobi method.

ExplainWhy is each time step of the heat equation a separate kernel launch?

Step t+1 needs all of step t's results, including those computed by other blocks. There's no barrier across all blocks inside a normal kernel, but consecutive launches in one stream are ordered, so each launch boundary acts as a global synchronization.

ApplyHow many times is each element of A read from global memory in naive matmul with A 1024×8191, B 8191×8197?

P = 8197 times (once by each thread in that row of C). Each element of B is read M = 1024 times.

ApplyWhat happens if you run the heat equation with α = 0.6?

It's unstable (α must be ≤ 0.5): small errors grow every step and the solution oscillates and blows up toward huge or infinite values. It's a property of the numerical method, not a GPU bug.

ApplyWrite the 1D heat step as a convolution with a 3-element mask. What is the mask?

u_new[i] = α·u[i−1] + (1 − 2α)·u[i] + α·u[i+1], so the mask is [α, 1 − 2α, α]; for α = 0.25 it's [0.25, 0.5, 0.25].

Lab: simulate heat diffusion

  1. N = 1024, α = 0.25. Initial condition: u = 1 for the 10 points in the middle (i = 507…516), 0 elsewhere, with fixed 0 at both ends.
  2. Run 1000 steps, swapping pointers. Every 250 steps copy back and print the max value and the sum of u.
  3. Predict before running: does the max go up or down? Does the sum stay constant?
  4. Profile N = 2²⁰ with 1000 steps in nsys. Is the total time mostly kernels or launch overhead?
Solution and expected output
int n = 1024, steps = 1000, TPB = 256, blocks = (n + TPB - 1) / TPB;
std::vector<float> h(n, 0.0f);
for (int i = n / 2 - 5; i < n / 2 + 5; i++) h[i] = 1.0f;
float *d_u, *d_unew;
cudaMalloc(&d_u, n * 4); cudaMalloc(&d_unew, n * 4);
cudaMemcpy(d_u, h.data(), n * 4, cudaMemcpyHostToDevice);
cudaMemcpy(d_unew, h.data(), n * 4, cudaMemcpyHostToDevice);   // boundaries too
for (int t = 1; t <= steps; t++) {
  heatStep<<<blocks, TPB>>>(d_u, d_unew, n, 0.25f);
  std::swap(d_u, d_unew);
  if (t % 250 == 0) {
    cudaMemcpy(h.data(), d_u, n * 4, cudaMemcpyDeviceToHost);
    float mx = 0, s = 0; for (float v : h) { mx = fmaxf(mx, v); s += v; }
    printf("step %4d  max %.4f  sum %.2f\n", t, mx, s);
  }
}

What you should see: the max falls quickly: roughly 0.36 at step 250, 0.25 at 500, 0.21 at 750 and 0.18 at 1000, as the spike spreads into a bell curve whose width grows like √t. The sum stays at 10.00, since heat is conserved until the curve reaches the ends (far more steps than 1000 for N = 1024).

Step 4: each kernel at N = 2²⁰ moves about 8 MB, which takes ~30 µs; launch overhead is a few µs per launch. With 1000 launches, kernels dominate but overhead is visible as gaps on the timeline. At N = 1024 each kernel takes only ~2 µs and launch overhead dominates completely.

Module 10 · Book bonus Kirk & Hwu, Chapter 1 (3rd ed.) §1.1, 1.3–1.7

Why parallel, what speed-ups are realistic, and why it's hard

The textbook's big-picture argument, which the course assumes you know.

  • After this module you can: explain why the industry moved to parallel hardware, use the book's Amdahl examples, describe the "peach" model of an application, name the four challenges of parallel programming, and compare CUDA with MPI, OpenMP, OpenACC and OpenCL.

10.1 The end of the free lunch (§1.1)

Until about 2003, each new CPU ran existing programs faster because clock speeds kept rising. Then power and heat stopped that: a faster clock costs disproportionately more energy. Since then, vendors add more cores instead. A sequential program no longer gets faster on new hardware; only parallel programs do. The book calls this the concurrency revolution, and it describes two design paths:

  • Multicore (CPUs): a few cores, each keeping the full speed of a sequential processor.
  • Many-thread (GPUs): huge numbers of simple threads, optimized for total throughput (Module 1).

The book's main motivation for learning CUDA is scalability: write code that keeps getting faster as future GPUs add more SMs.

10.2 Why more speed? (§1.3)

The book argues that tomorrow's mass-market applications are today's "super-applications": molecular simulation in biology and medicine, high-resolution video processing, natural user interfaces, physics-based games. They all simulate a concurrent physical world with huge amounts of data, so they're naturally parallel. Its two rules of thumb:

  • An application with data parallelism can often get a 10× speed-up with a few hours of work.
  • A good, carefully optimized GPU implementation can reach more than 100× over one CPU core.

This is directly relevant to your programme: molecular and medical simulation is one of the book's headline examples.

10.3 Speeding up real applications (§1.4)

The book makes Amdahl's law (Module 4) concrete with two examples. Check them yourself:

Parallel fractionParallel part sped upNew timeOverall speed-up
30%100×70% + 0.3% = 70.3%≈ 1.4×
30%∞70%1.43× at most
99%100×1% + 0.99% = 1.99%≈ 50×

Three further points from this section:

  • 100×+ needs more than 99.9% of the time in parallel code, and that usually requires improving the algorithm, not just porting it.
  • Straightforward ports usually saturate DRAM bandwidth and stop at around 10×. Going further means using on-chip memories (shared memory, registers) to cut DRAM accesses, then working around their limited capacity. This is the arc of the whole course.
  • Give the CPU a fair chance. Some code runs very well on CPUs. A GPU speed-up measured against a poor, single-threaded CPU baseline is misleading. Keep this in mind when you report speed-ups in your project.
The peach model. The book pictures an application as a peach. The pit is sequential code: a large share of the source, hard to parallelize, good for CPUs, but usually a small share of the runtime. The flesh is the data-parallel portions, easy to parallelize and where the runtime goes. CUDA aims to cover as much of the flesh as possible.

10.4 Four challenges of parallel programming (§1.5)

"If you don't care about performance, parallel programming is very easy." The hard part is speed, and the book names four reasons:

  1. Algorithmic complexity. Some parallel algorithms do much more total work than the sequential version (they aren't work-efficient), so they can be slower on large inputs. Example: a naive parallel prefix sum does O(n log n) work against O(n) sequentially.
  2. Memory-bound execution. Many applications are limited by memory access speed rather than by instructions (Module 4), which calls for new ways of improving data access.
  3. Input sensitivity. Parallel performance varies much more with the characteristics of the input: irregular sizes, sparse structure, unpredictable data rates. This causes load imbalance and warp divergence.
  4. Recurrences. Problems naturally written as "step n depends on step n−1" need non-intuitive reformulations to parallelize, sometimes with redundant work.

The good news: researchers have solved most of these for common patterns (reduction, scan, stencil, histogram, sparse matrix-vector product, and so on), and the rest of the book and course teaches them pattern by pattern.

10.5 Programming models compared (§1.6)

ModelTargetsHow you express parallelismBook's view
MPIClusters; nodes share no memoryExplicit message sending and receiving; you partition data across nodes (domain decomposition)Scales to 100,000+ nodes but porting is a lot of work. HPC codes combine MPI between nodes with CUDA within each node.
OpenMPShared-memory multicore CPUsCompiler directives and pragmas on loops; a runtime manages threadsEasy to add to existing code; originally CPU-only.
OpenACCHeterogeneous CPU+GPU systemsDirectives; the compiler generates GPU codeGood "performance portability", but you still need to understand the CUDA concepts, and compilers sometimes fall short.
OpenCLProcessors from many vendorsLike CUDA, but relies more on APIs than on language extensionsRuns unmodified on any supporting processor, but usually needs re-tuning per device. Concepts carry over directly from CUDA.
CUDANVIDIA GPUsLanguage extensions (kernels, <<< >>>) and explicit memory managementGives explicit control of every detail, which makes it the best vehicle for learning, even if you later use something else.

The book also mentions unified address spaces between CPU and GPU (the idea behind CUDA's Unified Memory, one of your possible project topics), which remove much of the manual copying.

10.6 The book's three goals (§1.7)

  1. High performance through "computational thinking": reasoning about problems in ways that suit parallel hardware. You need hardware knowledge, taught as part of programming techniques.
  2. Correctness and reliability: fast code you can debug and support, using simple barrier synchronization, memory consistency and atomics.
  3. Scalability across future hardware: the key is to regularize and localize memory accesses, minimizing use of critical resources and conflicts when updating data.
Worked example: a speed-up claim, reviewed

Claim. "Our GPU kernel is 80× faster than the CPU loop, so our medical imaging pipeline will be about 80× faster." The kernel's loop was 85% of the CPU runtime. Moving data to and from the GPU adds 5% of the original runtime.

  1. Original time: 1 (normalized). Serial part: 0.15. Parallel part: 0.85.
  2. New time = 0.15 + 0.85/80 + 0.05 = 0.15 + 0.0106 + 0.05 = 0.2106.
  3. Overall speed-up = 1/0.2106 ≈ 4.7×, not 80×. Even with an infinitely fast kernel: 1/(0.15 + 0.05) = 5×.
  4. What to do next: profile the remaining 15% (can any of it move to the GPU?), keep data on the GPU between steps to remove copies, and check that the CPU baseline was multithreaded (§1.4's "fair chance").

Exercises

RecallWhy did single-threaded performance stop improving around 2003?

Power and heat: raising clock speeds further cost too much energy. Vendors switched to adding cores, so only parallel software keeps getting faster.

RecallWhat are the book's four challenges of parallel programming?

Matching sequential algorithmic complexity (work efficiency), memory-bound execution, sensitivity to input characteristics, and problems described as recurrences.

ExplainWhy do straightforward GPU ports often stop at about 10×?

They saturate DRAM bandwidth: every operation reads its data from global memory. Further gains require reusing data from on-chip memories to cut DRAM traffic.

ExplainWhy does the book use CUDA to teach, even for people who'll use OpenACC?

CUDA makes every parallel detail explicit (threads, memory spaces, transfers), so you learn the concepts directive-based models hide. Using OpenACC well still requires them, and you'll need CUDA where the compiler falls short.

ApplyReproduce the book's numbers: 99% parallel, 100× faster parallel part. Then compute the speed-up if the parallel part were 1000× faster.

100×: new time = 0.01 + 0.0099 = 0.0199, speed-up ≈ 50×. 1000×: 0.01 + 0.00099 = 0.01099, speed-up ≈ 91×. The limit is 100×.

ApplyWhich of the four challenges applies to (a) a sparse-matrix solver, (b) a prefix sum, (c) a time-stepping simulation where each step depends on the last?

(a) Input sensitivity: rows have very different numbers of non-zeros, giving load imbalance; it's also memory-bound. (b) Algorithmic complexity: the simple parallel scan isn't work-efficient. (c) Recurrence: steps can't run in parallel, so you parallelize within each step, one launch per step (Module 9).

Module 11 · Review

Oral exam drill

Answer each question out loud in about a minute, with no notes. If you hesitate, go back to the module in brackets. Repeat weekly until all of them are easy.

Concepts

ExplainHow does a GPU hide memory latency, and what does that mean for how many threads you launch? [2]

A strong answer covers: warps of 32 run together; when a warp waits on memory the scheduler issues from another ready warp; switching is free because every resident warp keeps its registers on the SM; so you need many more threads than cores, enough warps per SM to cover a ~500-cycle latency.

ExplainWhy does the GPU have small caches and a CPU large ones? [1, 2]

The CPU avoids latency for few threads using caches and prediction; the GPU tolerates it with many threads and spends the area on arithmetic units. Mention the throughput vs latency trade-off.

ExplainWhat limits how many blocks can be resident on one SM? [3, 8]

Max warps per SM, max blocks per SM, registers (65,536 shared by all threads) and shared memory per SM. The tightest one sets theoretical occupancy.

ExplainMemory-bound versus compute-bound: how do you tell, and why does it matter? [4]

Compare arithmetic intensity (FLOPs per byte) with the machine balance (peak FLOP/s ÷ bandwidth, ≈ 25 on a T4). Below it, only reducing bytes moved helps; above it, only reducing or speeding up operations helps. Confirm with ncu's SpeedOfLight section.

Your Assignment 1 code

ExplainWalk through your vector add host code. Which calls are synchronous? [5]

malloc host buffers → cudaMalloc → cudaMemcpy H2D (synchronous) → kernel launch (asynchronous) → cudaGetLastError → cudaDeviceSynchronize (to time the kernel) → cudaMemcpy D2H (synchronous) → compare with CPU reference → free everything.

ApplyDerive your grid and thread counts for N = 263,149 and for the 1024×8191 by 8191×8197 matmul. [6]

1028 blocks / 263,168 threads / 19 idle. Matmul: grid (513, 64) = 32,832 blocks, 8,404,992 threads, 8,393,728 working, 11,264 idle in the last block column.

ApplyWhy was achieved occupancy low at N = 512 and high at N = 263,149? [8]

At 512 there are 2 blocks for 40 SMs, so most SMs are empty and the kernel is over almost instantly. At 263,149 there are 1028 blocks, many per SM, so the warp slots are filled for most of the kernel.

ApplyWhy do copies dominate vector add, but the kernel dominates large matmul? [4, 9]

Vector add does O(N) work on O(N) data, with AI ≈ 0.08, so PCIe transfers are many times longer than the kernel. Matmul does O(n³) work on O(n²) data, so as n grows the compute (even memory-bound naive compute) outgrows the transfers.

ApplyWhy is float so much faster than double on a T4, but less so on an H100? [3, 4]

The T4 (Turing) has an FP64 rate of 1/32 of FP32, so double compute is extremely slow. The H100 has FP64 at 1/2 of FP32. Also, float moves half the bytes, which helps memory-bound parts on both GPUs by about 2×.

ExplainWhy can't you compare GPU and CPU floating-point results with ==? [7]

Rounding at every operation, combined with FMA contraction, different summation order (addition isn't associative) and fast-math functions, means results can differ in the last bits. Use a relative tolerance.

Big picture

ApplyName three reasons a 50× kernel speed-up can give a much smaller application speed-up. [4, 10]

Amdahl's law (serial code left over), host↔device copies, and a weak CPU baseline that makes the kernel speed-up look bigger than it is. Also start-up costs such as context creation.

ExplainWhy must a stencil use two buffers, and why is each time step a separate launch? [9]

No ordering between threads means in-place updates mix old and new values. There's no barrier across blocks inside a kernel, and launches in one stream are ordered, so each launch boundary synchronizes all blocks.

Part 2 · Lecture 3 (CUDA memory) · Book Chapter 5 · Assignment 2

Memory: coalescing, shared memory and tiling

Part 1 taught you to get correct results. Part 2 is about getting them fast, and on a GPU that almost always means using memory well. Modules 12–15 cover everything Assignment 2 asks for, including the iPIC3D bonus. They teach each technique on a closely related example; the assignment versions are yours to write.

Module 12 · Lecture 3 (3-CUDA-memory) · Book §5.1–5.2 · 3-memory-coalescing.cu

Memory spaces and coalesced access

Why the order in which threads touch memory can change speed by 5× or more.

  • After this module you can: say where each kind of variable lives, explain DRAM bursts, decide whether a warp's access is coalesced, count the memory transactions it causes, and confirm it in Nsight Compute.

12.1 Where does each variable live?

SpaceDeclared asVisible toLives forSpeed
RegistersOrdinary local variables (int i, float x)One threadThe kernelFastest
LocalLarge local arrays, or registers that "spill"One threadThe kernelSlow: physically in global memory (cached)
Shared__shared__All threads of one blockThe kernelFast, on-chip
GlobalcudaMallocAll threads, plus host via copiesUntil cudaFreeSlow, off-chip DRAM
Constant__constant__, set with cudaMemcpyToSymbolAll threads (read-only)The applicationFast when all threads of a warp read the same address

The lecture's check question: in distanceKernel, the local variable x is in a register. Each thread has its own copy and no other thread can see it. d_out is different: the pointer is passed by value into a register in each thread, but the array it points to is in global memory, visible to all threads.

12.2 How DRAM is read: bursts

DRAM stores each bit in a tiny capacitor, and reading a cell is slow: select a bank, open a row, select a column. The DRAM core runs at only a fraction (1/4 or 1/8) of the interface speed. To compensate, every access reads a whole burst of consecutive bytes into a buffer and sends them all. On a modern GPU the unit is a 32-byte sector, grouped into 128-byte lines.

The consequence: reading 4 bytes costs about the same as reading the 32 bytes around them. If threads use those neighbouring bytes, the transfer is fully used. If they don't, most of the bandwidth is wasted.

12.3 Coalescing

All 32 threads of a warp execute a load instruction together. The hardware collects their 32 addresses and issues as few sector requests as possible. When the threads access consecutive addresses, typically array[base + threadIdx.x], the 32 floats (128 bytes) fit in 4 sectors. That's a coalesced access.

coalesced: thread t reads a[t] strided: thread t reads a[t * 8192] each box = one 32-byte sector fetched from DRAM; filled part = bytes actually used

When the addresses are spread out, the warp needs many sectors and uses only 4 bytes of each. Examples: a[threadIdx.x * stride], a column of a row-major matrix, or an array of large structs.

efficiency = bytes the threads need ÷ bytes fetched (coalesced float: 128/128 = 100%; worst case: 128/1024 = 12.5%)

12.4 Matrices: rows good, columns bad

For a row-major matrix, element (r, c) is at r * cols + c. Consecutive columns are adjacent in memory and consecutive rows are cols elements apart. So:

  • If neighbouring threads (consecutive threadIdx.x) handle neighbouring columns, accesses coalesce.
  • If they handle neighbouring rows, each thread is cols × 4 bytes from the next. In the demo that's 32 KB, a different sector for every thread.

This is the whole difference between the two kernels in 3-memory-coalescing.cu. They do the same copy; only the mapping of x and y changes. In naive matrix multiply (Module 9), threads in a warp read B[k*P + c], which is coalesced, and the same A[r*N + k], a broadcast. That's why naive matmul is still reasonable.

12.5 Book Chapter 5: two more levers

  • Memory parallelism (§5.2). DRAM has several channels and banks that work in parallel. To use them all, many requests must be in flight at once, which is another reason to have many warps resident (Module 2's Little's law).
  • Thread granularity (§5.5). Sometimes it pays to give each thread more work, e.g. two outputs instead of one, when neighbouring threads would otherwise load the same data twice. Fewer threads, less redundant traffic.
Worked example: the coalescing demo, predicted

Problem. 3-memory-coalescing.cu copies an 8192 × 8192 float matrix with block(32, 1), once row-wise and once column-wise. Predict the sectors per warp request and the run times on a T4.

  1. Data moved: 2 × 8192² × 4 B = 512 MiB (read + write), the matBytes the program prints.
  2. Row-wise: the 32 threads of a warp have consecutive c, so they read 32 consecutive floats = 128 B = 4 sectors per request. At ~260 GB/s achievable: 537 MB / 260 GB/s ≈ 2 ms.
  3. Column-wise: the 32 threads have consecutive r, so their addresses are 8192 × 4 = 32 KB apart: 32 sectors per request, each 32 B for 4 useful bytes. Up to 8× more DRAM traffic.
  4. In practice the slowdown is less than 8× (often 3–6×). Neighbouring warps read the adjacent columns soon after, so some sectors are still in L2 when needed. That partial rescue by the cache is worth mentioning in a report.
  5. To confirm, Nsight Compute's Memory Workload Analysis shows sectors per request (4 vs 32) and DRAM throughput, and SourceCounters flags the uncoalesced line.

Exercises

RecallWhere do these live: int i in a kernel, float buf[1000] in a kernel, an array from cudaMalloc, __shared__ float t[256]?

Register; local memory (too big for registers, so it goes to off-chip memory); global; shared.

ExplainWhy does reading one float cost almost as much as reading eight neighbouring floats?

DRAM always transfers whole bursts (32-byte sectors on the GPU). The slow part is opening the row and starting the transfer; once that's done, the neighbouring bytes come almost for free.

ExplainWhat's the difference between L1 cache and shared memory, given they're the same hardware?

L1 is managed by hardware: it decides what to keep, and you can't rely on it. Shared memory is managed by the programmer: you choose exactly what to load, when, and who uses it, and it stays until the block ends. Since Volta they share one pool, and the split is configurable.

ApplyA kernel reads a[2 * threadIdx.x] (floats). How many sectors does a warp need, and what's the efficiency?

The warp touches 64 consecutive floats = 256 B = 8 sectors, but uses only every other float: 128/256 = 50%.

ApplyParticles are stored as struct {double x,y,z,u,v,w;} p[N] and a kernel updates only p[i].x. Coalesced? How would you fix it?

No. Each struct is 48 bytes, so consecutive threads' x values are 48 B apart: 32 threads span 1,536 B for 256 useful bytes (about 17%). Use a structure of arrays (separate x[], y[], …), so the x values are contiguous. This matters for the iPIC3D bonus (Module 15).

ApplyIn the 2D kernels of Assignment 1, why does col = blockIdx.x*blockDim.x + threadIdx.x matter?

It puts consecutive threads on consecutive columns, so reads and writes of C[row*P + col] and B[k*P + col] are coalesced. Swapping it makes every warp access strided.

Lab: measure coalescing

  1. Add a CHECK macro and CUDA-event timers around each kernel in 3-memory-coalescing.cu, and print the time and effective bandwidth (matBytes / time).
  2. Predict both numbers using the worked example, then run on Colab.
  3. Profile with ncu --section MemoryWorkloadAnalysis_Tables --section SourceCounters ./coalescing and find sectors per request for each kernel.
  4. Change the block to (32, 8) and then (8, 32). Which kernel improves, and why?
Solution and expected output
cudaEvent_t s, e; cudaEventCreate(&s); cudaEventCreate(&e); float ms;
cudaEventRecord(s);
copyRowWise<<<gridRow, block>>>(d_in, d_out, ROWS, COLS);
cudaEventRecord(e); cudaEventSynchronize(e); cudaEventElapsedTime(&ms, s, e);
printf("row-wise    %.2f ms  %.0f GB/s\n", ms, matBytes / ms / 1e6);
// same for copyColumnWise

Expected on a T4: row-wise about 2–2.5 ms (210–260 GB/s). Column-wise several times slower, typically 6–12 ms. ncu: about 4 sectors/request for row-wise, close to 32 for column-wise.

Step 4: with (32, 8) the row-wise kernel stays fast (x still runs along columns). With (8, 32) only 8 consecutive threads share a row, so each warp covers 4 rows × 8 columns and the row-wise kernel gets slower (4 separate 32-byte pieces per request), while column-wise improves a little. The rule: make blockDim.x at least 32 along the contiguous dimension.

Module 13 · Lecture 2.4 (Shared and Atomics) · Lecture 3 · 2D Shared Memory tutorial · kernel.cu dot product

Shared memory, synchronization and atomics

A fast scratchpad that threads of a block share, and the rules that keep its use correct.

  • After this module you can: declare static and dynamic shared arrays, use __syncthreads() correctly (and spot when it deadlocks), handle halo cells, explain race conditions and fix them with atomics, write a tree reduction, and explain bank conflicts.

13.1 Why shared memory?

In the 1D stencil out[i] = (in[i-1] - 2·in[i] + in[i+1]) / h², every input value is read from global memory by three threads. Shared memory removes that redundancy: the block loads its piece of the input once into on-chip memory, then all threads read their neighbours from there. The recipe from the lecture:

  1. Break the global array into tiles, one per block.
  2. Allocate a tile in shared memory.
  3. Fill the tile collectively: each thread loads one (or a few) elements.
  4. Synchronize, then compute using the tile.

13.2 Declaring shared arrays

// static: size known at compile time
__shared__ float s_in[TPB + 2 * RAD];

// dynamic: size given at launch, as the 3rd launch parameter (in bytes)
extern __shared__ float s_in[];
size_t smem = (TPB + 2 * RAD) * sizeof(float);
kernel<<<blocks, TPB, smem>>>(…);
  • A block can normally use up to 48 KB. More needs an opt-in: cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes), up to prop.sharedMemPerBlockOptin (64 KB on a T4, 227 KB on an H100).
  • Several dynamic arrays share one extern buffer: declare one array and split it with pointers, e.g. float *sA = buf; float *sB = buf + tileA_elems;.
  • Shared memory limits occupancy, like registers (Module 8). With 64 KB per SM on a T4 and 16 KB per block, at most 4 blocks fit per SM.

13.3 __syncthreads(): the block barrier

Threads of a block run in no particular order, warp by warp. After thread 5 writes s_in[5], thread 6 can't assume thread 7 has written s_in[7] yet. __syncthreads() makes every thread in the block wait until all threads of that block have reached it. Every write before the barrier is then visible to every thread after it.

__syncthreads()cudaDeviceSynchronize()
Called fromGPU code (inside a kernel)CPU code
Waits forAll threads of the same blockAll GPU work queued by the host
Use it forShared memory is filled, or finished with, before the next phaseTiming, error checks, reading results
The deadlock rule. Every thread of the block must reach the same __syncthreads(). If some threads return early or skip it inside an if, the behaviour is undefined: the kernel may hang or silently compute garbage. So don't put if (i >= n) return; before a barrier. Instead, let out-of-range threads load a neutral value (0) and skip only the final write.

13.4 Halo cells

A stencil of radius RAD needs RAD extra values on each side of the tile, called halo (or ghost) cells. The tile holds blockDim.x + 2·RAD values, and thread t stores its own value at local index s_idx = t + RAD. The first RAD threads also load the left and right halos. Here's a version that is safe at every boundary:

#define RAD 1
__global__ void ddShared(float *out, const float *in, int n, float h) {
  extern __shared__ float s[];
  int i = blockIdx.x * blockDim.x + threadIdx.x;   // global index
  int s_idx = threadIdx.x + RAD;                   // local index
  s[s_idx] = (i < n) ? in[i] : 0.0f;               // body (0 if past the end)
  if (threadIdx.x < RAD) {                         // halos
    int L = i - RAD, R = i + blockDim.x;
    s[s_idx - RAD]        = (L >= 0) ? in[L] : 0.0f;
    s[s_idx + blockDim.x] = (R < n)  ? in[R] : 0.0f;
  }
  __syncthreads();                                 // ALL threads reach this
  if (i > 0 && i < n - 1)                           // guard only the write
    out[i] = (s[s_idx - 1] - 2.f * s[s_idx] + s[s_idx + 1]) / (h * h);
}
// launch: ddShared<<<blocks, TPB, (TPB + 2*RAD) * sizeof(float)>>>(…)
Two bugs in the lecture slide's version. (1) It does if (i >= size) return; before __syncthreads(), which breaks the barrier in the last, partial block. (2) Thread 0 of block 0 reads d_in[i - RAD] = d_in[-1], and the last block reads past the end: out-of-bounds accesses. compute-sanitizer catches the second. Fixing both is a good oral-exam story.

13.5 Race conditions and atomics

The dot product example (main.cpp, kernel.cu) first has each block sum its own products in shared memory, then adds the block sum to one global result. With ATOMIC 0 it does *d_res += blockSum;. That single line is really three steps: read d_res, add, write. When two blocks do this at once:

TimeBlock 3Block 7d_res
1reads 128128
2reads 128128
3writes 128 + 64 = 192192
4writes 128 + 64 = 192192 (should be 256)

One update is lost. This is a race condition: the result depends on timing, so it can differ between runs and even be right sometimes, which makes it dangerous. atomicAdd(d_res, blockSum) does the read-add-write as one indivisible operation, so no update is lost. Atomics exist for add, sub, min, max, exch, CAS and bitwise operations, on int, float and (since Pascal) double.

Cost: atomics on the same address are serialized. One atomic per block (as here) is cheap; one per thread on a single counter can be very slow. That's why we reduce within the block first.

13.6 Doing the block sum in parallel: tree reduction

In kernel.cu, thread 0 adds all 64 products alone while 63 threads wait, which is O(TPB) serial steps. A tree reduction halves the active threads each step, so it finishes in log₂(TPB) steps:

__shared__ int s[TPB];                                  // TPB a power of 2
s[tid] = (idx < n) ? a[idx] * b[idx] : 0;              // 0 for threads past n
__syncthreads();
for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
  if (tid < stride) s[tid] += s[tid + stride];
  __syncthreads();                                      // outside the if: every thread reaches it
}
if (tid == 0) atomicAdd(res, s[0]);

With stride halving from the top, active threads are always the contiguous tid < stride, so whole warps either work or idle. That avoids divergence until stride < 32.

13.7 Bank conflicts

Shared memory is divided into 32 banks, each 4 bytes wide; element s[i] (float) is in bank i % 32. If the 32 threads of a warp access 32 different banks (or the same word, which broadcasts), the access takes one cycle. If k threads hit different words in the same bank, it takes k cycles: a k-way bank conflict.

  • s[threadIdx.x]: conflict-free.
  • s[threadIdx.x * 2]: 2-way conflict.
  • Column of a 32×32 tile, tile[threadIdx.x][c]: every element is in the same bank, a 32-way conflict. The classic fix is padding: __shared__ float tile[32][33]; shifts each row by one bank.

The 2D Shared Memory tutorial (needed for Assignment 3) explores exactly this with setRowReadRow (conflict-free) against setColReadCol (conflicts), using a 16 × 32 tile. Nsight Compute reports conflicts under Memory Workload Analysis.

Worked example: find every bug in the dot product

Problem. kernel.cu is run with N = 1024, TPB = 64. Predict the output with ATOMIC 1 and ATOMIC 0, then find the bugs that would appear for N = 1000.

  1. Blocks: 1024/64 = 16. Each block's products are all 1, so each prints blockSum = 64, in any block order.
  2. ATOMIC 1: gpu result = 1024, always matching the CPU.
  3. ATOMIC 0: often still 1024 for only 16 blocks, because they rarely collide, but sometimes less (e.g. 960). Run it 20 times or with more blocks (N = 2²⁰) and the losses become frequent. "It passed once" proves nothing for a race.
  4. N = 1000: the last block (block 15) has 1000 − 960 = 40 valid threads. Threads 40–63 return before __syncthreads(), which breaks the barrier rule.
  5. Thread 0 of that block then sums s_prod[0..63], including 24 entries nobody wrote, so the result is garbage. Fix: no early return; set s_prod[s_idx] = (idx < n) ? d_a[idx]*d_b[idx] : 0; and keep the barrier unconditional.
  6. Also: no error checking, and the printf in the kernel would dominate the runtime for large N.

Exercises

RecallHow do you pass the size of a dynamic shared array?

As the third launch parameter, in bytes: kernel<<<grid, block, bytes>>>, with extern __shared__ float s[]; in the kernel.

RecallWhat does __syncthreads() wait for, and what does it not synchronize?

All threads of the calling block. It does nothing across blocks; for that you need separate kernel launches (or atomics, or cooperative groups).

ExplainWhy is a race condition worse than a crash?

It gives plausible, varying results and may pass tests by luck. A crash is reported; a race can go unnoticed into your results.

ExplainWhy reduce inside the block before the atomicAdd instead of one atomic per thread?

Atomics on the same address are serialized. Per-thread atomics mean N serialized updates; per-block means N/TPB, a factor of TPB fewer. The in-block reduction runs in parallel in fast shared memory.

ApplyA stencil has radius 2 and blocks of 128 threads. How big is the shared tile, and how many global loads does a block make compared with no shared memory?

128 + 2·2 = 132 floats (528 bytes). With shared memory, 132 loads per block. Without, each of the 128 threads loads 5 values = 640 loads. That's about 4.8× fewer global loads (some are cache hits anyway, which is why measured gains are smaller).

ApplyIs this correct? if (tid < stride) { s[tid] += s[tid+stride]; __syncthreads(); }

No. The barrier is inside a condition that only some threads meet, so threads with tid >= stride never reach it: undefined behaviour, a likely hang. Move __syncthreads() outside the if.

ApplyWhich accesses have bank conflicts for __shared__ float t[32][32]: (a) t[ty][tx], (b) t[tx][ty], (c) t[tx][ty] with t[32][33]? (tx = threadIdx.x varies within a warp.)

(a) None: consecutive tx are consecutive banks. (b) 32-way: t[tx][ty] is at tx·32 + ty, and every tx gives the same bank. (c) None: the address is tx·33 + ty, and since 33 mod 32 = 1, each tx lands in a different bank.

Lab: races and reductions

  1. Build the dot product: nvcc -arch=sm_75 main.cpp kernel.cu -o dot. Run with ATOMIC 1, then ATOMIC 0, 20 times each with N = 1024.
  2. Change N to 2²⁰ (remove the printf) and repeat. Predict how often ATOMIC 0 is wrong.
  3. Set N = 1000 and run the original under compute-sanitizer --tool racecheck and --tool synccheck.
  4. Replace the serial block sum with the tree reduction from 13.6 and verify it for N = 1000 and N = 2²⁰.
Solution and expected output
__global__ void dotKernel(int *d_res, const int *d_a, const int *d_b, int n) {
  __shared__ int s[TPB];
  int idx = blockIdx.x * blockDim.x + threadIdx.x, tid = threadIdx.x;
  s[tid] = (idx < n) ? d_a[idx] * d_b[idx] : 0;
  __syncthreads();
  for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
    if (tid < stride) s[tid] += s[tid + stride];
    __syncthreads();
  }
  if (tid == 0) atomicAdd(d_res, s[0]);
}

Step 1: ATOMIC 1 always gives 1024. ATOMIC 0 usually gives 1024 with only 16 blocks, and sometimes less.

Step 2: with 16,384 blocks, ATOMIC 0 is wrong essentially every run, often by a large margin (you might see a few thousand instead of 1,048,576). ATOMIC 1 is always 1,048,576.

Step 3: synccheck reports the barrier divergence in the last block; the result is also wrong, because the 24 unwritten entries are summed.

Step 4: correct for both sizes (1000 and 1,048,576).

Module 14 · Lecture 3 (Tiling) · Book Chapter 5 · Assignment 2 Q1–Q2

Tiling: convolution and matrix multiplication

Load data into shared memory once and reuse it many times. This is the most important optimization in the course.

  • After this module you can: write basic and tiled 1D convolution, derive global read counts for both, use constant memory for a mask, explain the tiled matrix multiply phase by phase, derive its reduction in global reads, handle sizes that aren't multiples of the tile, and reason about choosing a tile size.

14.1 1D convolution

Convolution slides a small mask M of width MW = 2r + 1 over an input array and computes a weighted sum at every position:

P[i] = Σ_{j=0}^{MW−1} M[j] · N[i − r + j] (N[k] = 0 when k is outside the array)

It's used for signal and audio filtering, image blurring and edge detection, CNN layers, and stencils in simulations such as the heat equation (whose mask is [α, 1−2α, α], Module 9).

Basic kernel: one thread per output, reading MW inputs straight from global memory:

#define R 1                                     // example: mask width 3 (the assignment uses 5)
__constant__ float d_M[2 * R + 1];              // mask in constant memory
__global__ void conv_basic(const float *N, float *P, int n) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i >= n) return;                           // fine here: no __syncthreads
  float sum = 0.0f;
  for (int j = -R; j <= R; j++) {
    int k = i + j;
    if (k >= 0 && k < n) sum += d_M[j + R] * N[k];   // out-of-range inputs count as 0
  }
  P[i] = sum;
}
// host: cudaMemcpyToSymbol(d_M, h_M, sizeof(h_M));

Why constant memory for the mask? Every thread reads the same d_M[j] at the same time. Constant memory has a cache that broadcasts one value to the whole warp, so the mask costs almost no global traffic. Mention this in your report: the mask's reads are separate from the input's.

14.2 Tiled 1D convolution

Each input value is needed by MW different outputs. In the tiled kernel, a block loads a tile of TILE inputs plus r halo cells on each side into shared memory once, then each thread computes from shared memory. This is Module 13's halo pattern with the radius as a parameter:

__global__ void conv_tiled(const float *N, float *P, int n) {
  extern __shared__ float s[];                      // TILE + 2R floats
  int i = blockIdx.x * blockDim.x + threadIdx.x, t = threadIdx.x;
  s[t + R] = (i < n) ? N[i] : 0.0f;
  if (t < R) {                                       // left and right halos
    int L = i - R, Rt = i + blockDim.x;
    s[t]                  = (L >= 0) ? N[L]  : 0.0f;
    s[t + blockDim.x + R] = (Rt < n) ? N[Rt] : 0.0f;
  }
  __syncthreads();
  if (i < n) {
    float sum = 0.0f;
    for (int j = 0; j < 2 * R + 1; j++) sum += d_M[j] * s[t + j];
    P[i] = sum;
  }
}
// launch: conv_tiled<<<(n+TILE-1)/TILE, TILE, (TILE + 2*R)*sizeof(float)>>>(d_N, d_P, n);

Counting global memory accesses

Ignoring the mask (constant cache) and the edges of the whole array:

Global reads of NGlobal writes of P
BasicMW per output → about MW · nn
Tiled(TILE + 2r) per block × ⌈n/TILE⌉ blocks → about n · (1 + 2r/TILE)n

The reduction factor is about MW / (1 + 2r/TILE). It approaches MW for large tiles, because the halo overhead 2r/TILE shrinks. At the array ends, inputs outside the array aren't read at all (they're 0), so exact counts are slightly lower. A careful report states whether it counts those.

Real speed-ups are smaller than the count suggests. In the basic kernel, neighbouring threads read overlapping inputs, and L1/L2 caches catch many of those repeats. Tiling guarantees the reuse rather than hoping for it. On a T4 a 1D convolution is often only modestly faster tiled, and for small n launch overhead dominates both. Saying this with profiler numbers to back it is what makes an A-level answer.

Choosing the tile size

  • TILE = threads per block here, so it must be ≤ 1024 and should be a multiple of 32.
  • Bigger tiles mean less halo overhead (2r/TILE) but fewer blocks. For n = 1024 and TILE = 1024 there's only one block, using 1 of 40 SMs.
  • Shared memory per block is (TILE + 2r) × 4 bytes, tiny for 1D, so it doesn't limit occupancy here. That matters for the assignment's question about whether more shared memory helps.
  • Decide by experiment: sweep TILE ∈ {32, 64, 128, 256, 512, 1024}, time each with events, and explain the best one with occupancy and block count.

14.3 Tiled matrix multiplication

Naive matmul (Module 9) reads every element of A P times and every element of B M times from global memory. Tiling exploits that a block of threads computing a T × T tile of C needs the same rows of A and columns of B.

B phase p A phase p C tile computed by one block each phase: load one A tile +one B tile, then T multiply-adds

The kernel runs in phases. In phase p, each thread of the block loads one element of the A tile and one of the B tile into shared memory, the block synchronizes, every thread does T multiply-adds from shared memory, and the block synchronizes again before the tiles are overwritten:

#define T 16                                        // square tile, fixed at compile time
__global__ void matmul_tiled(const float *A, const float *B, float *C, int M, int N, int P) {
  __shared__ float sA[T][T], sB[T][T];
  int tx = threadIdx.x, ty = threadIdx.y;
  int col = blockIdx.x * T + tx, row = blockIdx.y * T + ty;
  float sum = 0.0f;
  for (int p = 0; p < (N + T - 1) / T; p++) {       // phases along the shared dimension
    int aCol = p * T + tx, bRow = p * T + ty;
    sA[ty][tx] = (row < M && aCol < N) ? A[row * N + aCol] : 0.0f;   // 0-padding at edges
    sB[ty][tx] = (bRow < N && col < P) ? B[bRow * P + col] : 0.0f;
    __syncthreads();                                // tiles fully loaded
    for (int k = 0; k < T; k++) sum += sA[ty][k] * sB[k][tx];
    __syncthreads();                                // everyone done before overwrite
  }
  if (row < M && col < P) C[row * P + col] = sum;
}
dim3 block(T, T), grid((P + T - 1) / T, (M + T - 1) / T);

Points to understand line by line:

  • Coalesced loads (corner turning). Both loads index the contiguous dimension with tx: A[row*N + p*T + tx] and B[(p*T+ty)*P + col]. Consecutive threads read consecutive addresses. The lecture calls this corner turning: the load runs along rows even though the computation uses a column of B.
  • Padding with zeros handles M, N, P that aren't multiples of T. A 0 contributes nothing to the sum. There's no early return, because of the barriers.
  • Two barriers per phase: the first so no one reads a tile before it's complete, the second so no one overwrites it while others still read.
  • Shared-memory reads: sA[ty][k] is the same for all threads in a row (broadcast); sB[k][tx] spans consecutive banks. No conflicts.

Counting global reads

Each element of C still needs N products, but each thread now loads only one A and one B element per phase, and there are N/T phases. So each thread loads 2N/T elements instead of 2N:

naive: 2·M·N·P reads tiled (T×T): 2·M·N·P / T reads → T times fewer

With T = 16 that's 16× fewer global reads, and the arithmetic intensity grows by the same factor (Module 4). For non-square tiles, work out separately how many times each A and B element is loaded: each A element is loaded once per block column of the grid, each B element once per block row. That reasoning gives you the formula for a tileX × tileY kernel.

Tile size: the trade-offs

TThreads / blockShared mem / block (float)Read reductionT4 notes
864512 B8×2 warps/block; the 16-block-per-SM limit caps occupancy at 32 warps → OK, but more barriers per useful work
162562 KB16×Usually a good balance
3210248 KB32×One block fills an SM (32 warps); each barrier stalls all 1024 threads; little scheduling flexibility

Bigger tiles aren't automatically faster. Once memory traffic is low enough, the limit moves to shared-memory bandwidth, barrier stalls and occupancy, and the best T has to be measured.

Runtime tile sizes: the assignment's tiled_gemm() takes tileX and tileY as parameters. A static __shared__ float sA[T][T] needs T at compile time, so you'll need dynamic shared memory: one extern buffer split into two arrays, indexed by hand, with the size passed at launch (Module 13.2). Think carefully about which dimension of the A tile and of the B tile each of tileX and tileY controls, and whether the shared dimension's tile width must match between them.

Worked example: counting reads and choosing T

Problem. Square matrices with M = N = P = 2048, float. Compare global reads and traffic for naive matmul and tiled matmul with T = 32. Then estimate the best possible kernel times on a T4.

  1. Naive reads: 2 · 2048³ = 1.72×10¹⁰ elements = 68.7 GB of traffic.
  2. Tiled reads: 1.72×10¹⁰ / 32 = 5.4×10⁸ elements = 2.1 GB of traffic.
  3. FLOPs for both: 2 · 2048³ = 1.72×10¹⁰.
  4. Naive bound: max(1.72×10¹⁰ / 8.1×10¹², 68.7 GB / 320 GB/s) = max(2.1 ms, 215 ms) = 215 ms if every read went to DRAM. Caches catch much of this, so measured times are well below 215 ms, but it's clearly memory-limited.
  5. Tiled bound: max(2.1 ms, 2.1 GB / 320 GB/s = 6.7 ms) = 6.7 ms. Moving closer to the compute bound is the whole point of tiling. Real tiled kernels on a T4 typically take tens of ms here, and cuBLAS approaches the ~2 ms compute bound.

Exercises

RecallName three application domains of convolution.

For example: image processing (blurring, edge detection), audio and signal filtering, convolutional neural networks, and stencil computations in physics simulations. Give your own examples and a sentence on each in the report.

ExplainWhy does tiled matmul need two __syncthreads() per phase?

The first makes sure both tiles are fully loaded before anyone computes. The second makes sure everyone has finished computing with the current tiles before the next phase overwrites them.

ExplainWhy is the mask stored in constant memory, and does it count as global reads?

All threads of a warp read the same mask element at the same time, and the constant cache broadcasts it. After the first access it's served from cache, so it adds almost no DRAM traffic. A precise count should say whether mask reads are included and why.

ApplyMask width 3 (r = 1), n = 1,048,576, TILE = 256. Count global reads of N for the basic and tiled kernels.

Basic: about 3n = 3,145,728 (minus 2 at the array ends). Tiled: 4096 blocks × (256 + 2) = 1,056,768 (minus the 2 out-of-range halos, if you skip loading them). That's about a 3× reduction. Do the same derivation for mask width 5 in your report.

ApplyTiled matmul with T = 16 on A (100 × 50) and B (50 × 70). How many phases, blocks and threads? How many threads write to C?

Phases: ⌈50/16⌉ = 4 (the last one is partly zero-padded). Grid: (⌈70/16⌉, ⌈100/16⌉) = (5, 7) = 35 blocks × 256 = 8,960 threads. Only 100 × 70 = 7,000 write to C; the rest only help load tiles (with zeros where out of range) and pass the barriers.

ApplyWhy might T = 32 be slower than T = 16 on a T4, even though it reads global memory half as often?

A 1024-thread block fills the SM alone, so when its warps wait at a barrier nothing else can run. With T = 16, 4 blocks share the SM and one block's barrier stall is covered by others. Also, once DRAM traffic is low, shared-memory bandwidth and instruction overhead dominate, and halving DRAM reads no longer helps much.

Lab: tiled matmul, square case

  1. Implement matmul_tiled exactly as above with T = 16 and a CPU reference (use double accumulation on the CPU).
  2. Test correctness on 64 × 64, 100 × 50 × 70 and 1024 × 1024 × 1024, with a relative tolerance of about 1e-4.
  3. Time the naive and tiled kernels at 1024³ with CUDA events. Predict the speed-up first.
  4. Profile both with ncu --section SpeedOfLight --section MemoryWorkloadAnalysis. What happens to DRAM traffic? Did occupancy change?
Solution and expected output

The kernel above is the solution. For the CPU reference, accumulate in double and compare with fabs(gpu-ref) <= 1e-4 * fabs(ref).

Expected at 1024³ on a T4: naive about 8–15 ms, tiled T = 16 about 3–6 ms, so roughly a 2–3× speed-up, not 16×. The 16× reduction in reads doesn't translate directly because the naive kernel was already helped by the L1/L2 caches, and the tiled kernel becomes limited by shared-memory loads and barriers. ncu shows DRAM bytes dropping sharply for tiled, while L1/shared throughput becomes the top unit in SpeedOfLight. Occupancy is similar (both use 256-thread blocks; tiled adds 2 KB shared memory per block, which doesn't limit a T4).

The assignment generalizes this to runtime tileX/tileY and rectangular sizes such as 513 × 8192 × 1023. Those odd sizes test your zero-padding.

Module 15 · Assignment 2 Q3 (llama2.c) and Bonus (iPIC3D mover) · Profiling tutorials

Porting a CPU hotspot to the GPU

The workflow you'll use in Q3, the bonus and your llm.c project: measure, port one function correctly, find the new bottleneck, fix it, measure again.

  • After this module you can: find and count a hotspot, port a matrix-vector product with a coalesced design, verify a port inside a larger program, explain why per-call allocation and copying kill performance, choose an optimization from an nsys profile, and plan the port of a particle mover (structs, deep copies, validation).

15.1 The workflow

  1. Measure the baseline with the program's own metric (tokens/s, total simulation time) and a profiler.
  2. Find and count the hotspot. Add a static counter to the function or use perf/gprof. Knowing the call count and per-call size tells you whether launch overhead will matter.
  3. Port naively but correctly, with data allocated and copied inside the function. It's slow, but it isolates correctness.
  4. Verify against the CPU version.
  5. Profile with nsys: is time in kernels, in cudaMemcpy, in cudaMalloc, or in gaps between launches?
  6. Fix the biggest cost, one change at a time, and re-profile to show which metrics changed.

15.2 Matrix-vector products in an LLM

In llama2.c, matmul(xout, x, w, n, d) computes xout = W · x, where W is d × n (row-major) and x has n entries. Generating each token calls it several times per transformer layer (the attention projections and the feed-forward layers) plus once for the output classifier. Count it with a static counter, don't guess. Unlike matrix-matrix multiply, a matrix-vector product does just 2 FLOPs per weight element loaded, so it's memory-bound (AI ≈ 0.5 FLOP/byte). Its speed is set by how fast you can stream W.

Design 1: one thread per output row

__global__ void matvec_row(const float *W, const float *x, float *y, int n, int d) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;          // output row
  if (i >= d) return;
  float s = 0.0f;
  for (int j = 0; j < n; j++) s += W[(size_t)i * n + j] * x[j];
  y[i] = s;
}

Simple and correct, but check the access to W with Module 12: at each step j, consecutive threads read W[i*n + j], which are n floats apart, so the access is uncoalesced. (x[j] is a broadcast, which is fine.) For d = 288 rows there are also only 288 threads, which won't fill a GPU.

Design 2: one warp per output row

__global__ void matvec_warp(const float *W, const float *x, float *y, int n, int d) {
  int warp = (blockIdx.x * blockDim.x + threadIdx.x) / 32, lane = threadIdx.x % 32;
  if (warp >= d) return;                                  // whole warp exits together
  float s = 0.0f;
  for (int j = lane; j < n; j += 32)                      // lanes read consecutive W entries
    s += W[(size_t)warp * n + j] * x[j];
  for (int off = 16; off > 0; off /= 2)                   // warp reduction in registers
    s += __shfl_down_sync(0xffffffff, s, off);
  if (lane == 0) y[warp] = s;
}
// launch: d warps → blocks = (d * 32 + 255) / 256, 256 threads

Now the 32 lanes read 32 consecutive floats of a row (coalesced), and there are 32× more threads. __shfl_down_sync lets threads of a warp read each other's registers, so the warp sums its 32 partial results in 5 steps with no shared memory and no barrier. Comparing designs 1 and 2 with ncu (sectors per request, DRAM throughput) makes a strong Module 12 story for your report.

A third option is the library: cublasSgemv. cuBLAS assumes column-major storage, so a row-major W (d × n) looks like a column-major n × d matrix, and you pass CUBLAS_OP_T. Library calls are a legitimate optimization and a project topic.

15.3 Why the naive port is slow

A naive port does cudaMalloc → copy W → copy x → kernel → copy y back → cudaFree inside every call. Per call:

  • W is the same every token (model weights), yet it's re-copied each call. Copying d·n·4 bytes over PCIe takes longer than the GPU needs to read it.
  • cudaMalloc/cudaFree are slow API calls (tens to hundreds of µs) that also synchronize.
  • Each call is small, so fixed costs (launch ~5 µs, copy latency ~10 µs) dominate.

So the GPU version can easily be slower than the CPU at first. That's expected and is the point of the exercise. nsys's cuda_api_sum and cuda_gpu_mem_time_sum tables show exactly which of these costs dominates.

Optimizations to choose from (the assignment asks for one)

Bottleneck in nsysFixWhat should change in the profile
cudaMalloc/cudaFree callsAllocate device buffers once (at start-up) and reuse themCall count drops from thousands to a handful
H2D copies of weightsCopy all weights to the GPU once at load time; pass device pointers to matmulH2D bytes per token drop from megabytes to just x
Remaining small copiesPinned host memory (cudaMallocHost) for x and xoutHigher bandwidth and lower latency per copy
Kernel timeCoalesced warp-per-row kernel, or cuBLASLower average kernel duration in cuda_gpu_kern_sum

Verifying inside a larger program

  • Temporarily run both versions each call and compare xout elementwise with a relative tolerance (reductions sum in a different order, Module 7).
  • Run with temperature 0 (greedy sampling; llama2.c's -t 0) on CPU and GPU builds. The generated text should be identical, or diverge only after many tokens because of float rounding. Explain this in the report.

15.4 Bonus preparation: porting a particle mover

The iPIC3D-mini bonus asks you to port mover_PC(): for every particle, interpolate the electromagnetic field at its position and push it for several sub-cycles. The structure suits a GPU well: particles are independent, so use one thread per particle, keeping its whole sub-cycling loop inside the thread. What makes it harder than vector add:

  • Structs with pointers. Particle and field data live in structs containing pointers to arrays. Copying the struct to the GPU copies only the host pointers, which are invalid there. You need a deep copy: cudaMalloc each array, copy its contents, and pass device pointers (in a device-side struct or as kernel arguments).
  • Multi-dimensional arrays. Fields may be stored as double*** on the CPU. On the GPU, use the flat contiguous array behind them (check the code for a "flat" version) and compute the index by hand.
  • Data layout. Separate arrays per component (x[], y[], …), a structure of arrays, coalesce. An array of structs doesn't (Module 12 exercise).
  • Double precision. On a T4, FP64 runs at 1/32 of FP32, so don't expect a huge speed-up there; the cluster's H100 is much better at FP64. Mention which GPU you used and why it matters.
  • Validation. Run CPU and GPU versions on the same input, compare the output files (positions, velocities, or the code's diagnostic outputs) with a tolerance, and report the maximum relative difference.
  • Timing. Report the mover's time with and without allocation and transfers, since the bonus allows them inside mover_PC_gpu(). The difference shows how much a fuller port, keeping data resident, would gain.
Worked example: is one matvec worth a GPU?

Problem. A matvec with d = 768, n = 768 (float). Compare the naive-port cost per call (allocate, copy W and x, kernel, copy back) with the GPU-resident version, using PCIe ≈ 12 GB/s, T4 bandwidth 320 GB/s, launch ≈ 5 µs, cudaMalloc+cudaFree ≈ 100 µs.

  1. W = 768 × 768 × 4 B = 2.36 MB. x and y = 3 KB each.
  2. Naive: alloc/free ~100 µs + copy W 2.36 MB / 12 GB/s ≈ 197 µs + small copies ~20 µs + kernel (2.36 MB / 320 GB/s ≈ 7 µs, plus launch 5 µs) ≈ 330 µs per call.
  3. CPU: 2 × 768² = 1.2 MFLOP; one core at a few GFLOP/s takes about 300–600 µs. The naive GPU port is about the same, or worse.
  4. Resident weights, reused buffers: copy x (~10 µs) + kernel (~12 µs) + copy y (~10 µs) ≈ 32 µs, about 10× faster than naive.
  5. Next step, which the project explores: keep x and y on the GPU between layers as well, so only one token's worth of data moves per step and the GPU can run kernels back to back.

Exercises

RecallWhy is a matrix-vector product memory-bound?

Each weight is used once: 2 FLOPs (multiply, add) per 4-byte load, so AI ≈ 0.5 FLOP/byte, far below the T4's ~25.

ExplainWhy is "one thread per row" uncoalesced while "one warp per row" is coalesced?

With a thread per row, at each loop step consecutive threads read elements n floats apart, in different rows. With a warp per row, the 32 lanes read 32 consecutive floats of the same row in each step.

ExplainWhy is it fine to return early in matvec_warp but not before a __syncthreads()?

The condition depends only on the warp index, so all 32 lanes exit together, and __shfl_down_sync(0xffffffff, …) is only called by fully active warps. There's no block-wide barrier in the kernel, so no other warp waits for the exited one.

ApplyThe nsys output of your naive port shows cudaMalloc 45% and cudaMemcpy 50% of API time, and kernels at 2% of GPU time. What do you change first, and what do you expect?

Allocate once and copy weights once. Malloc/free calls drop to a few, H2D traffic per token drops to the activation vector, and tokens/s rises a lot. The kernel's share of GPU time then grows, and that is the next thing to optimize.

ApplyYou cudaMemcpy a struct Particles { int n; double *x, *u; } to the GPU and the kernel crashes reading p->x[i]. Why, and what's the fix?

The copied struct still holds host addresses for x and u. Allocate device arrays, copy the contents, then build a struct with the device pointers (or pass the device pointers directly as kernel arguments).

ApplyYour GPU text output matches the CPU for 40 tokens, then differs. Bug or not?

Probably not. A parallel reduction sums in a different order, giving tiny float differences; once two logits are nearly tied, greedy sampling can pick a different token and the texts diverge from there. Confirm by comparing matmul outputs directly: if the relative error is ~1e-6, it's rounding, not a bug.

Lab: three matvec designs, standalone

  1. Write a standalone program with a CPU matvec, matvec_row and matvec_warp. Use d = n = 4096 and random data.
  2. Check both GPU kernels against the CPU (relative tolerance 1e-4).
  3. Time each kernel with events (weights already on the GPU). Predict first, using Module 4: what's the memory-bound minimum time?
  4. Profile both with ncu: compare sectors per request and DRAM throughput.
  5. Add a version that allocates and copies inside every call, and time 100 calls of each. This is the gap Q3 asks you to explain.
Solution and expected output

Use the two kernels above. For d = n = 4096: W = 67 MB, so the memory-bound minimum is 67 MB / 320 GB/s ≈ 0.21 ms.

Expected on a T4: matvec_warp about 0.25–0.3 ms (close to the bound, ~85% of peak bandwidth). matvec_row several times slower (often 1–3 ms), with ncu showing far more sectors per request and low DRAM efficiency (4,096 threads also leave the GPU under-filled).

Step 5: with allocation and copies inside each call, each call takes about 6–10 ms (67 MB over PCIe dominates). That's 20–40× slower than the resident kernel, which is the same pattern you'll see in llama2.c, just at a different size.

Part 3 · Lectures 3.1–3.2, 4–6 · Main topics 2 and 3

Concurrency, launch overhead, Unified Memory and sparsity

Part 2 made individual kernels fast. Part 3 is about the whole program: overlapping copies with computation, removing launch overhead, letting the runtime move data for you, and handling sparse data where most values are zero. Unified Memory is also one of the project topics.

Sources. Modules 16 and 18 follow the course slides (3.1.2 Streams & Asynchronous Copy, 3.2.1 Unified Memory). Modules 17 and 19 cover topics where you sent only Canvas topic lists (scheduling, synchronization, CUDA Graphs; Lecture 6: SpMV, SpMM, embedding lookup, structured sparsity). Those are written from the CUDA Programming Guide and standard references. Check them against the lecture recordings if any are available.

Module 16 · Lecture 3.1.2 (Streams & Asynchronous Copy) · Visualize timelines tutorial

Streams: overlapping copies and computation

Keep the PCIe link and the GPU busy at the same time, instead of taking turns.

  • After this module you can: explain what a stream is and how the default stream behaves, use pinned memory and cudaMemcpyAsync, pipeline a computation in segments, synchronize streams correctly, time with CUDA events, and predict the speed-up from overlap.

16.1 The problem: everything takes turns

So far, each program copies A in, copies B in, computes, then copies C out, one after another. While data crosses PCIe the GPU is idle, and while the GPU computes the PCIe link is idle. For vector add (Module 4) the copies are about 95% of the time, so the GPU sits idle almost always.

The fix is pipelining: split the arrays into segments. While segment 1 is being computed, segment 2 is being copied in and segment 0's result copied out. That's the slides' A.0/A.1/A.2 picture.

serial (default stream) H2D A, B kernel D2H C pipelined (3 streams) copy engines and compute units work at the same time; total ≈ the longest single resource + a little ramp-up

16.2 Streams and the default stream

A stream is a queue of GPU operations (copies and kernels). Operations in the same stream run in order. Operations in different streams may run at the same time. This is task parallelism on top of the data parallelism inside each kernel.

If you don't name a stream, you use the default (null) stream, which is special:

  • No operation in the default stream starts until all previously issued operations in any stream have finished.
  • An operation in the default stream must finish before any later operation in any stream starts.

So one default-stream call in the middle of your pipeline serializes everything. Since CUDA 7 you can compile with --default-stream per-thread, which gives each host thread its own default stream that behaves like a normal stream.

16.3 The API

cudaStream_t s[NS];
for (int k = 0; k < NS; k++) cudaStreamCreate(&s[k]);

// pinned (page-locked) host memory: REQUIRED for copies to overlap
float *h_a; cudaMallocHost(&h_a, bytes);       // free with cudaFreeHost

cudaMemcpyAsync(d_a, h_a, bytes, cudaMemcpyHostToDevice, s[k]);   // 5th arg: stream
kernel<<<grid, block, 0, s[k]>>>(…);                              // 4th launch arg: stream
cudaMemcpyAsync(h_c, d_c, bytes, cudaMemcpyDeviceToHost, s[k]);

cudaStreamSynchronize(s[k]);   // wait for stream k only
cudaDeviceSynchronize();       // wait for all streams
cudaStreamDestroy(s[k]);
Why pinned memory? Normal (pageable) host memory can be moved or swapped by the OS, so the GPU's DMA engine can't read it directly. The driver first copies it into a hidden pinned buffer, and that copy happens synchronously on the CPU. With cudaMallocHost the memory is locked in place, and the DMA engine reads it directly while the CPU and GPU carry on. Pinned copies are also faster, often 1.5–2× the bandwidth. Don't pin huge amounts, though: it takes physical RAM away from the OS.

The two requirements from the slides: the kernel and the copy to be overlapped must be in different non-default streams, and the host memory must be pinned. Hardware matters too: most GPUs have separate copy engines for each direction, so H2D, D2H and a kernel can all run at once. deviceQuery reports this as asyncEngineCount.

16.4 Pipelining vector add

int seg = (n + NS - 1) / NS;                    // could also use many more segments than streams
for (int k = 0; k < NS; k++) {
  int off = k * seg, len = min(seg, n - off);
  size_t b = len * sizeof(float);
  cudaMemcpyAsync(d_a + off, h_a + off, b, cudaMemcpyHostToDevice, s[k]);
  cudaMemcpyAsync(d_b + off, h_b + off, b, cudaMemcpyHostToDevice, s[k]);
  vecAdd<<<(len + 255) / 256, 256, 0, s[k]>>>(d_a + off, d_b + off, d_c + off, len);
  cudaMemcpyAsync(h_c + off, d_c + off, b, cudaMemcpyDeviceToHost, s[k]);
}
cudaDeviceSynchronize();

Note the pointer offsets (d_a + off): each stream works on its own slice, so there's no conflict. Issue order matters on some GPUs. Interleaving per segment (copy, kernel, copy for segment 0, then segment 1 …), as above, works well on modern hardware; older GPUs sometimes needed all H2D copies issued first. The nsys timeline shows which you got.

16.5 Timing with CUDA events

CPU timers measure when the host saw things happen. With asynchronous work, events are better: they're recorded in a stream and timestamped by the GPU when they're reached.

cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop);
cudaEventRecord(start, s[0]);
/* … work … */
cudaEventRecord(stop, s[0]);
cudaEventSynchronize(stop);
float ms; cudaEventElapsedTime(&ms, start, stop);

Events also create dependencies between streams: cudaStreamWaitEvent(s2, ev) makes stream 2 wait until ev (recorded in stream 1) is reached, without blocking the CPU.

Worked example: how much can overlap save?

Problem. Vector add on N = 2²⁶ floats (256 MB per array) on a T4 with pinned memory at ~12 GB/s per direction. Estimate the serial time and the pipelined time with 4 segments.

  1. H2D: 2 × 268 MB / 12 GB/s ≈ 45 ms. D2H: 268 MB / 12 GB/s ≈ 22 ms. Kernel: 805 MB / 300 GB/s ≈ 2.7 ms.
  2. Serial: 45 + 2.7 + 22 ≈ 70 ms.
  3. Pipelined: H2D and D2H use separate copy engines, so they can overlap. The busiest resource is the H2D engine (45 ms). Segment 0's copy-in can't overlap anything, and the last segment's copy-out happens at the end: about 45 + (2.7 + 22)/4 ≈ 51 ms.
  4. Speed-up ≈ 1.4×. The limit is the H2D link itself (45 ms), so no number of streams can beat it. Overlap hides the shorter operations behind the longest one.
  5. For a kernel with real compute (say 40 ms), overlap would hide almost all of it: max(45, 40, 22) plus ramp-up, against 107 ms serial. That's the case where streams shine.

Exercises

RecallName the two requirements for a copy to overlap with a kernel.

Both are in different non-default streams, and the host memory is pinned (cudaMallocHost).

ExplainWhy does one cudaMemcpy (no stream argument) in the middle of a pipeline destroy the overlap?

It runs in the legacy default stream, which waits for all earlier work in every stream and blocks later work in every stream until it finishes. Everything is serialized around it, and it also blocks the host.

ExplainWhat's the difference between cudaStreamSynchronize(s) and cudaDeviceSynchronize()?

The first waits only for stream s; the second waits for all streams on the device. Use the narrowest one you need so other streams keep working.

ApplyH2D takes 30 ms, the kernel 30 ms, D2H 30 ms, all serial. What's the best-case pipelined time with many segments?

All three resources can work in parallel, so the steady state is limited by any one of them: about 30 ms plus ramp-up and drain (one segment's H2D at the start, one segment's D2H at the end). With S segments that's about 30 + 60/S ms, approaching 30 ms: a 3× speed-up.

ApplyYou use 4 streams and cudaMemcpyAsync, but nsys shows no overlap. List three causes to check.

(1) Host memory is pageable (malloc) rather than pinned. (2) Some call uses the default stream, e.g. a plain cudaMemcpy or a kernel launched without a stream. (3) The kernel occupies the whole GPU, or the segments are so small that launch overhead dominates. Also check for a cudaDeviceSynchronize() inside the loop.

Lab: pipeline vector add

  1. Starting from your Assignment 1 vector add, switch to pinned memory and measure how much faster the plain serial copies get.
  2. Implement the 4-stream pipeline from 16.4 and time it with events. Predict the result using the worked example.
  3. Sweep the number of segments (1, 2, 4, 8, 16, 32) and plot time against segments.
  4. Record a timeline with nsys profile -o pipe ./vecadd_streams and open it in the Nsight Systems GUI. Take a screenshot showing overlapping copy and kernel rows.
Solution and expected output

Step 1: pageable copies on Colab are typically 5–8 GB/s; pinned, 11–13 GB/s.

Step 2–3: time falls from 1 segment to about 4–8 segments, then flattens near the H2D-only time, and rises slightly beyond ~16–32 segments as per-operation overhead (~10 µs each) adds up. The best speed-up for vector add is modest (~1.3–1.5×), because the transfers themselves are the floor.

Step 4: the GUI timeline shows "Memcpy HtoD" for one stream overlapping "Memcpy DtoH" and kernels in others. If not, re-check the three causes in the last exercise.

Module 17 · Main topic 2 (Scheduling, Synchronization, CUDA Graph) · standard sources · heat1d.cu profile

Scheduling, synchronization and CUDA Graphs

What the hardware does with your blocks, which kinds of synchronization exist, and how to stop launch overhead from dominating.

  • After this module you can: describe how blocks and warps are scheduled, choose the right synchronization level (warp, block, grid, stream, device), recognize a launch-overhead-bound program from an nsys profile, and use CUDA Graphs to remove that overhead.

17.1 How work is scheduled

  • Blocks → SMs. The block scheduler hands blocks to SMs as resources free up (Module 8's limits). When a block finishes, a waiting block takes its place. There's no guaranteed order, and a block never migrates.
  • Waves. If a grid has more blocks than fit at once, it runs in waves. With 40 SMs × 4 blocks per SM = 160 resident blocks, a 170-block grid runs one full wave and then a second wave of 10 blocks with most of the GPU idle: the tail effect. Grids much larger than one wave make tails negligible.
  • Warps → schedulers. Inside an SM, each of the 4 warp schedulers picks a ready warp every cycle (Module 2). Nsight Compute's Scheduler Statistics and Warp State sections show why warps aren't ready (waiting on memory, at a barrier, …).

17.2 The synchronization ladder

LevelMechanismWaits forTypical use
Warp__syncwarp(), __shfl_*_syncLanes of one warpWarp reductions (Module 15)
Block__syncthreads()Threads of one blockShared-memory tiles (Modules 13–14)
GridKernel boundary; or cooperative groups grid.sync()All blocksTime steps (Module 9). grid.sync() needs a cooperative launch with all blocks resident at once.
StreamcudaStreamSynchronize, events, cudaStreamWaitEventOne stream / one eventPipelines (Module 16)
DevicecudaDeviceSynchronize()Everything on the GPUTiming, end of program
Data, not timeAtomics, __threadfence()Nothing; makes updates indivisible or visibleCounters, reductions (Module 13)

Rule of thumb: use the narrowest synchronization that is correct. Every wider one stops more work.

17.3 Launch overhead

Every kernel launch costs a few microseconds of CPU time (cudaLaunchKernel), and back-to-back kernels leave small gaps on the GPU. That's negligible for a 10 ms kernel and dominant for a 2 µs one.

Worked example: the Lecture 3 heat1d profile

Data (from the Lecture 3 notebook, N = 2001, 500,000 time steps):

  • cudaLaunchKernel: 500,000 calls, 3.96 s in total, 7.9 µs average, 92% of API time.
  • heatStep kernel: 1.87 µs average, 0.94 s of GPU time in total.
  1. Each step, the CPU spends ~7.9 µs launching a kernel that runs for ~1.9 µs. The CPU can't issue launches as fast as the GPU finishes them.
  2. So the GPU is busy only about 1.9 / 7.9 ≈ 24% of the time, and the program is launch-bound, not compute- or memory-bound.
  3. Tuning the kernel (shared memory, block size) would change almost nothing. At best it saves part of 0.94 s out of ~4 s.
  4. Fixes: (a) do several time steps per launch, which is possible for small N in one block using __syncthreads() between steps; (b) make each launch cheaper with CUDA Graphs; (c) for large N the kernel time grows and this problem disappears by itself.

17.4 CUDA Graphs

A CUDA Graph records a sequence of GPU operations (kernels, copies, dependencies) once, then replays the whole sequence with one launch. The driver validates and prepares everything at creation time, so each replay costs about as much as a single launch, and the GPU runs the kernels back to back. The easiest way to build one is stream capture:

cudaStream_t s; cudaStreamCreate(&s);
cudaGraph_t graph; cudaGraphExec_t exec;

cudaStreamBeginCapture(s, cudaStreamCaptureModeGlobal);
for (int k = 0; k < 100; k++) {                     // record 100 steps (an even number,
  heatStep<<<blocks, threads, 0, s>>>(d_u, d_unew, N, r);   // so the swaps end where they began)
  std::swap(d_u, d_unew);                           // host-side swap is baked into the captured args
}
cudaStreamEndCapture(s, &graph);
cudaGraphInstantiate(&exec, graph, 0);              // CUDA 12+ signature

for (int rep = 0; rep < steps / 100; rep++)
  cudaGraphLaunch(exec, s);                         // one launch = 100 kernels
cudaStreamSynchronize(s);
  • Captured operations aren't executed during capture, only recorded. Kernel arguments are captured by value, so the pointer swaps are frozen into the graph, which is why an even number of steps per graph keeps d_u pointing at the latest result.
  • Graphs suit work that repeats with the same structure: time-stepping, every training step of a neural network, every token in LLM inference. llm.c and PyTorch both use them.
  • If parameters change between replays, update the graph with cudaGraphExecKernelNodeSetParams or re-capture.

Exercises

RecallName the synchronization mechanism for each: lanes of a warp, threads of a block, all blocks of a grid, one stream.

__syncwarp (or the _sync shuffle intrinsics); __syncthreads; a kernel boundary (or cooperative groups grid.sync()); cudaStreamSynchronize or events.

ExplainHow can you tell from nsys that a program is launch-bound?

Many launches with average cudaLaunchKernel time comparable to or larger than the average kernel duration, and a GPU timeline with gaps between short kernels. GPU kernel time is much less than wall-clock time.

ExplainWhy can't you just put a global barrier inside a normal kernel's time loop?

Not all blocks are guaranteed to be resident at once. A block waiting at a global barrier could be waiting for a block that can't start until it finishes: a deadlock. Cooperative launches guarantee residency (and limit the grid size) so grid.sync() is safe.

ApplyA grid has 165 blocks and 160 fit at once. Estimate the GPU's utilization if every block takes the same time.

Two waves: 160 blocks, then 5. Total time ≈ 2 block-times for 165 block-times of work, versus 160 × 2 = 320 slots available: about 52% utilization. Resizing blocks so the grid fits in one wave (or is many waves long) fixes this.

ApplyWith CUDA Graphs, launch cost per kernel falls from ~8 µs to ~1 µs. Estimate the new heat1d time.

Kernels take 1.87 µs, and now the CPU issues work faster than that, so the GPU becomes the limit: about 500,000 × (1.87 µs + ~1 µs of small gaps) ≈ 1.4 s, against ~4 s before. Roughly 2.5–3× faster without touching the kernel.

Lab: graph the heat equation

  1. Run heat1d.cu with N = 2001 under nsys profile --stats=true and check the numbers in the worked example.
  2. Add a CUDA Graph with 100 captured steps (as in 17.4) and handle the snapshot steps (the snapshot indices are multiples of steps/8; capture-and-launch until the next snapshot, then launch the remainder normally).
  3. Verify that the error table is unchanged, then compare total runtime and nsys API stats.
  4. Repeat with N = 2,000,001. Does the graph still help? Why or why not?
Solution and expected output

Step 3: the max-error column should match to all printed digits, since the arithmetic is identical. Expect total time to drop from ~4–5 s to ~1.5–2 s, with cudaLaunchKernel replaced by about 5,000 cudaGraphLaunch calls.

Step 4: careful: dx shrinks, so with r fixed the step count grows as N² (dt ∝ dx²). Reduce tEnd or the number of steps to keep the run short. Each kernel then takes ~20–30 µs (8 MB of traffic), so launch overhead is already hidden behind kernel time and the graph gives little benefit. Graphs help when kernels are short.

Simpler alternative for step 2: set tEnd so steps is a multiple of 100, and take snapshots only at graph boundaries.

Module 18 · Lecture 3.2.1 (CUDA Unified Memory) · Lecture 6 · Project topic 6

Unified Memory

One pointer that works on both CPU and GPU, what it really does underneath, and how to make it fast.

  • After this module you can: allocate managed memory, explain on-demand page migration and why the first kernel is slow, use prefetching and memory advice, explain oversubscription, and design a fair Unified Memory experiment for the project.

18.1 One address space

Unified Memory gives you a single pointer valid on both the CPU and the GPU. Replace malloc + cudaMalloc + cudaMemcpy with one call:

float *x, *y;
cudaMallocManaged(&x, n * sizeof(float));
cudaMallocManaged(&y, n * sizeof(float));
for (int i = 0; i < n; i++) { x[i] = 1.0f; y[i] = 2.0f; }    // CPU writes
add<<<blocks, 256>>>(n, x, y);                                 // GPU reads/writes the same pointers
cudaDeviceSynchronize();                                      // REQUIRED before the CPU reads y
printf("%f\n", y[0]);                                          // CPU reads
cudaFree(x); cudaFree(y);

The synchronize is still essential: the launch is asynchronous, so without it the CPU might read y before the kernel has written it.

18.2 How it works (Pascal and newer, including T4 and H100)

  • cudaMallocManaged reserves address space but may not allocate any physical memory yet. Pages are created on first touch.
  • When the GPU accesses a page that lives in CPU memory, it takes a page fault. The driver migrates the page (typically 4 KB–2 MB chunks) over PCIe, updates the page tables, and the warp continues. The same happens in reverse when the CPU touches GPU-resident pages.
  • Faults are handled in batches, but each batch costs tens of microseconds. A kernel touching gigabytes of non-resident data spends most of its time faulting, so the first kernel that touches managed data is often much slower than the same kernel with explicit copies.
  • Pre-Pascal GPUs worked differently: all managed data was migrated to the GPU at every launch, with no faults. That's why the slides distinguish the generations.
  • Oversubscription: on Pascal and newer, managed allocations can exceed GPU memory. Pages are evicted to the host as needed. It's slow, but it lets programs run that wouldn't fit otherwise.

18.3 Making it fast: prefetch and advise

int dev; cudaGetDevice(&dev);
cudaMemPrefetchAsync(x, bytes, dev, stream);             // move pages to the GPU before the kernel
kernel<<<g, b, 0, stream>>>(x, …);
cudaMemPrefetchAsync(y, bytes, cudaCpuDeviceId, stream); // bring results back before the CPU reads

cudaMemAdvise(w, bytes, cudaMemAdviseSetReadMostly, dev);  // read-only data: keep copies on both sides
cudaMemAdvise(x, bytes, cudaMemAdviseSetPreferredLocation, dev);

Prefetching turns many small fault-driven migrations into one bulk transfer, often matching explicit cudaMemcpy performance. SetReadMostly suits model weights or lookup tables: each processor keeps its own read-only copy. In CUDA 13 the prefetch and advise functions take a cudaMemLocation struct instead of a plain device ID. Check the signature in your toolkit's headers if a call won't compile.

18.4 When to use it

  • Good for: complex data structures with pointers (linked structures, the particle structs of Module 15), where deep copies are painful; fast porting; data that doesn't fit in GPU memory; CPU and GPU taking turns on the same data.
  • Costs: without prefetching, page faults can make it several times slower than explicit copies; behaviour is less predictable; it's harder to overlap with computation.
  • On systems with a fast CPU–GPU link and shared page tables (such as Grace Hopper), the costs are much lower. Results depend on the platform, which is worth stating in the project.
Worked example: designing a Unified Memory experiment (project topic 6)

Goal: evaluate replacing cudaMalloc/cudaMemcpy with Unified Memory in an application, as the project topic list asks.

  1. Variants: (a) explicit copies (baseline); (b) cudaMallocManaged with no hints; (c) managed + cudaMemPrefetchAsync; (d) managed + prefetch + cudaMemAdvise for read-mostly data.
  2. Metrics: end-to-end time; kernel time separately; with nsys --cuda-um-cpu-page-faults=true --cuda-um-gpu-page-faults=true, the number of page faults and bytes migrated.
  3. Predictions: (b) is slowest on the first iteration (faults), then close to (a) once data is resident; (c) is close to (a) from the start; (d) helps when the CPU also reads the data.
  4. Extension: a problem larger than GPU memory, which only (b)–(d) can run. Measure how time grows beyond the memory limit.
  5. Report: explain each difference by page-fault counts and migrated bytes, not just times. That is the A-level step.

Exercises

RecallWhat replaces cudaMalloc + cudaMemcpy with Unified Memory, and what call is still needed before the CPU reads results?

cudaMallocManaged; cudaDeviceSynchronize() (or a stream synchronize), because the kernel is asynchronous.

ExplainWhy is the first kernel touching managed memory often slow?

The data starts in CPU memory (written by the CPU), so the GPU page-faults on every page it touches, and pages migrate one batch at a time while warps stall. Later kernels find the pages already resident.

ExplainWhat does oversubscription mean, and why is it only possible with Unified Memory?

Allocating more managed memory than the GPU has. Pages migrate in and are evicted on demand. With cudaMalloc, all memory must physically exist on the GPU at allocation time.

ApplyYour Unified Memory version is 3× slower than explicit copies. What do you measure, and what do you try?

Profile page faults and migrated bytes with nsys's UM options. If faults are high, add cudaMemPrefetchAsync to the GPU before the kernel and back to the CPU before host reads; mark read-only arrays SetReadMostly. Check you aren't touching the data on the CPU between kernels, which causes ping-pong migration.

ApplyWhich is easier to port with Unified Memory: Assignment 1's vector add or the iPIC3D particle structs? Why?

The particle structs. Their pointers become valid on both sides, so no deep copy is needed. Vector add is already trivial with explicit copies, so Unified Memory saves little code there.

Lab: four versions of one kernel

  1. Write vector add with N = 2²⁶ in four versions: explicit copies; managed; managed + prefetch; managed with the kernel run twice (time each run).
  2. Predict the order of times before running.
  3. Profile with nsys profile --stats=true --cuda-um-gpu-page-faults=true --cuda-um-cpu-page-faults=true and find the page-fault counts.
Solution and expected output

Expected on a T4: managed without prefetch: the first kernel takes tens of ms (vs ~2.7 ms for the resident kernel) because of thousands of GPU page faults, and the second run drops to about the resident time. Managed + prefetch: the prefetch takes about as long as an explicit copy, and the kernel runs at full speed. Explicit copies with pageable memory are similar to or slightly slower than prefetch.

nsys shows GPU page faults only for the non-prefetched run, and CPU page faults when the host reads results that weren't prefetched back.

Module 19 · Lecture 6 (SpMV, SpMM, embedding lookup, structured sparsity) · standard sources

Sparse data on GPUs

Most values are zero, so store and compute only the rest, and deal with the irregularity that brings.

  • After this module you can: store a matrix in CSR, write scalar and vector SpMV kernels and explain their coalescing and load balance, describe SpMM and embedding lookups, and explain 2:4 structured sparsity on Tensor Cores.

19.1 Why sparsity is different

A sparse matrix (finite-element meshes, graphs, recommendation data, pruned neural networks) is mostly zeros. Storing it densely wastes memory and computing on it wastes FLOPs. Sparse formats store only the non-zeros (nnz), but the access pattern becomes irregular: the book's challenge of "input sensitivity" (Module 10). Rows have different lengths, so threads get different amounts of work.

19.2 CSR: compressed sparse row

Three arrays: val[nnz] (non-zero values, row by row), col[nnz] (their column indices), and rowPtr[rows+1] (where each row starts in val and col).

// matrix        val    = [5, 1, 2, 3, 4]
// [5 0 0 1]     col    = [0, 3, 1, 2, 3]
// [0 2 0 0]     rowPtr = [0, 2, 3, 3, 5]     (row 2 is empty: rowPtr[2] == rowPtr[3])
// [0 0 0 0]
// [0 0 3 4]

Other formats trade differently: COO (row, col, value triples: simple, good for building), ELL (pad every row to the same length: regular and coalesced, but wasteful if lengths vary), and hybrids. cuSPARSE supports all of them.

19.3 SpMV: sparse matrix × dense vector

Scalar kernel, one thread per row:

__global__ void spmv_scalar(int rows, const int *rowPtr, const int *col,
                            const float *val, const float *x, float *y) {
  int r = blockIdx.x * blockDim.x + threadIdx.x;
  if (r >= rows) return;
  float s = 0.0f;
  for (int k = rowPtr[r]; k < rowPtr[r + 1]; k++) s += val[k] * x[col[k]];
  y[r] = s;
}
  • Uncoalesced: neighbouring threads read val from different rows, far apart (the same problem as Module 15's thread-per-row matvec).
  • Load imbalance and divergence: a thread with a 1,000-element row keeps its whole warp busy while threads with 3-element rows idle.
  • Gather: x[col[k]] is an indirect, irregular read. Caches help if columns are clustered.

Vector kernel, one warp per row: lanes stride through the row's non-zeros (k = rowPtr[r] + lane; k < rowPtr[r+1]; k += 32), then do a shuffle reduction exactly as in Module 15. Reads of val and col are now coalesced. It's best when rows have ≳ 32 non-zeros; for very short rows most lanes idle, so libraries pick strategies based on the row-length distribution.

SpMV has very low arithmetic intensity: 2 FLOPs per non-zero for ~12 bytes read (value, index, x). It's always memory-bound, and its performance is judged in GB/s, not GFLOP/s.

19.4 SpMM and embedding lookups

  • SpMM (sparse matrix × dense matrix): every non-zero is used for a whole row of the dense matrix, so each loaded index is reused many times. Intensity is higher than SpMV and coalescing is easier along the dense dimension. It's used in graph neural networks and pruned models.
  • Embedding lookup (recommender systems, LLM token embeddings): gather rows of a large table by index, out[i] = table[ids[i]], often followed by a sum (EmbeddingBag). This is mathematically an SpMM with a one-hot sparse matrix, purely memory-bound. Assign a warp or block per looked-up row so that each row's elements are copied with coalesced accesses.

19.5 Structured sparsity on Tensor Cores

Unstructured sparsity is hard for hardware to exploit. Ampere and newer Tensor Cores support 2:4 structured sparsity: in every group of 4 consecutive weights, at most 2 are non-zero. The hardware stores only the 2 values plus 2-bit indices, and skips the zeros, up to 2× the Tensor Core throughput with half the weight storage.

  • Networks are pruned to the 2:4 pattern and fine-tuned to recover accuracy, typically with little loss.
  • Used through cuSPARSELt or framework support. It needs Ampere or newer: the H100 has it, the T4 (Turing) doesn't.
  • It's the "structured sparsity" listed in project topic 2. Pair it with Module 20 (Tensor Cores).

Exercises

RecallWrite the CSR arrays for [[0,7],[8,9]].

val = [7, 8, 9], col = [1, 0, 1], rowPtr = [0, 1, 3].

ExplainWhy is the scalar CSR kernel both uncoalesced and imbalanced?

Uncoalesced: neighbouring threads read val/col at the start of different rows, far apart. Imbalanced: each thread loops over its own row's length, so the warp waits for its longest row.

ExplainWhy is an embedding lookup memory-bound, and how do you make it coalesced?

It just copies data (zero or one FLOP per element). Assign a group of consecutive threads (a warp or block) to each looked-up row, so they copy consecutive elements of that row together.

ApplyA matrix has 1M rows: 99% have 4 non-zeros, 1% have 2,000. Scalar or vector kernel?

Neither alone is ideal. Scalar: the long rows make some warps take ~500× longer, so tail imbalance dominates. Vector: the 4-element rows waste 28 of 32 lanes. A hybrid (short rows with thread- or few-lanes-per-row, long rows with a block or warp each), or a library that picks per row, is best. Mention this kind of trade-off with numbers in a project report.

ApplyA layer's weights are pruned to 2:4 sparsity. What speed-up should you expect at most, and on which of your GPUs?

Up to 2× in the matrix-multiply itself, on the H100 (Ampere or newer) only, not on the T4. End-to-end gains are smaller (Amdahl: other layers and memory traffic don't shrink).

Lab: SpMV two ways

  1. Build a random CSR matrix (100,000 rows, about 64 non-zeros per row) and a CPU reference SpMV.
  2. Implement spmv_scalar and a warp-per-row spmv_vector; verify both.
  3. Time both and compute effective bandwidth: (nnz × 8 + rows × 8 + x bytes) / time.
  4. Repeat with 4 non-zeros per row. Which kernel wins now?
Solution and expected output
__global__ void spmv_vector(int rows, const int *rowPtr, const int *col,
                            const float *val, const float *x, float *y) {
  int r = (blockIdx.x * blockDim.x + threadIdx.x) / 32, lane = threadIdx.x % 32;
  if (r >= rows) return;
  float s = 0.0f;
  for (int k = rowPtr[r] + lane; k < rowPtr[r + 1]; k += 32) s += val[k] * x[col[k]];
  for (int off = 16; off > 0; off /= 2) s += __shfl_down_sync(0xffffffff, s, off);
  if (lane == 0) y[r] = s;
}

Expected: with 64 non-zeros per row the vector kernel is clearly faster (often 2–4×), reaching a good fraction of DRAM bandwidth. With 4 per row the scalar kernel wins, because the vector kernel idles 28 of 32 lanes. The best kernel depends on the data.

Part 4 · Lectures 1.3b, 1.4, 3.3.1, 7 · Main topics 3 and 4 · Project topics 3–5

Tensor Cores, low precision, libraries and scaling out

The fastest GEMM you can write by hand on a modern GPU is still several times slower than the hardware's peak, because the peak lives in Tensor Cores running in reduced precision, reached most easily through libraries. This part covers all three, then Triton and multiple GPUs. Modules 20–22 map directly onto your llm.c project topics.

Sources. Module 20 follows the slides 1.3b Tensor Cores and 1.4 AMD GPUs, and the course reading by Markidis, Chien, Laure, Peng and Vetter (2018). Module 22 follows the CUDA Libraries and cuRAND tutorials. Module 23's multi-GPU half follows slides 3.3.1. Module 21, CUTLASS and Triton are Lecture 7 topics you received only as a list, written from NVIDIA documentation and the Triton docs.

Module 20 · Lecture 1.3b (Tensor Cores) · 1.4 (AMD GPUs) · Reading: Markidis et al. 2018 · Reading: AMD Matrix Core

Tensor Cores and matrix cores

Hardware that does a whole small matrix multiply per instruction, and what it costs in precision.

  • After this module you can: explain what a Tensor Core computes and why it's so fast, compute peak throughput from unit counts, program one with WMMA, summarize the course reading's findings on performance and precision (including its residual technique), and compare NVIDIA Tensor Cores with AMD matrix cores.

20.1 What a Tensor Core does

A normal CUDA core does one fused multiply-add (FMA) per cycle: d = a·b + c on scalars. Volta (V100, 2017) added Tensor Cores to each SM. A Tensor Core does

D = A · B + C on 4 × 4 matrices, in one clock cycle = 64 FMAs = 128 FLOPs per cycle

with A and B in half precision (FP16) and the accumulation C, D in FP16 or FP32. That's mixed precision: cheap 16-bit inputs, but products summed in 32 bits so rounding errors don't pile up. Each FMA rounds once instead of twice (Module 7), which also helps accuracy.

Why so fast? Matrix multiply has huge data reuse (Module 4). One instruction that fetches 32 inputs and does 64 FMAs needs far less control logic, register traffic and energy per FLOP than 64 separate FMA instructions. The silicon goes to arithmetic.

20.2 Peak throughput, from the slides

The V100 has 80 SMs; each SM has 4 processing blocks with 2 Tensor Cores each, so 8 per SM and 640 in total.

640 Tensor Cores × 64 FMA × 2 FLOP × 1.53 GHz ≈ 125 TFLOP/s (vs 15.7 TFLOP/s FP32 on CUDA cores)
GPUTensor Core generationInput typesDense peak (approx.)
V100 (Volta, 2017)1stFP16125 TFLOP/s FP16
T4 (Turing, Colab)2ndFP16, INT8, INT4 (no BF16)65 TFLOP/s FP16, 130 TOPS INT8
A100 (Ampere)3rd+ TF32, BF16, FP64, 2:4 sparsity312 TFLOP/s FP16/BF16, 156 TF32
H100 SXM (Hopper, cluster)4th+ FP8≈ 990 TFLOP/s FP16/BF16, ≈ 1,980 FP8
Project warning. Your proposal plans BF16 training in llm.c. The T4 has no BF16 Tensor Cores: BF16 needs Ampere or newer. Develop on Colab in FP32 (or FP16), but run the mixed-precision experiments on the cluster's H100 slice, and say so in the report.

20.3 Three ways to program them (from the reading)

  1. WMMA API (warp matrix multiply-accumulate): CUDA C++ calls where a whole warp cooperatively loads 16×16 tiles into opaque "fragments" and multiplies them.
  2. CUTLASS: NVIDIA's C++ template library of GEMM building blocks (Module 22).
  3. cuBLAS: call GEMM with Tensor Core math enabled (Module 22).
#include <mma.h>
using namespace nvcuda;
// one warp computes a 16x16 tile of C = A (16xK) * B (Kx16); half inputs, float accumulate
__global__ void wmma_tile(const half *A, const half *B, float *C, int K, int lda, int ldb, int ldc) {
  wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a;
  wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b;
  wmma::fragment<wmma::accumulator, 16, 16, 16, float> acc;
  wmma::fill_fragment(acc, 0.0f);
  for (int k = 0; k < K; k += 16) {
    wmma::load_matrix_sync(a, A + k, lda);          // whole warp loads the tile
    wmma::load_matrix_sync(b, B + k * ldb, ldb);
    wmma::mma_sync(acc, a, b, acc);                  // Tensor Core multiply-accumulate
  }
  wmma::store_matrix_sync(C, acc, ldc, wmma::mem_row_major);
}
// launch one warp: wmma_tile<<<1, 32>>>(…); a full GEMM tiles C across many warps and blocks

Note the shift in thinking: the unit of work is a warp, not a thread. Fragments are distributed across the 32 lanes in an undocumented layout, and the _sync functions must be called by all lanes. Dimensions must be multiples of 16, so pad with zeros otherwise.

20.4 What the reading found (Markidis et al., 2018, on a V100)

  • Performance: up to 83 TFLOP/s in mixed precision with cuBLAS, about 7× single-precision and 3× half-precision (non-Tensor-Core) GEMM, but still only two thirds of the 125 TFLOP/s peak. A WMMA batched GEMM for many small matrices reached about 4 TFLOP/s. Tensor Cores sped up GEMM about 6× for large matrices and 2.5–12× for many small matrices in parallel.
  • Programmability: WMMA gives direct control but is low-level; CUTLASS builds on it; cuBLAS is easiest and fastest for standard GEMMs.
  • Precision: rounding FP32 inputs to FP16 loses accuracy, and the error grows with matrix size. That might be critical for HPC applications.
  • Their fix, residual refinement: split each input into a half-precision part and the leftover, R_A = A_single − A_half (also stored in FP16). Then compute
    A·B ≈ A_h·B_h + A_h·R_B + R_A·B_h (dropping the tiny R_A·R_B)
    Each term runs on Tensor Cores. Three GEMMs instead of one buys back most of the precision, while still beating FP32 CUDA cores when Tensor Cores are ≥ 3× faster. You can refine one or both matrices depending on the accuracy you need.
  • Outlook: codes built on GEMM benefit directly; others (e.g. particle codes) would need reformulating in matrix form.

20.5 AMD: MI100 and matrix cores

  • AMD's data-centre line (CDNA architecture, Instinct MI100/MI200/MI300) powers Frontier, El Capitan, LUMI and KTH's own Dardel.
  • Vocabulary: AMD's compute unit (CU) ≈ NVIDIA's SM; a wavefront is 64 threads (vs a 32-thread warp); MI100 has 120 CUs with HBM2.
  • From the slides: MI100 offers 11.5 TFLOP/s FP64 and 23.1 TFLOP/s FP32, against the A100's 9.7 and 19.5 (non-Tensor-Core). It's competitive in raw performance, while NVIDIA's software ecosystem (CUDA, libraries, tools) is more mature.
  • Matrix Core Engines are AMD's equivalent of Tensor Cores, programmed through rocBLAS, rocWMMA or compiler intrinsics. The course reading (Schieffer, De Medeiros, Faj, Marathe and Peng, ISPASS 2024) studies their performance, power efficiency and programmability.
  • Portability: HIP is nearly source-compatible with CUDA (cudaMalloc → hipMalloc), and hipify tools translate code automatically. Everything in this workbook transfers, but warp-size assumptions (32 vs 64) must be checked.
Worked example: is the residual trick worth it?

Problem. On a V100, FP32 GEMM on CUDA cores runs at ~14 TFLOP/s and Tensor Core GEMM at ~83 TFLOP/s. You need better accuracy than plain FP16 inputs give. Compare FP32 against residual refinement on both matrices.

  1. Refining both matrices needs 3 Tensor Core GEMMs (A_h·B_h, A_h·R_B, R_A·B_h) plus converting A and B to half and residual (memory-bound, O(n²)).
  2. Effective rate ≈ 83 / 3 ≈ 28 TFLOP/s for the GEMM part, ignoring conversions.
  3. Speed-up over FP32 ≈ 28 / 14 = 2×, with accuracy close to FP32 rather than FP16.
  4. On an H100 the balance shifts further (FP16 Tensor Cores ~15× FP32 CUDA cores), so the trick pays even more. On a T4 (65 vs 8 TFLOP/s) it's still ~2.7×.

Exercises

RecallWhat does one Volta Tensor Core compute per clock, in which precision?

D = A·B + C on 4×4 matrices (64 FMAs); A and B in FP16, accumulation in FP16 or FP32.

RecallWhat are AMD's equivalents of an SM, a warp and a Tensor Core?

A compute unit (CU), a 64-thread wavefront, and the Matrix Core Engine.

ExplainWhy accumulate in FP32 even when the inputs are FP16?

Each output sums thousands of products. In FP16 (about 3 significant digits) small products are lost when added to a large running sum, and errors accumulate. FP32 accumulation keeps the sum accurate while the cheap inputs still give the speed.

ExplainWhy did cuBLAS reach only 83 of 125 TFLOP/s in the reading?

Peak assumes every Tensor Core does an MMA every cycle. In practice, loading tiles from memory into fragments, synchronization and data movement (memory bandwidth and shared memory) can't keep all units fed all the time. The Tensor Cores make the GEMM so fast that it becomes partly memory- or latency-bound again.

ApplyCompute the T4's FP16 Tensor Core peak: 40 SMs, 8 Tensor Cores per SM, 64 FMA per core per cycle, 1.59 GHz.

40 × 8 × 64 × 2 × 1.59×10⁹ ≈ 65 TFLOP/s, about 8× its 8.1 TFLOP/s FP32 peak.

ApplyCould the iPIC3D particle mover use Tensor Cores? Use the reading's conclusion.

Not directly. The mover does per-particle interpolation and updates, not matrix multiplications. The reading notes that particle codes would need reformulating in tensor form. It's also double precision, which needs FP64 Tensor Cores (A100/H100 only).

Lab: Tensor Cores on the T4

  1. Write a full WMMA GEMM: each warp computes one 16×16 tile of C (M = N = K = 4096, multiples of 16), with blocks of 4 warps.
  2. Verify against your FP32 tiled GEMM (Module 14). Report the maximum relative error and explain it.
  3. Time both. Compute TFLOP/s and compare with the 65 TFLOP/s peak.
  4. Implement residual refinement on A only, then on both, and measure how the error and time change.
Solution and expected output

Indexing for step 1: warp w in the grid gets tile (tileRow, tileCol); point A + tileRow*16*K and B + tileCol*16 at the tile start, and loop k as in 20.3 with lda = K, ldb = N, ldc = N.

Expected: relative errors around 10⁻³ (FP16 inputs have ~3 significant digits), growing slowly with K. The naive WMMA kernel typically reaches only ~10–20 TFLOP/s on a T4, several times faster than your FP32 tiled kernel but well below peak, because it loads fragments straight from global memory with no shared-memory staging. cuBLAS (Module 22) gets much closer to peak.

Step 4: refining A only roughly halves the error; refining both brings it near FP32 accuracy (~10⁻⁶–10⁻⁵ relative), at about 2× and 3× the time.

Module 21 · Lecture 7 (Low precision & numerics) · standard sources · Project topic 4

Low precision and numerics

What you lose and gain with 16-bit and 8-bit numbers, and how training stays stable.

  • After this module you can: describe FP32, TF32, FP16, BF16, FP8 and INT8 by their bits, range and precision, predict overflow and underflow, explain mixed-precision training (master weights, loss scaling), and design an accuracy-vs-speed experiment.

21.1 Floating-point formats

A float is sign × mantissa × 2exponent. The exponent bits set the range (how big and small numbers can get); the mantissa bits set the precision (how many significant digits).

FormatBits (sign/exp/mantissa)Largest valueSignificant decimal digitsWhere
FP641 / 11 / 52≈ 1.8×10³⁰⁸≈ 16Scientific computing
FP321 / 8 / 23≈ 3.4×10³⁸≈ 7Default; master weights
TF321 / 8 / 10 (19 bits used)≈ 3.4×10³⁸≈ 3Ampere+ Tensor Cores for "FP32" inputs
FP161 / 5 / 1065,504≈ 3Volta+ Tensor Cores (T4 OK)
BF161 / 8 / 7≈ 3.4×10³⁸≈ 2–3Ampere+ (H100, not T4); LLM training
FP8 E4M31 / 4 / 3448≈ 1Hopper+; weights and activations
FP8 E5M21 / 5 / 257,344< 1Hopper+; gradients
INT8integer127needs a scale factorInference (quantized)
  • FP16 vs BF16: same 16 bits, different trade-off. FP16 has more precision but a tiny range: values above 65,504 overflow to infinity, and gradients smaller than about 6×10⁻⁸ underflow to zero. BF16 keeps FP32's range with less precision, which is why it became the standard for training: no overflow or underflow, and you can convert from FP32 by just dropping bits.
  • Machine epsilon (relative gap between neighbouring numbers): FP32 ≈ 1.2×10⁻⁷, FP16 ≈ 9.8×10⁻⁴, BF16 ≈ 7.8×10⁻³. In BF16, 256 + 1 = 256.
  • Fewer bytes also halves or quarters memory traffic. For memory-bound kernels that's a speed-up even without Tensor Cores (Module 4).

21.2 Mixed-precision training

Training in pure 16-bit fails: weight updates (learning rate × gradient) are often smaller than the weight's precision, so w + Δw == w and the model stops learning. The standard recipe:

  1. Keep FP32 master weights.
  2. Each step, cast to BF16/FP16 for the forward and backward passes; GEMMs run on Tensor Cores with FP32 accumulation.
  3. Apply the gradient update to the FP32 master copy.
  4. With FP16 only, use loss scaling: multiply the loss by a factor S (e.g. 1024) so small gradients don't underflow, then divide the gradients by S before the update. Dynamic loss scaling lowers S when infinities appear. BF16 usually doesn't need it, thanks to its range.
  5. Keep numerically sensitive operations in FP32: softmax, layernorm statistics, the loss, and large reductions.

llm.c's GPU training follows this pattern with BF16. That's what your project topic 3 measures.

21.3 Quantization for inference

For inference only, weights can be stored as INT8 (or 4-bit) with a scale: w ≈ scale × q with q ∈ [−127, 127], per tensor, per channel or per block of weights. Memory and bandwidth drop 4× relative to FP32, which directly speeds up memory-bound inference (the llama2.c matvec in Module 15 is exactly this case). Accuracy depends on how outliers are handled, so always measure perplexity or task accuracy, not just speed.

Worked example: what precision does each tensor need?

Problem. In a GPT-2 training step, values range roughly as follows: weights ±2, activations up to ~10³, attention logits up to ~10⁴ before softmax, gradients from 10⁻¹⁰ to 10⁻². Which fit in FP16, and which in BF16?

  1. Weights (±2): fit in both, but updates of ~10⁻⁶ relative are lost in either. Keep FP32 master weights.
  2. Activations (10³): fine in both (FP16 max 65,504).
  3. Logits (10⁴): fit in FP16, but exp(10⁴) overflows everything. Softmax must subtract the max first, and is usually done in FP32 anyway.
  4. Gradients (down to 10⁻¹⁰): below FP16's smallest subnormal (~6×10⁻⁸), so they flush to zero. Needs loss scaling in FP16. BF16 represents them (range like FP32), but with only ~2–3 digits.
  5. Conclusion: BF16 + FP32 master weights + FP32 softmax/norms is the robust choice, which is what llm.c does. Your experiment should confirm it by comparing validation loss curves for FP32 and BF16.

Exercises

RecallWhich bits control range and which control precision?

Exponent bits control range; mantissa bits control precision.

ExplainWhy did BF16 replace FP16 for training?

BF16 has FP32's 8-bit exponent, so the same range: no overflow of large activations and no underflow of small gradients, which removes the need for loss scaling. Its lower precision is acceptable because FP32 master weights and FP32 accumulation keep sums accurate.

ExplainWhy does reduced precision speed up even kernels that don't use Tensor Cores?

Memory-bound kernels move half (16-bit) or a quarter (8-bit) of the bytes. Their time is set by bytes moved, so they speed up by about 2× or 4×.

ApplyCompute 1000 + 0.5 in FP16 and in BF16.

FP16: near 1000 the spacing is 0.5 (2⁹ × 2⁻¹⁰), so 1000.5 is exact. BF16: near 1000 the spacing is 4 (2⁹ × 2⁻⁷), so 1000.5 rounds to 1000. A small update is completely lost.

ApplyWhat should your BF16 llm.c experiment measure to make a convincing claim?

Time per step and tokens/s for FP32 and BF16 on the same GPU (the H100, since the T4 lacks BF16); memory use; validation loss/perplexity curves over the same number of steps; and, for one or two key kernels, ncu throughput, to explain where the speed-up comes from (Tensor Cores vs fewer bytes).

Lab: see rounding happen

  1. Write a kernel that sums 1,000,000 copies of 0.0001 in FP32, FP16 (__half, cuda_fp16.h) and BF16 (__nv_bfloat16, cuda_bf16.h) using a single thread and a running sum. Predict each result first.
  2. Repeat with a tree reduction (Module 13) in each precision.
  3. Explain why the tree reduction is so much more accurate.
Solution and expected output

The exact answer is 100. Sequential: FP32 gives roughly 100.9–101, not 100 (each addition rounds, and the error grows as the sum gets large relative to 0.0001). FP16 stalls at 0.25: once the sum's spacing exceeds 2 × 0.0001, adding 0.0001 rounds back to the same value. BF16 stalls far earlier, around 0.03. (BF16 compiles on the T4 as a storage type, emulated in FP32 arithmetic and rounded back, so the result still shows the format's precision.)

Tree: each addition combines numbers of similar size, so the errors stay small: FP32 is close to 100.0000, FP16 within about ±0.1 of 100, BF16 within a few units. This is why parallel reductions, and FP32 accumulation in Tensor Cores, matter for accuracy, not only for speed.

Module 22 · Tutorials: CUDA Libraries, Random Numbers with cuRAND · Lecture 7 (Library ecosystem, CUTLASS) · Project topics 3 and 5

CUDA libraries: cuBLAS, cuSPARSE, cuFFT, cuRAND, CUTLASS

Expert-tuned building blocks, and the handful of conventions you have to get right to use them.

  • After this module you can: follow the library workflow, call cuBLAS GEMM correctly from row-major C code (the column-major trick), enable Tensor Cores through cuBLAS, generate random numbers with cuRAND in kernels, and say what CUTLASS, cuSPARSE, cuFFT and Thrust are for.

22.1 The common workflow (from the tutorial)

  1. Create a handle (cublasCreate): an opaque object holding the library's context. Optionally set its stream (cublasSetStream).
  2. Allocate device memory.
  3. Convert inputs to a supported format (layout, sparse format).
  4. Copy inputs to the device (cudaMemcpy or cublasSetMatrix/SetVector).
  5. Configure (descriptors, transposes, algorithms).
  6. Execute.
  7. Retrieve results.
  8. Convert back to your format.
  9. Release resources, and reuse handles and buffers across calls, since creating them is expensive (the same lesson as Module 15).

22.2 cuBLAS and column-major order

cuBLAS follows Fortran BLAS: column-major storage, but zero-based pointers. Levels: 1 = vector ops (axpy, dot), 2 = matrix-vector (gemv), 3 = matrix-matrix (gemm). The leading dimension (lda) is the stride between columns, normally the number of rows.

The trick for row-major C code. A row-major M × N array, read as column-major, is its transpose (N × M). Since (A·B)ᵀ = Bᵀ·Aᵀ, ask cuBLAS for Cᵀ = Bᵀ·Aᵀ by simply swapping the operands. No copies, no transposes:

// row-major: C (M x P) = A (M x N) * B (N x P)
cublasHandle_t h; cublasCreate(&h);
const float alpha = 1.0f, beta = 0.0f;
cublasSgemm(h, CUBLAS_OP_N, CUBLAS_OP_N,
            P, M, N,              // dimensions of the column-major problem C^T = B^T * A^T
            &alpha,
            d_B, P,               // "first" matrix is B, leading dim P
            d_A, N,               // "second" matrix is A, leading dim N
            &beta,
            d_C, P);              // C, leading dim P
cublasDestroy(h);

Getting this right first time is rare. Always check against your CPU reference. The tutorial's cublasSgemv example works on genuinely column-major data, which is why it passes CUBLAS_OP_N with lda = M.

Tensor Cores through cuBLAS

  • cublasGemmEx takes separate data types for inputs, output and compute: e.g. A, B in CUDA_R_16F or CUDA_R_16BF, C in CUDA_R_32F, compute type CUBLAS_COMPUTE_32F. cuBLAS picks Tensor Core kernels automatically when the types and alignment allow.
  • For FP32 data on Ampere+, CUBLAS_COMPUTE_32F_FAST_TF32 (or cublasSetMathMode(h, CUBLAS_TF32_TENSOR_OP_MATH)) runs FP32 GEMMs on Tensor Cores in TF32.
  • cuBLASLt is the more flexible interface (epilogues such as bias + GELU fused into the GEMM, FP8). llm.c uses it for its matmuls.

22.3 cuRAND: random numbers inside kernels (from the tutorial)

Random number generators are sequential, so each thread needs its own state, initialized so that threads produce different, independent streams:

#include <curand_kernel.h>
__global__ void init(curandState *st, unsigned long long seed) {
  int id = blockIdx.x * blockDim.x + threadIdx.x;
  curand_init(seed, id, 0, &st[id]);         // same seed, subsequence = thread id
}
__global__ void monteCarloPi(curandState *st, int trials, unsigned long long *hits) {
  int id = blockIdx.x * blockDim.x + threadIdx.x;
  curandState s = st[id];                    // copy state to registers (faster)
  unsigned int local = 0;
  for (int t = 0; t < trials; t++) {
    float x = curand_uniform(&s), y = curand_uniform(&s);
    local += (x * x + y * y <= 1.0f);
  }
  st[id] = s;                                // save state if you'll generate more later
  atomicAdd(hits, (unsigned long long)local); // one atomic per thread (Module 13)
}
// pi ≈ 4 * hits / (threads * trials)
  • The tutorial seeds with seed = id. The documented best practice is the same seed with a different subsequence per thread (as above), which guarantees non-overlapping streams.
  • curand_init is slow (it skips ahead in the sequence), so do it once in a separate kernel and reuse the states.
  • For filling large arrays, the host API (curandCreateGenerator, curandGenerateUniform) is simpler and faster.

22.4 The rest of the ecosystem

LibraryUse it forNote
cuSPARSESpMV, SpMM in CSR/COO/… (Module 19)Generic API: create sparse/dense descriptors, query buffer size, then compute.
cuSPARSELt2:4 structured sparse GEMM on Tensor CoresAmpere+.
cuFFTFast Fourier transforms (signal processing, spectral solvers, fast convolution)Create a "plan" once, execute many times.
cuDNNNeural-network primitives (convolution, attention, normalization)Used by PyTorch; llm.c can use its attention.
Thrust / CUBSort, scan, reduce, transform with STL-like C++thrust::reduce(d_vec.begin(), d_vec.end()) replaces your hand-written reduction.
CUTLASSBuild your own high-performance GEMM-like kernelsC++ templates, see below.
NCCLCommunication between GPUs (all-reduce etc.)Module 23.

CUTLASS exposes the same hierarchy you built by hand in Module 14, one level per layer: a threadblock tile staged through shared memory, divided into warp tiles, each computed with Tensor Core MMA instructions (the WMMA idea of Module 20), plus an epilogue that can fuse bias, activation or scaling before writing C. You choose tile sizes and data types as template parameters. Use it when cuBLAS doesn't offer the fused operation you need; for a plain GEMM, cuBLAS is usually as fast and far simpler. (The course reading used an early CUTLASS built on WMMA.)

Worked example: a fair hand-written vs library comparison (project topic 2)
  1. Same problem: identical M, N, K, data types, layout and GPU for all variants: naive, tiled (yours), cuBLAS FP32, cuBLAS TF32 or BF16 on the H100.
  2. Same timing: CUDA events around the kernel or library call only, after a warm-up call (cuBLAS's first call includes set-up), median of 10+ repeats.
  3. Report GFLOP/s = 2MNK / time, and % of the relevant peak (FP32 CUDA cores for FP32, Tensor Core peak for TF32/BF16).
  4. Explain with ncu: cuBLAS kernels show high Tensor Core or FMA pipe utilization and high shared-memory throughput; your tiled kernel shows where it stalls (Warp State Statistics).
  5. Check correctness against an FP64 CPU reference, and report errors for each precision.

Exercises

RecallWhat is a cuBLAS handle, and why create it only once?

An opaque host-side object with the library's context (device, stream, settings, workspace). Creating it is expensive, so make it once and reuse it for every call.

ExplainWhy does swapping A and B let cuBLAS compute a row-major product?

Row-major memory read as column-major gives the transpose. cuBLAS therefore sees Aᵀ and Bᵀ; computing Bᵀ·Aᵀ = (A·B)ᵀ produces Cᵀ in column-major order, which is exactly C in row-major order.

ExplainWhy must each cuRAND thread have its own state, initialized differently?

A generator's state advances with every number. Sharing it would be a race (Module 13); identical initialization would give every thread the same numbers. Different subsequences of one seed give independent, reproducible streams.

ApplyWrite the cublasSgemv call for row-major W (d × n), y = W·x.

Row-major W looks to cuBLAS like a column-major n × d matrix (Wᵀ) with leading dimension n. Ask for the transpose of that: cublasSgemv(h, CUBLAS_OP_T, n, d, &alpha, d_W, n, d_x, 1, &beta, d_y, 1); (m = n rows and n = d columns of the column-major view).

ApplyWhen would you use CUTLASS instead of cuBLAS in llm.c?

When you want something cuBLAS doesn't offer, e.g. a GEMM with a custom fused epilogue (bias + GELU + a residual add) to avoid an extra pass through memory. For a plain GEMM, cuBLAS/cuBLASLt is the right choice.

Lab: your GEMM against cuBLAS, and Monte Carlo π

  1. Add a cuBLAS path to your Module 14 tiled GEMM program using the row-major trick. Compile with -lcublas. Verify it against the CPU reference.
  2. Time naive, tiled and cuBLAS at 1024³ and 4096³ (with a warm-up call). Compute GFLOP/s and % of the T4's 8.1 TFLOP/s.
  3. Add a cublasGemmEx call with FP16 inputs and FP32 output. Compare speed and error.
  4. Run the Monte Carlo π program with 65,536 threads × 1,000 trials. How does the error shrink as you multiply trials by 100?
Solution and expected output

Step 2, expected on a T4 at 4096³: naive ~0.5–1 TFLOP/s, tiled ~1–2 TFLOP/s, cuBLAS FP32 ~4–5.5 TFLOP/s (50–70% of peak). The gap to cuBLAS comes from register tiling (each thread computes many outputs), double-buffered shared memory and vectorized loads, techniques CUTLASS exposes.

Step 3: FP16 Tensor Core GEMM via cublasGemmEx: often 20–40 TFLOP/s on a T4, with relative errors around 10⁻³ (Module 21).

Step 4: π is accurate to about 3–4 digits with 6.5×10⁷ samples. Monte Carlo error shrinks as 1/√samples, so 100× more trials gives only one more correct digit (10× smaller error).

Module 23 · Lecture 7 (Triton, multi-GPU) · Lecture 3.3.1 (Multi-GPU Programming with CUDA) · Triton: standard sources

Triton, and programming several GPUs

A higher-level way to write GPU kernels, and how to spread work over more than one GPU.

  • After this module you can: explain Triton's block-level programming model and write a simple Triton kernel, use cudaSetDevice and streams to drive several GPUs, enable and use peer-to-peer copies, and explain data-parallel training with all-reduce.

23.1 Triton: programming blocks, not threads

Triton (from OpenAI, used heavily inside PyTorch) is a Python-embedded language for GPU kernels. In CUDA you write what one thread does; in Triton you write what one program instance does to a whole block of data, as vector operations. The compiler then decides how to map that onto threads, warps, shared memory and Tensor Cores: the coalescing, tiling and memory staging you did by hand in Part 2.

import torch, triton, triton.language as tl

@triton.jit
def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
    pid = tl.program_id(0)                       # like blockIdx.x
    offs = pid * BLOCK + tl.arange(0, BLOCK)     # a whole vector of indices
    mask = offs < n                              # replaces the "if (i < n)" bounds check
    x = tl.load(x_ptr + offs, mask=mask)
    y = tl.load(y_ptr + offs, mask=mask)
    tl.store(out_ptr + offs, x + y, mask=mask)

n = 1 << 24
x = torch.rand(n, device="cuda"); y = torch.rand(n, device="cuda"); out = torch.empty_like(x)
grid = (triton.cdiv(n, 1024),)                   # like (n + BLOCK - 1) / BLOCK
add_kernel[grid](x, y, out, n, BLOCK=1024)
  • Mapping to CUDA: program_id ≈ blockIdx; tl.arange ≈ the threadIdx range; mask ≈ the bounds check; there's no explicit shared memory and no __syncthreads.
  • For matrix multiply, tl.dot(a_block, b_block) on 2D blocks compiles to Tensor Core instructions, and @triton.autotune searches over block sizes automatically.
  • Trade-off: much faster to write and often close to cuBLAS for GEMM-like and fused element-wise kernels (FlashAttention has a Triton version), but less control than CUDA, and debugging generated code is harder. It's the "productivity" learning outcome of the course.

23.2 Several GPUs in one node (from the slides)

Multi-GPU systems are connected two ways: GPUs in one node over PCIe or NVLink (much faster, hundreds of GB/s), and nodes over a network (InfiniBand). The lecture covers one node. The workflow:

  1. Count devices: cudaGetDeviceCount(&ngpus).
  2. For each device: cudaSetDevice(d), then create its stream and allocate its memory. Every CUDA call after cudaSetDevice targets that device.
  3. Launch work on each GPU through its stream. Calls are asynchronous, so you can switch device while earlier GPUs are still running, and all GPUs work at once.
  4. Wait on each stream.
for (int d = 0; d < ngpus; d++) {
  cudaSetDevice(d);
  int off = d * chunk;
  cudaMemcpyAsync(d_a[d], h_a + off, chunk * 4, cudaMemcpyHostToDevice, s[d]);   // pinned h_a
  vecAdd<<<(chunk + 255) / 256, 256, 0, s[d]>>>(d_a[d], d_b[d], d_c[d], chunk);
  cudaMemcpyAsync(h_c + off, d_c[d], chunk * 4, cudaMemcpyDeviceToHost, s[d]);
}
for (int d = 0; d < ngpus; d++) { cudaSetDevice(d); cudaStreamSynchronize(s[d]); }

Peer-to-peer (P2P)

int can; cudaDeviceCanAccessPeer(&can, 0, 1);          // can GPU 0 access GPU 1?
if (can) { cudaSetDevice(0); cudaDeviceEnablePeerAccess(1, 0); }   // one direction only
cudaMemcpyPeerAsync(d_dst_on1, 1, d_src_on0, 0, bytes, s0);   // GPU0 → GPU1 without the host

Access is enabled one direction at a time. With P2P, kernels on GPU 0 can even dereference pointers into GPU 1's memory. Without it, copies go through host memory and are much slower. NVIDIA's p2pBandwidthLatencyTest sample measures what your node supports.

23.3 Data-parallel training and NCCL

The most common multi-GPU pattern in ML is data parallelism: every GPU holds a full copy of the model and processes a different slice of the batch. After the backward pass, the gradients are averaged across GPUs with an all-reduce, so every replica applies the same update. NCCL implements all-reduce efficiently over NVLink, PCIe and networks (ring and tree algorithms). For a ring all-reduce over G GPUs, each GPU sends and receives about 2·(G−1)/G times the gradient size, nearly constant in G, which is why it scales. llm.c supports multi-GPU training this way (NCCL, with MPI to launch processes). Larger models also split the model itself (tensor or pipeline parallelism).

Worked example: will 2 GPUs train twice as fast?

Problem. GPT-2 124M has about 124M parameters. In BF16 the gradients are about 250 MB. One training step takes 200 ms of compute on one GPU. Two GPUs are connected by (a) NVLink at ~300 GB/s or (b) PCIe at ~25 GB/s. Estimate the step time with data parallelism.

  1. Compute per GPU: each processes half the batch, so about 100 ms.
  2. Ring all-reduce traffic per GPU: 2·(2−1)/2 × 250 MB = 250 MB.
  3. (a) NVLink: 250 MB / 300 GB/s ≈ 0.8 ms, so the step is ≈ 101 ms: about 2× faster.
  4. (b) PCIe: 250 MB / 25 GB/s = 10 ms, so ≈ 110 ms: about 1.8×. Overlapping the all-reduce with the backward pass (as frameworks do, layer by layer) hides most of even this.
  5. Amdahl again: anything not split (data loading, the optimizer if not sharded) limits scaling as GPUs are added.

Exercises

RecallIn Triton, what replaces blockIdx and the if (i < n) check?

tl.program_id(axis), and a mask passed to tl.load/tl.store.

RecallWhich call selects the GPU that subsequent CUDA calls target?

cudaSetDevice(id).

ExplainWhat does the Triton compiler do that you did by hand in Part 2?

It maps block operations onto threads with coalesced accesses, stages data in shared memory, inserts synchronization, picks vector widths, and uses Tensor Cores for tl.dot. You specify the block size (or autotune it); it handles the rest.

ExplainWhy is peer access enabled per direction?

cudaDeviceEnablePeerAccess maps the peer's memory into the current device's address space, which is a one-way mapping. For both directions, call it from each device.

ApplyYour 4-GPU vector add is no faster than 1 GPU. Most likely reason?

It's dominated by host↔device PCIe transfers, which may share the same host links, and the kernel is tiny. Also check that you used pinned memory and per-device streams, and didn't synchronize inside the launch loop. Multi-GPU pays off when each GPU does a lot of compute per byte transferred.

Lab: Triton on Colab

  1. In a Colab Python cell (T4 runtime): pip install triton (usually preinstalled with PyTorch), then run add_kernel above and check it against x + y.
  2. Time it with triton.testing.do_bench and compute GB/s. Compare with your CUDA vector add's kernel-only bandwidth.
  3. Try BLOCK = 128, 256, 1024 and 4096. What changes?
  4. On the cluster, if your node has more than one GPU (MIG slices count as separate devices), run cudaGetDeviceCount and the multi-GPU vector add. MIG slices don't support P2P; note what you find.
Solution and expected output

Step 2: both reach ~250–280 GB/s at n = 2²⁴ on a T4, the same memory-bound limit (Module 4). Triton matches hand-written CUDA for this simple kernel.

Step 3: small blocks (128) are slightly slower (more programs, less work each); 1024–4096 are about equally good. Large blocks give each program more elements, which Triton vectorizes across its warps (its num_warps setting).

Step 4: MIG instances appear as separate devices only if the cluster assigns you several, and cudaDeviceCanAccessPeer returns 0 between MIG slices.

Module 24 · Review of Parts 2–4 · Oral exam (bonus) · Project presentation

Final drill: bonus oral exam and project defence

Answer each out loud in about a minute, with no notes. Brackets point to the module to reread. Do this weekly from December, together with Module 11.

Memory and kernels

ExplainWhat is coalescing, and how do you check whether a kernel has it? [12]

A warp's 32 accesses combine into few 32-byte sectors when they fall on consecutive addresses. Check in ncu's Memory Workload Analysis: about 4 sectors per request for 4-byte loads means coalesced; up to 32 means fully scattered. SourceCounters points to the line.

ExplainWalk through your tiled matmul's phases and both barriers, and derive its global reads. [14]

Per phase: load one A and one B element per thread into shared tiles; barrier (tiles complete); T multiply-adds from shared memory; barrier (no overwrite while in use). Reads fall from 2MNP to 2MNP/T. Explain non-square tiles from how many grid columns and rows reload each A and B element.

ApplyWhy wasn't your tiled kernel T times faster than naive? [12, 14]

The naive kernel was partly rescued by L1/L2 caching, so its real DRAM traffic was below 2MNP. The tiled kernel adds barriers and shared-memory traffic, and becomes limited by shared-memory bandwidth, latency and occupancy rather than DRAM. Back it with ncu numbers.

ExplainWhat's a race condition, and when do you need atomics? [13]

Several threads doing read-modify-write on the same location without coordination lose updates, with results depending on timing. Use atomics for shared counters or accumulators, after reducing within blocks to keep atomics few.

Whole program

ApplyYour naive llama2.c port is slower than the CPU. Explain with the nsys profile, then name your fix and the metric that proved it worked. [15]

Per-call cudaMalloc/cudaFree and re-copying constant weights dominate API time and H2D bytes, while kernels are a few percent of GPU time. Fix: allocate once and copy weights once. Evidence: malloc call counts and H2D bytes per token drop, and tokens/s rises.

ExplainWhen do streams help, and what limits them? [16]

When copies and kernels take comparable time: they run concurrently on separate engines. The floor is the busiest single resource (often the H2D link). Requires pinned memory and non-default streams.

ApplyHow do you recognize and fix a launch-bound program? [17]

Many short kernels with launch time ≥ kernel time and gaps on the timeline. Fix by doing more work per launch (fusion, more steps per kernel) or with CUDA Graphs.

ExplainWhat does Unified Memory do on a page fault, and how do you avoid fault costs? [18]

The GPU stalls the warp while the driver migrates the page over PCIe and updates page tables. Avoid it with cudaMemPrefetchAsync before kernels and cudaMemAdvise hints, and by not touching data on the CPU between kernels.

Project (llm.c)

ApplyExplain the speed-up from BF16 in llm.c: where does it come from, and why isn't it the Tensor Core ratio? [20, 21]

GEMMs move to Tensor Cores (much higher peak), and memory-bound kernels move half the bytes. The overall speed-up is smaller than the GEMM speed-up because of Amdahl: softmax, layernorm, element-wise ops and the optimizer don't use Tensor Cores and stay memory-bound, some in FP32. Support it with the per-kernel nsys breakdown before and after.

ApplyHow did you check that an optimization didn't break training? [21]

Kernel-level: compare outputs against the reference with max absolute and relative error. Model-level: validation loss/perplexity curves for baseline and optimized runs over the same steps and data. Explain any small difference by rounding.

ApplyWhy does your fused attention kernel save memory, and how does it relate to tiling? [14, 20]

It computes attention tile by tile in shared memory with an online softmax, so the N × N score matrix is never written to global memory. Memory drops from O(N²) to O(N), and traffic falls the same way tiled matmul's does. It's Module 14's idea applied to attention.

ApplyAn examiner asks: "Why not just use PyTorch?" Answer. [10, 22, 23]

The project's purpose is to understand and measure where time goes and why. llm.c exposes every kernel, so each optimization can be isolated, measured and explained with roofline, occupancy and Amdahl arguments. The same techniques are what PyTorch, cuBLAS, Triton and FlashAttention use internally.