A3 — CUDA Kernels
Files: llmc/encoder.cuh, llmc/layernorm.cuh, llmc/attention.cuh, llmc/matmul.cuh, llmc/gelu.cuh, llmc/fused_classifier.cuh, llmc/adamw.cuh, train_gpt2.cu
Needs: 1 NVIDIA GPU
Builds on: A0–A2
Check with: ./grade A3
Overview
Here you port the forward pass, backward pass, and optimizer from A1/A2 to CUDA. The goal is not just correctness — you must write kernels that saturate GPU memory bandwidth. A correct but slow kernel is insufficient.
By the end of A3, ./train_gpt2cu will train GPT-2 124M at hardware-limited speed on a single GPU. Multi-GPU is A4.
This one requires an NVIDIA GPU. A0–A2 did not; everything from here does. ./grade A3 reports every check as SKIP when nvcc is absent, so you can still read through it without one.
To list your work:
grep -rn "TODO (A3)" llmc/ train_gpt2.cu
When these notes and the in-file HINT disagree, the code is authoritative — please report the discrepancy so it can be fixed.
Key techniques you will use:
- 128-bit vectorized loads/stores (
x128/Packed128<T>) to reach peak memory bandwidth - Warp-level reductions (
warpReduceSum,warpReduceMax) to avoidatomicAddbottlenecks - Block-level reductions (
blockReduce) using shared memory and warp shuffles - Online softmax to compute softmax in a single pass without materializing all values
- Stochastic rounding to correctly convert FP32 accumulations back to BF16/FP16
- cuBLASLt for matrix multiplications with fused epilogue operations
Where this fits
flowchart LR
A0["A0
tokens, batches, PRNG"] --> A1["A1
forward pass (CPU)"]
A1 --> A2["A2
backward + AdamW (CPU)"]
A2 --> A3["A3
port to CUDA (1 GPU)"]
A3 --> A4["A4
scale to N GPUs"]
A3 --> A5["A5
schedules, clipping, checkpoints"]
style A3 fill:#2563eb,stroke:#1e40af,color:#fff
The maths does not change in this assignment — A1 and A2 already defined it. What changes is that you are now writing for a machine with tens of thousands of threads and a memory system that is the actual bottleneck. Most of these kernels do very little arithmetic per byte loaded, so the goal is not “compute faster”, it is stop wasting memory bandwidth.
flowchart TB
subgraph gpu["one GPU"]
direction TB
hbm["HBM / global memory
~2 TB/s, ~80 GB
this is the bottleneck"]
l2["L2 cache"]
smem["shared memory
per block, ~100 TB/s"]
regs["registers
per thread"]
hbm <--> l2 <--> smem <--> regs
end
hbm -.->|"load128cs
bypasses L1"| regs
regs -.->|"__shfl_xor_sync
warpReduceSum"| regs
Three ideas do most of the work:
| Technique | What it buys you |
|---|---|
x128 vectorized load/store |
One 128-bit transaction instead of 8 scalar ones. Memory-bound kernels get most of their speed here. |
| warp/block reductions | A sum over C without atomicAdd serialisation — and deterministic, which A4 needs. |
| kernel fusion | fused_residual_forward5 and fused_classifier_kernel5 avoid writing a [B,T,C] or [B,T,Vp] tensor to HBM only to read it straight back. |
Why the classifier is fused. The naive path materialises probs[B,T,Vp].
At B=4, T=1024, Vp=50304 that is ~820 MB in BF16 — written, then read, then
written again for the gradient. Fusing softmax + loss + dlogits into one kernel
removes both round trips, which is why it is the single biggest kernel in the
codebase and worth 10 points on its own.
Why stochastic rounding. Gradients accumulate in FP32 but weights live in BF16, which has 8 mantissa bits. Round-to-nearest turns every update smaller than half an ULP into a no-op, so small consistent gradients never move the weight at all. Rounding probabilistically makes the update correct in expectation. This is the classic silent A3 bug: everything runs, nothing errors, and the model converges to a visibly worse loss.
flowchart LR
subgraph pat["the three kernel shapes in this codebase"]
direction TB
pa["A: elementwise
one thread per x128 chunk
encoder, gelu, residual"]
pb["B: row reduction over C
one block per (b,t)
layernorm fwd/bwd"]
pc["C: row reduction over V
one block per (b,t), online softmax
fused classifier"]
end
Background Reading
| Source | Read | Why it matters here |
|---|---|---|
llmc/cuda_utils.cuh and llmc/cuda_common.h |
Packed128, load128/store128 + cs variants, warpReduceSum, blockReduce, stochastic_rounding |
Provided, and you will use them in nearly every kernel. Read them before starting — most of A3 is composing these four primitives. |
| NVIDIA, CUDA C++ Programming Guide | Ch. 5 (memory hierarchy), §5.3 (shared memory), §5.4 (warp shuffles) | The mental model for why x128 and warp reductions matter. §5.4 in particular explains the shuffle intrinsics warpReduceSum is built from. |
| Harris, Optimizing Parallel Reduction in CUDA | all 38 slides | The canonical walkthrough: seven successive versions of a sum reduction, each faster than the last, with the reason for each change. This is the single most useful document for A3. |
| Milakov & Gimelshein 2018, Online normalizer calculation for softmax | §3, Algorithm 3 | The one-pass max-and-sum recurrence used by softmax_forward_kernel5 and prepare_softmax_blockwide3. |
| Micikevicius et al. 2018, Mixed Precision Training | §3 | Why FP32 master weights exist alongside BF16 working weights, and what goes wrong without them. Directly explains the use_master_weights path in Part 7. |
| Gupta et al. 2015, Deep Learning with Limited Numerical Precision | §2.2 | Stochastic rounding, and the experiment showing round-to-nearest stalls training at low precision. This is the justification for the warning in Part 7. |
| NVIDIA, cuBLASLt docs | skim | Background only — matmul_cublaslt is provided. Useful for understanding what a “fused epilogue” is when you meet gelu_fusion. |
Key Data Types
// floatX is the training precision: BF16 by default, FP16 or FP32 with flags
typedef __nv_bfloat16 floatX; // default
// x128: 128-bit packed load of 8 x BF16 (or 4 x FP32)
typedef Packed128<floatX> x128;
// f128: 128-bit packed load of 4 x float (always FP32)
typedef Packed128<float> f128;
Note x128::size is precision-dependent: 8 in BF16/FP16 mode, 4 in FP32 mode. Never hard-code it.
Always load and store in x128 chunks. The load128cs (cache-streaming) and store128cs variants bypass L1 for data that won’t be reused. Use load128cs for large sequential reads and store128cs for write-only outputs.
Parallelism Strategy
Most kernels follow one of three patterns:
Pattern A — One thread per output element:
idx = blockIdx.x * blockDim.x + threadIdx.x
out[idx] = f(inp[idx])
Used for: encoder_forward_kernel3, gelu_forward_kernel2, residual_forward_kernel
Pattern B — One warp/block per row (reduction over C):
row = blockIdx.x
each thread processes C/blockDim.x elements of that row
warpReduceSum / blockReduce to aggregate
Used for: layernorm_forward_kernel3, layernorm_backward_kernel10
Pattern C — One block per (b,t) position (reduction over vocab):
position = blockIdx.x // indexes into B*T
each thread handles part of the V dimension
online max/sum reduction via blockReduce
Used for: fused_classifier_kernel5, prepare_softmax_blockwide3
Part 1 — Encoder (llmc/encoder.cuh)
encoder_forward_kernel3
Each thread processes x128::size contiguous channels:
tid = blockIdx.x * blockDim.x + threadIdx.x
c = tid % (C / x128::size) // which x128-chunk of channels
bt = tid / (C / x128::size) // which (b,t) position
b = bt / T, t = bt % T
// load x128 from wte[inp[b,t]*C + c*x128::size]
// load x128 from wpe[t*C + c*x128::size]
// add element-wise and store128cs to out[bt*C + c*x128::size]
Use load128cs for both embedding reads and store128cs for the output.
wte_backward_kernel
The backward pass over wte is complicated because multiple sequence positions may reference the same token. A naive atomicAdd would introduce non-determinism — which A4 then makes visible as a bit-exactness failure across GPU counts. The codebase pre-sorts tokens into buckets by vocabulary index on the CPU, then assigns one CUDA block per bucket. Within the block, warps cooperate over bucket items, and warp 0 accumulates the final gradient using stochastic_rounding.
You do not implement the bucket-sorting logic (it is in the launcher). Focus on the kernel: given bucket_info (start index, size) for this block’s token, accumulate the gradient from all positions in the bucket into a warp-level accumulator, then write back to dwte using stochastic rounding.
wpe_backward_kernel
Simpler: for each position t, accumulate dout[b, t, c] over the batch dimension:
for b in 0..B:
acc += load128cs(dout + (b*T + t)*C + c)
stochastic_rounding(acc, dwpe + t*C + c, seed ^ t ^ c)
Part 2 — LayerNorm (llmc/layernorm.cuh)
layernorm_forward_kernel3
One block per (b, t) row. Each thread computes a partial sum over C/blockDim.x elements. After a blockReduce for mean and variance:
mean = blockReduceSum(local_sum) / C
rstd = rsqrtf(blockReduceSum(local_sq_diff) / C + 1e-5f)
// write mean, rstd to output arrays
// apply normalization with weight and bias
Use load128cs for reading inp, store128 for writing out.
fused_residual_forward_kernel5
Fuses the residual addition with the subsequent LayerNorm. In the training loop the pattern residual_forward + layernorm_forward appears twice per transformer block; fusing saves a full read of a [B,T,C] tensor. The kernel computes both outputs in one launch:
out_residual = inp1 + inp2
out_ln = layernorm(out_residual, weight, bias)
layernorm_backward_kernel10
One block per (b,t) row. Accumulates dweight and dbias across rows via shared-memory scratch plus a final atomic flush, and computes dinp using the two-reduction formula from A2 Part 2.
Part 3 — Attention (llmc/attention.cuh)
The GPU attention path transposes Q, K, V into a layout amenable to batched GEMM, then calls cuBLASLt for the QK^T and att·V products. You implement the surrounding permutation and softmax kernels.
permute_kernel / permute_kernel_backward
Rearrange inp[B, T, 3C] → Q[B, NH, T, HS], K[...], V[...]:
// forward:
q[b, nh, t, hs] = inp[b, t, nh*HS + hs] (first C channels)
k[b, nh, t, hs] = inp[b, t, C + nh*HS + hs]
v[b, nh, t, hs] = inp[b, t, 2C + nh*HS + hs]
// backward: reverse the mapping, accumulate into dinp
unpermute_kernel / unpermute_kernel_backward
Inverse of permute: merge per-head outputs back into a single [B, T, C] tensor.
softmax_forward_kernel5
Row-wise causal softmax over [B, NH, T, T]. Each block handles one row (b, nh, t1) and only the first t1+1 entries are live. Use the online max-then-sum algorithm:
maxval = -INFINITY, sumval = 0
for each chunk: x = inp[...]
maxval_new = max(maxval, x)
sumval = sumval * exp(maxval - maxval_new) + exp(x - maxval_new)
maxval = maxval_new
// warpReduceMax then warpReduceSum for block-level aggregation
// normalize and write out
softmax_autoregressive_backward_inplace_kernel
Given datt and the post-softmax att, compute dpreatt in place:
dpreatt[t1,t2] = att[t1,t2] * (datt[t1,t2] - dot(att[t1,:], datt[t1,:]))
Use a block reduction for the per-row dot product.
Part 4 — Matrix Multiplication (llmc/matmul.cuh)
You do not write a GEMM kernel — no tiling, no tensor-core intrinsics, no software pipelining. cuBLASLt does that, and it is very hard to beat. What you do write is everything around it, which is more than it sounds:
matmul_cublaslt
The wrapper every matmul in the model goes through. Build a
cublasLtMatmulDesc and the three cublasLtMatrixLayouts, set TRANSA/TRANSB
for the operand order you want, attach the optional epilogue
(CUBLASLT_EPILOGUE_BIAS, or the fused ..._GELU_AUX variants), then call
cublasLtMatmulAlgoGetHeuristic and cublasLtMatmul with a workspace.
This is where fused epilogues come from: asking cuBLASLt to add the bias and
apply GELU while the output tile is still in registers, instead of launching two
more kernels that each re-read a [B,T,4C] tensor from HBM. That is what the
gelu_fusion flag toggles, and it is a real speedup for free.
matmul_forward_cublaslt
Thin dispatcher: pick the argument order, and if gelu_fusion < 1, run
gelu_forward as a separate kernel afterwards instead of in the epilogue.
matmul_backward_bias_kernel9
Reduces dout[B*T, OC] along B*T to get dbias[OC], using warp-level shuffle reductions for the inner sum.
reduce_add_sum_kernel
Sums partial results across thread blocks when the bias gradient doesn’t fit in a single block reduction.
matmul_backward
The launcher. Calls:
matmul_cublasltfordinp = dout @ weightmatmul_cublasltfordweight = dout.T @ inp- If
bias != NULL:matmul_backward_bias_kernel9fordbias
Part 5 — GeLU (llmc/gelu.cuh)
gelu_forward_kernel2 / gelu_forward — same formula as A1, vectorized over x128::size elements per thread.
gelu_backward_inplace_kernel / gelu_backward_inplace — same derivative as A2, vectorized. Note the signature: it takes a single floatX* d_in_out and works in place, reading the incoming gradient and overwriting it with the GeLU-adjusted one, rather than taking separate dinp/dout buffers as the CPU version did. Launch with block_size = 128.
Part 6 — Fused Classifier (llmc/fused_classifier.cuh)
The most complex kernel in the codebase. It fuses softmax, cross-entropy, and the logit gradient into a single kernel, avoiding materializing the [B*T, Vp] probability tensor.
prepare_softmax_blockwide3 (device function)
Computes the softmax normalizer for one (b,t) row using the online algorithm, processing the vocabulary in x128 chunks with each thread holding a running (max, sum) pair. After a block-wide reduction:
return SoftmaxParams{1.0f / sumval, maxval}
fused_classifier_kernel5
Template kernel over <WriteDLogits, WriteProbs>. For each (b,t) position (one block):
prepare_softmax_blockwide3→{scale, offset}loss = -log(exp(logits[target] - offset) * scale)- Write to
losses[b*T+t] - If
WriteDLogits:logits[b,t,v] = dloss * (expf(logits[b,t,v] - offset) * scale - (v == target))— note this writes the gradient in place over the logits buffer - If
WriteProbs: writeexpf(...) * scaletoprobs
The template parameters are compile-time so the branches vanish. The training loop calls with WriteDLogits=True; validation with False.
As in A1/A2, the reduction and the target lookup run over [0, V) while the row stride is Vp.
Part 7 — AdamW on the GPU (llmc/adamw.cuh)
The same optimizer you wrote for the CPU in A2, now with three additions.
lerp (device function)
start + weight * (end - start), expressed as two FMAs so the result is exact at the endpoints. Use it for the m and v updates.
adamw_update (device function)
The A2 update, plus:
- a
grad_scalemultiplier applied to the gradient (this is how A5’s gradient clipping is applied — the gradient buffer itself is never modified) - selective weight decay: the caller passes
weight_decay = 0for 1D tensors (biases, LayerNorm scales/shifts) and the configured value for 2D tensors. Unlike the CPU version in A2, decay is not uniform. - optional master weights: when present, the FP32 master copy is the one being updated, and the working-precision copy is written with
stochastic_rounding.
adamw_kernel3 — dispatches the device-side adamw_update for the right slice, offsetting each pointer by blockIdx.y * stride first.
init_from_master_kernel — stochastically rounds master_params (float) down into params_memory for one slice. A5’s checkpoint resume uses this to rebuild working-precision weights from the master copy.
adamw_update (host overload) / init_from_master (host) — the launchers. Both launch over a 2D grid, dim3(num_blocks, num_slices), with block_size = 512. Note adamw_update is overloaded: a __device__ function that updates one parameter, and a host function that launches adamw_kernel3. The launcher precomputes beta1_correction = 1 - beta1^t and beta2_correction = 1 - beta2^t so the kernel does not recompute powf per element.
Plain
(floatX)valueinstead ofstochastic_roundingintroduces a systematic bias that compounds over thousands of steps. This is the single most common silent A3 bug: training appears to work and converges to a visibly worse loss.
Part 8 — Wiring it together (train_gpt2.cu)
gpt2_build_from_checkpoint
Same job as A1’s, with two differences: the version must be 3 (fp32) or 5 (bf16) and must be cross-checked against the compiled PRECISION_MODE (loading a bf16 checkpoint into an fp32 build is a fatal error, and vice versa); and weights are streamed to the device with file_to_device rather than fread.
gpt2_forward
Same structure as A1 Part 8, but:
encoder_forwardcalls your kernel launcher- QKV projection and all other GEMMs:
matmul_forward_cublaslt(provided) - Attention: your kernels
- After the attention projection:
fused_residual_forward5— one call replacesresidual_forward+layernorm_forward - MLP:
matmul_forward_cublasltwith thegelu_fusionflag, which folds GeLU into the GEMM epilogue - After each layer except the last:
fused_residual_forward5produces bothresidual3and the next layer’sln1
gpt2_backward_and_reduce
Same structure as A2 Part 3, with CUDA launchers. It also takes grad_accum_steps / micro_step: zero the gradient buffers only on micro_step == 0, and use dloss = 1.0f / (B * T * grad_accum_steps).
This function also carries a TODO (A4) for the NCCL gradient reduction. For A3, leave that part out — with one GPU multi_gpu_async_reduce_gradient returns immediately anyway.
gpt2_update
Drives the host-side adamw_update launcher over all 16 parameter tensors, passing weight_decay only for the 2D ones. It also carries a TODO (A4) for ZeRO-1 sharding and the ncclAllGather; for A3 implement the single-GPU path only.
Testing
make train_gpt2cu
make test_gpt2cu
./test_gpt2cu
test_gpt2cu is the CUDA sibling of test_gpt2 and checks against the same gpt2_124M_debug_state.bin reference. It accepts three flags:
| Flag | Meaning |
|---|---|
-w 0\|1 |
use master weights |
-r 0\|1\|2 |
recompute level (2 also recomputes LayerNorm) |
-ge 0\|1 |
GeLU fusion in the matmul epilogue |
Run all of them — the recompute and fusion paths take different routes through your kernels:
./test_gpt2cu
./test_gpt2cu -w 0
./test_gpt2cu -r 2
./test_gpt2cu -ge 0
Then train for real:
./train_gpt2cu -i dev/data/tinyshakespeare/tiny_shakespeare_train.bin \
-j dev/data/tinyshakespeare/tiny_shakespeare_val.bin \
-b 4 -t 1024 -v 20 -x 100
These are single-character flags (-i, -b, -x, …), not GNU long options. --input-bin will not work — the parser reads argv[i][1]. Run ./train_gpt2cu with no arguments to see the full list. The ones you need most in A3:
| Flag | Meaning |
|---|---|
-i / -j |
train / val data shard glob |
-b / -t |
micro-batch size B / sequence length T |
-d |
total batch size in tokens (sets gradient accumulation) |
-x |
number of steps |
-v |
run validation every N steps |
-w |
use master weights (default 1) |
-r |
recompute level |
-ge |
GeLU fusion |
-f |
override TF32 |
MFU is reported in the per-step log line, but only once gpt2_estimate_mfu is implemented in A5. Until then it prints -1. For A3 we measure performance from the reported step time; see below.
Performance Targets
| GPU | Expected MFU (BF16) | Naive baseline |
|---|---|---|
| A100 80GB | ≥ 50% | ~15% |
| H100 80GB | ≥ 55% | ~12% |
| RTX 4090 | ≥ 40% | ~10% |
| RTX 3090 | ≥ 35% | ~8% |
On a 4090, step time for B=4, T=1024 GPT-2 124M should be under 200 ms. MFU below 20% means something is leaving a lot on the table — profile with Nsight Compute. These numbers depend heavily on which GPU you are on, so treat them as a target rather than a threshold.
Common Mistakes
- Hard-coding
x128::sizeas 8. It is 4 in FP32 builds, and the FP32 test configuration will fail. - Shared memory bank conflicts. If threads in a warp hit the same bank, throughput drops 32×. Pad shared arrays by one element.
- Inconsistent
__syncthreads(). Every thread in a block must reach it. Don’t put syncs inside divergent conditionals. - Misaligned
x128reads.load128requires 16-byte alignment.Cis a multiple of 64 in GPT-2, soc * sizeof(floatX)is aligned whenevercis a multiple ofx128::size. - Forgetting
cudaCheck(cudaGetLastError())after launches. Launch failures are otherwise silent until the next sync. - Stochastic rounding omitted. See the warning in Part 7.
- Writing gradient into the
[V, Vp)padding in the fused classifier.
Checking your work
./grade A3
Builds the CUDA trainer and runs ./test_gpt2cu in all four configurations,
scoring the kernel checks by how many pass. The MFU and compute-sanitizer
lines are reported as SKIP: both depend on your specific GPU, so run them
yourself with nsys/ncu and compute-sanitizer ./test_gpt2cu.
Scoring
What ./grade A3 checks, and what each part is worth.
| Component | Points |
|---|---|
encoder_forward_kernel3 correctness |
7 |
layernorm_forward_kernel3 and fused_residual_forward_kernel5 correctness |
9 |
layernorm_backward_kernel10 correctness |
7 |
permute / unpermute kernels correctness |
7 |
softmax_forward_kernel5 and the autoregressive softmax backward correctness |
8 |
fused_classifier_kernel5 correctness |
10 |
matmul_cublaslt, matmul_backward_bias_kernel9, matmul_backward correctness |
7 |
wte_backward_kernel and wpe_backward_kernel correctness |
7 |
adamw.cuh: lerp, adamw_update, selective decay, stochastic rounding |
10 |
gpt2_build_from_checkpoint, gpt2_forward, gpt2_backward_and_reduce, gpt2_update wire the kernels correctly |
8 |
test_gpt2cu passes in all four configurations (-w 0, -r 2, -ge 0, default) |
5 |
| Performance: MFU ≥ target on the reference GPU | 10 |
No memory errors (compute-sanitizer) |
5 |
| Total | 100 |