From-scratch implementations built for Stanford CS336 (Language Modeling from Scratch), covering GPU kernel programming and distributed training systems for large language models.
A from-scratch implementation of Flash Attention as a custom Triton GPU kernel.
Includes a PyTorch autograd reference implementation, a Triton forward/backward
kernel, and a Modal-based benchmarking script comparing against naive attention
and PyTorch's fused scaled_dot_product_attention.
-
flash_attention.pyMyFlashAttnAutogradFunctionClass— reference PyTorch implementation of flash attention (online softmax, single-pass forward + analytic backward) used to validate correctness of the Triton kernel.TritonFlashAttention—torch.autograd.Functionwrapping the Triton kernel, usable as a drop-in replacement for standard attention.flash_fwd_kernel— the Triton JIT kernel implementing the tiled forward pass: block-wiseQKᵀ, online softmax (running max/sum), causal masking, and accumulation of the output block, following the FlashAttention algorithm.
-
benchmark_attention.py- Modal app (
attention-benchmark) that runs on an H100 and times forward and backward passes across a grid ofd_modeland sequence lengths, comparing:- naive — explicit
QKᵀ → softmax → PV, materializing the full attention matrix (the O(N²) memory baseline) - torch_sdpa —
torch.nn.functional.scaled_dot_product_attention(PyTorch's fused/dispatched kernel, for reference) - triton_flash — the custom Triton kernel above
- naive — explicit
- Reports forward/backward latency, peak memory, and speedup/memory reduction relative to the naive baseline.
- Modal app (
Standard attention is memory-bound: it materializes an (seq_len, seq_len)
score matrix in HBM, which the GPU has to write and re-read for softmax and
the final matmul with V. Flash attention avoids this by tiling Q, K,
V into blocks that fit in SRAM and computing the output incrementally with
an online (running) softmax — keeping track of a running max m and running
normalizer l per query, and rescaling the accumulated output O as new
key/value tiles are processed. The score matrix itself is never fully
materialized, which turns an O(N²) memory-traffic problem into roughly
O(N²d / SRAM_size), and lets attention scale to much longer sequences before
running out of memory.
pip install modal
modal setup # one-time auth, opens a browser to link your Modal accountLocal dependencies for running/testing outside Modal:
pip install torch triton einops numpyFrom the directory containing flash_attention.py and benchmark_attention.py
(so the local module import resolves):
modal run benchmark_attention.pyThis builds a Modal image (torch, triton, einops, numpy), ships
flash_attention.py into it, and runs the benchmark remotely on an H100.
Output streams live to your terminal: per-config timings as each
implementation finishes, followed by a summary table of forward/backward
speedup and memory reduction versus the naive baseline.
Note: the naive implementation is expected to run out of memory at long sequence lengths — that's the intended demonstration of flash attention's memory advantage, not a bug.
Measured on Modal (H100), batch_size=8, comparing Triton flash attention
against naive PyTorch attention (full QKᵀ materialization):
| d_model | seq_len | fwd speedup | bwd speedup | peak mem reduction |
|---|---|---|---|---|
| 16 | 256 | 6.10x | 0.63x | 3.21x |
| 16 | 1024 | 4.89x | 0.76x | 12.18x |
| 16 | 4096 | 9.73x | 0.76x | 18.61x |
| 32 | 256 | 0.27x | 0.65x | 2.96x |
| 32 | 1024 | 4.62x | 0.72x | 10.40x |
| 32 | 4096 | 1.62x | 0.73x | 17.39x |
| 64 | 256 | 0.61x | 0.62x | 2.58x |
| 64 | 1024 | 11.77x | 0.59x | 8.13x |
| 64 | 4096 | 2.22x | 0.68x | 15.39x |
Takeaways:
- Memory reduction is the clearest, most consistent win — up to ~18.6x
peak memory reduction over naive attention at
seq_len=4096, growing with sequence length as expected, since naive attention's O(N²) score matrix is exactly what flash attention avoids materializing. - Forward speedup is real but noisy, especially at small sizes (e.g.
d_model=32, seq_len=256shows a 0.27x "speedup" — actually a slowdown, most likely Triton kernel launch overhead dominating at tiny problem sizes where there isn't much memory pressure to relieve yet). Speedup trends upward and stabilizes at largerd_model/seq_len. - Backward speedup is consistently below 1x (0.59x–0.76x) — the Triton
kernel's backward pass is currently slower than naive PyTorch. This
matches a known limitation of this implementation: the backward pass
recomputes attention weights in plain PyTorch from the saved logsumexp
Lrather than using a fused Triton backward kernel, so it doesn't get the same memory-bound advantage the forward pass does. A fused Triton backward kernel (following FlashAttention-2's tiled backward approach) would be the natural next step to close this gap.
Q_TILE_SIZE/K_TILE_SIZEare currently set conservatively (16); larger tiles (64–128) tuned to the target GPU's SRAM would better reflect production flash-attention kernel performance.- Backward pass for
TritonFlashAttentioncurrently recomputes attention weights in PyTorch (not a fused Triton backward kernel) — benchmark data above confirms this makes backward consistently slower than naive PyTorch (0.59x–0.76x). A natural next step would be a fused Triton backward kernel following the same tiling approach as the forward pass.
A from-scratch PyTorch implementation of Fully Sharded Data Parallel
training. Shards nn.Linear and nn.Embedding parameters across ranks
(ZeRO-3 style) and overlaps communication with compute via forward/backward
hooks, exposed as a transparent nn.Module wrapper.
-
_ShardedLayer— per-layer sharding and communication state.- Splits each wrapped module's parameters into a flat buffer, pads to be
divisible by
world_size, and keeps only this rank's contiguous shard as the "true" parameter (local_shard) that the optimizer sees and updates. start_gather/finish_gather_and_install— asyncall_gather_into_tensorto reconstruct the full parameter on-device right before it's needed, then splices the flat buffer back into the module's named tensors.start_reduce_scatter/finish_reduce_scatter— asyncreduce_scatter_tensor(sum, then divided byworld_size) to combine gradients across ranks and leave each rank holding only the gradient for its own shard — no full gradient is ever materialized on any single rank.free— drops the gathered full parameter after use so peak memory stays at shard size, not full-model size.
- Splits each wrapped module's parameters into a flat buffer, pads to be
divisible by
-
FSDP— the user-facing wrapper.- Walks the wrapped module's submodules, replaces every
nn.Linear/nn.Embedding's parameters with a_ShardedLayer, and registers the local FP32 shards as this module's own parameters (soAdamW(fsdp_model.parameters(), ...)works with no special-casing). - Registers forward pre/post hooks and full backward pre/post hooks per
layer to gather parameters just before they're needed and free them
immediately after, with 2-layer-ahead prefetching in both directions
(
i+2on the forward pass,i-2on the backward pass) so the next layer's all-gather overlaps with the current layer's compute. finish_gradient_synchronization()— blocks until every in-flight reduce-scatter (and any stray all-gather) has completed, to be called afterloss.backward()and before the optimizer step.
- Walks the wrapped module's submodules, replaces every
Standard data parallelism (DDP) replicates full model parameters, gradients,
and optimizer state on every GPU — memory cost scales with model size
regardless of how many GPUs you have. FSDP instead shards parameters,
gradients, and optimizer state across ranks, so each GPU only permanently
holds 1/world_size of the model.
The trick to making this fast rather than just memory-efficient is overlap:
right before a layer's forward (or backward) needs its full parameters, an
async all_gather reconstructs them from all ranks' shards — and while that
layer computes, the next layer's gather is already kicked off in the
background via the hook-based prefetch. After compute, the full parameter is
freed immediately, so at any instant only ~2 layers' worth of full
parameters are resident, not the whole model. Gradients are synchronized
with reduce_scatter instead of all_reduce: each rank ends up with the
(mean) gradient for only its own shard, rather than every rank redundantly
holding the full gradient — the same sharding principle applied to the
backward pass.
- FP32 master shards:
local_shard(what the optimizer updates) is always FP32, while the gathered, compute-time parameter can be cast to a lowercompute_dtype(e.g. bf16) if passed toFSDP(...). This mirrors the mixed-precision "FP32 master weights" pattern used in production FSDP/ZeRO implementations. - Gradient reduction: reduce-scatter sums gradients across ranks and
divides by
world_size, matching DDP's mean-gradient semantics.
- Fixed prefetch depth: gather-ahead distance is hardcoded to 2 layers in both directions, not adaptive to layer size or available memory.
- Hard sync point:
finish_gradient_synchronization()blocks on every pending reduce-scatter; there's no overlap between the optimizer step and communication (an optimization real FSDP implementations pursue further). - Only shards
nn.Linearandnn.Embedding— other parameterized modules (e.g.nn.LayerNorm) are left unsharded and fully replicated.