Write CUDA by hand, time it correctly, and find out why your first version loses to the CPU.
1. What you build#
Three kernels in raw CUDA C++, each with a correct timing harness:
- SAXPY — the “hello world”; purely memory-bound.
- Naive matmul — one thread per output element.
- Tiled matmul — shared-memory tiles, the GPU version of Project 02’s blocking.
Then you compare all three against cuBLAS and against your Project 02 numbers.
2. Why it matters#
Until you have launched a kernel yourself, “the GPU” is a magic box that PyTorch talks to. After this project, terms like block, warp, shared memory, coalesced access, and launch overhead refer to code you wrote and bugs you hit.
3. Read first#
- VI.02 — Execution model
- VI.05 — Kernel launch, host and device
- VI.06 — Streams and synchronization
- VI.08 — CUDA programming
- VI.09 — Coalescing
4. Spec#
saxpy(n, a, x, y) n up to 2^28
matmul_naive(A, B, C, n) n ∈ {256 … 4096}
matmul_tiled(A, B, C, n) TILE = 16, 32 shared memory per block
Reference: cublasSgemm
Timing: cudaEvent around the kernel; separate timing for H2D/D2H copiesReport three times for every run: transfer in, kernel, transfer out. Never one blended number.
5. Milestones#
- Toolchain.
nvccbuilds and runs;nvidia-smishows your process. - SAXPY. Correct result, then sweep
n. Compute effective GB/s (3 × 4 × nbytes: read x, read y, write y) and compare to the card’s rated bandwidth. - Small-n crossover. Find the
nbelow which the CPU wins end to end. That number is launch overhead plus PCIe (VI.05, II.09). - Naive matmul. Works, and is disappointing. Profile it.
- Tiled matmul. Load tiles into
__shared__arrays,__syncthreads(), accumulate. - Versus cuBLAS. Expect to lose by a wide margin. List what cuBLAS does that you don’t.
- Pinned memory. Repeat transfers with
cudaMallocHost; record the H2D bandwidth change.
6. Starter skeleton#
__global__ void saxpy(int n, float a, const float *x, float *y) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) y[i] = a * x[i] + y[i];
}
#define TILE 16
__global__ void matmul_tiled(const float *A, const float *B, float *C, int n) {
__shared__ float As[TILE][TILE], Bs[TILE][TILE];
int row = blockIdx.y * TILE + threadIdx.y;
int col = blockIdx.x * TILE + threadIdx.x;
float acc = 0.f;
for (int t = 0; t < (n + TILE - 1) / TILE; ++t) {
int ac = t * TILE + threadIdx.x, br = t * TILE + threadIdx.y;
As[threadIdx.y][threadIdx.x] = (row < n && ac < n) ? A[row * n + ac] : 0.f;
Bs[threadIdx.y][threadIdx.x] = (br < n && col < n) ? B[br * n + col] : 0.f;
__syncthreads();
for (int k = 0; k < TILE; ++k) acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
__syncthreads();
}
if (row < n && col < n) C[row * n + col] = acc;
}
// Timing that measures the kernel, not the launch
cudaEvent_t t0, t1; cudaEventCreate(&t0); cudaEventCreate(&t1);
cudaEventRecord(t0);
saxpy<<<(n + 255) / 256, 256>>>(n, 2.0f, dx, dy);
cudaEventRecord(t1); cudaEventSynchronize(t1);
float ms; cudaEventElapsedTime(&ms, t0, t1);7. What to measure#
| Measurement | Expectation to write down first |
|---|---|
| SAXPY GB/s at n=2^28 | Close to rated memory bandwidth |
| CPU/GPU crossover n for SAXPY end-to-end | Thousands? Millions? |
| Empty kernel launch time | Single-digit microseconds |
| Naive vs tiled vs cuBLAS GFLOP/s at n=2048 | Each gap, with a cause |
| Pageable vs pinned H2D GB/s | Compare to II.09’s PCIe numbers |
| Block size 64/128/256/512/1024 for SAXPY | Mostly flat — why? |
Add the GPU’s measured bandwidth and peak GFLOP/s to numbers.md next to the CPU’s.
8. Done when#
- All kernels verified against a CPU reference.
- You can show, with numbers, a problem size where the GPU loses and explain it.
- Tiled beats naive, and you can explain it in terms of global-memory reads per output.
- You have opened one run in Nsight Systems or
nvprof-equivalent and identified the kernel and the memcpy on the timeline.
9. Common pitfalls#
Timing without synchronizing. Kernel launches are asynchronous. A host timer around the launch measures microseconds no matter how big the job is.
Forgetting the bounds check when n is not a multiple of the block size — silent memory
corruption.
Counting the first launch. It includes context creation and module load.
Column-major vs row-major when comparing to cuBLAS. cuBLAS is column-major.
Missing __syncthreads() — a race that only fails sometimes.
10. No GPU? Do this instead#
- Use a free Colab T4:
!nvcc -o saxpy saxpy.cu && ./saxpyworks in a notebook cell. - Or run the logic on the Numba CUDA simulator (
NUMBA_ENABLE_CUDASIM=1). You get correctness and the mental model, not the timings — borrow timings from a Colab session.
11. Stretch goals#
- Rewrite SAXPY in Triton-lang or as a
torch.compiled function and compare the generated code path (preview of XIII.11-12). - Add an FP16 matmul and look for tensor-core usage in the profiler (VI.11).
- Deliberately break coalescing (stride-n access) and measure the damage.
- Launch 10,000 tiny kernels vs one fused kernel — the motivation for CUDA graphs (VI.07).
12. Interview questions this project answers#
- Why must you synchronize before reading a GPU timer?
- Where does the time go for a small GPU workload?
- What does shared memory buy you in a tiled matmul?
- Why is SAXPY bandwidth-bound regardless of how many cores the GPU has?