cudatraining · lab notes

LESSON 09 · 2026.04.20 · L4

MHA + Causal FA — 80% of cuDNN in 300 lines

Extend the 2-D Triton FA to (B, H, N, d) + a causal mask. Register with torch.library.custom_op, and with torch.compile(fullgraph=True) an entire LLaMA-style attention block lands in a single graph.

GPU · L4 · fp16 kernel · 192 lines (incl. docstring) op wrapper · 76 lines

Five things that changed

changelocationcore idea
2-D → 4-D stridekernel signaturePass all 4 strides × 3 tensors for (B,H,N,d) at launch
3-D grid(cdiv(N, BM), H, B)One program handles a single (batch, head) Q block
causal specializationIS_CAUSAL: tl.constexprCompile two kernels (causal / non-causal); remove the runtime branch
loop skip (FA-v2)end_n = min(N, (pid_m+1)*BM)Drop upper-triangle tiles from iteration entirely
Diagonal maskoffs_m[:,None] >= offs_n[None,:]Only the tile that straddles the diagonal actually applies it

Accuracy (max rel_err vs fp32 reference)

stage# shapesfp32 worstfp16 worst
non-causal573.5e-33.4e-4
causal (+ N=129/513 edge)601.1e-33.2e-4

Speed — LLaMA-7B causal, d=128, fp16

(B, H, N)oursSDPA (FA-2)ours/SDPAvs naive
(1,32,512)0.100 ms · 21.5 TF0.1001.00×10.97×
(1,32,1024)0.223 · 38.5 TF0.2020.90×29.6×
(1,32,2048)0.784 · 43.8 TF0.6130.78×31.4×
(1,32,4096)2.964 · 46.4 TF2.5590.86×32.7×
(2,32,2048)1.565 · 43.9 TF1.3720.88×31.3×

On GPT-2 (d=64) short shapes: 1.13× ahead at (16,12,512), tied at (8,12,1024).

Lesson 1 · Causal speed isn't from the mask

Just masking still loads the upper triangle and flips it to -inf. The real speed comes from loop skip:

if IS_CAUSAL:
    end_n = tl.minimum(N, (pid_m + 1) * BLOCK_M)
else:
    end_n = N
for start_n in range(0, end_n, BLOCK_N):
    ...

On average it drops to N/2, so both the K/V loads and the two tl.dots are halved. Verified: non-causal (1,32,2048,128) 2.643 ms vs causal 0.784 ms — half the FLOPs, but 3.3× faster (pipeline amortization).

Lesson 2 · tl.constexpr = fold away the runtime if

Pass is_causal as a plain arg and you get a branch inside the tight K loop plus warp-scheduler tangles. With tl.constexpr, Triton compiles two specialized kernels (True/False). At runtime, only one is dispatched — the branch doesn't even exist. Using autotune(key=[...,"IS_CAUSAL"]) also separates the autotune space: empirically, causal prefers BM=64 / BN=128 while non-causal likes bigger tiles.

Lesson 3 · Three gifts from custom_op

@custom_op("triton_training::flash_attention_mha",
           mutates_args=(), device_types="cuda")
def flash_attention_mha(q, k, v, is_causal: bool = False) -> Tensor: ...

@flash_attention_mha.register_fake
def _(q, k, v, is_causal): return torch.empty_like(q)
  1. torch.compile doesn't break the graph. register_fake supplies shape inference so Dynamo doesn't see an unknown op. Under fullgraph=True, the entire LLaMA AttentionBlock lives in one graph.
  2. Serialization-preserving. ONNX/AOT/TorchScript record triton_training::flash_attention_mha as-is.
  3. Drop-in torch.ops.<ns>.<op> path. Downstreams like vLLM call without importing Triton. The same pattern vLLM uses to expose custom kernels.

Lesson 4 · The ROI of 300 lines

Doing the same in CUDA: 5–10× the code (Lesson 6's FA v1 was ~500 lines + 150 lines of host), plus manual re-tuning on every new GPU. This is the whole reason to learn Triton.

The remaining 20% gap

The 22% left to FA-2 isn't Triton's gap — it's what cuDNN FA-2 has:

Triton is getting persistent / warp-spec but it's tutorial-stage. Closing this gap takes CUTLASS 3.x — next round.

Position summary

80% of cuDNN in 300 lines. The remaining 20% only comes from the layer below.