PyTorch & Resource Accounting
Before you can build a language model, you have to know whether you can afford to train it. This part covers the question Lecture 2 of CS336 puts right after tokenization: given a model shape and a training run, how many FLOPs will it cost, how much GPU memory will it need, and will the hardware actually be able to keep its compute units fed โ with original, executed Python you can run yourself against the same numbers assignment1-basics asks you to reason about.
assignment1-basics's README and tests/adapters.py were also read directly โ and, notably, adapters.py contains no FLOPs-counting or memory-profiling functions; resource accounting in that assignment is a written-analysis question in the course's PDF handout, not a code stub, which is worth knowing before you go looking for it in the repo. The three standard references this article leans on โ Kaplan et al.'s scaling-laws paper (source of the 6N FLOPs heuristic), the ZeRO paper, and the Roofline model โ were each checked for title, authors, identifier and the specific claim attributed to them. The FLOPs and memory formulas themselves are derived from first-principles arithmetic in this article, not copied from any paper.
Why Resource Accounting Comes Before Code
It's tempting to treat "how much will this cost" as an afterthought โ something you check after the model works. CS336 puts it in Lecture 2, immediately after the course overview and before tokenization, because the opposite order is how research budgets get blown: a team spends weeks getting an architecture correct, then discovers the training run they designed needs 40 GPU-years, or that the activations alone won't fit in memory at the batch size they wanted. Resource accounting is arithmetic you can do on a napkin before writing a single line of model code, and it tells you up front which experiments are even possible on the hardware you have.
What Lecture 2 Actually Covers
lecture_02.py, viewable via the site's own lecture-trace viewer) and a recording. The lecture trace itself isn't reproduced here, so this article covers the same three concepts named in the title โ FLOPs, memory, arithmetic intensity โ independently, not as a transcript of the actual slides or trace.PyTorch & einops, Briefly
The "PyTorch" half of Lecture 2's title is mostly about a habit, not a library feature: writing tensor operations so that their shapes are legible at the call site instead of buried in comments. Raw PyTorch reshaping code like x.view(b, h, s, d).transpose(1, 2) is correct but says nothing about what b, h, s, and d mean without reading surrounding context. The einops library replaces that with named-axis notation โ rearrange(x, "b h s d -> b s (h d)") โ so the operation documents its own shape transformation. This matters more than it looks: most of the actual bugs in from-scratch transformer implementations (in assignment1-basics and everywhere else) are shape-mismatch or shape-mixup bugs โ attention heads getting concatenated in the wrong order, a sequence and batch axis getting silently swapped โ and einops-style code makes that entire bug class visible at a glance rather than crashing three layers downstream with a cryptic matmul dimension error.
Counting FLOPs
A FLOP (floating-point operation) is one multiply or one add. The dominant cost in a transformer is matrix multiplication, and a matrix multiply of a (m ร k) matrix by a (k ร n) matrix costs 2ยทmยทkยทn FLOPs โ one multiply and one add per output element, summed over the k-length dot product, hence the factor of 2. Every linear layer, every attention projection, and the attention score matrix itself are matrix multiplies, so this one formula is most of what you need.
For a full forward pass through a transformer with N non-embedding parameters processing one token, the standard shortcut used across the field is: forward pass โ 2ยทN FLOPs per token (each parameter participates in roughly one multiply-add per token it touches). Backward pass costs roughly twice the forward pass, because computing gradients requires propagating error back through the same matrices twice โ once with respect to the inputs, once with respect to the weights โ giving โ 4ยทN FLOPs per token. Forward plus backward together is commonly summarized as โ 6ยทN FLOPs per token, and total training compute for a run of D tokens is then approximately 6ยทNยทD. This is the estimate given in Kaplan et al., "Scaling Laws for Neural Language Models" (2020, arXiv:2001.08361): non-embedding training compute C โ 6NBS (batch size ร steps), i.e. roughly 6N FLOPs per training token, with the backward pass costing about twice the forward pass. It ignores the attention-score term, which grows with context length, so it slightly underestimates long-context training.
Counting Memory
Training memory is not just "the model." Four things typically compete for GPU memory at once, and mixed-precision training (the near-universal default) makes the accounting slightly more involved than "bytes per parameter times number of parameters":
m and v), also typically kept in fp32 (4 + 4 bytes/param). That's 12 bytes/param just for optimizer bookkeeping, on top of the 2+2 bytes for params and gradients โ optimizer state alone is roughly 3ร the size of the model weights.The ZeRO family of optimizations (Rajbhandari et al., "ZeRO: Memory Optimizations Toward Training Trillion Parameter Models" (2019, arXiv:1910.02054), from Microsoft's DeepSpeed team) exists specifically to stop replicating those terms on every data-parallel GPU: ZeRO partitions optimizer state, gradients and eventually parameters across devices, so per-GPU memory for model state shrinks roughly in proportion to the number of GPUs.
Arithmetic Intensity & the Roofline
Knowing the FLOPs and the memory traffic separately isn't enough โ what determines actual GPU utilization is their ratio. Arithmetic intensity is defined as FLOPs performed per byte moved between GPU memory (HBM) and the compute cores: AI = FLOPs / bytes_moved. A GPU has both a peak compute throughput (FLOPs/second) and a peak memory bandwidth (bytes/second); the "roofline" model from Williams, Waterman & Patterson, "Roofline: An Insightful Visual Performance Model for Multicore Architectures" (Communications of the ACM 52(4), 2009) says any real workload's achievable throughput is capped by whichever of those two limits it hits first. An operation with low arithmetic intensity (like an elementwise activation function, which moves a lot of data but does very little compute per element) is memory-bound: the GPU's compute units sit idle waiting for data. An operation with high arithmetic intensity (like a large matrix multiply, which reuses the same loaded values across many multiply-adds) is compute-bound: the GPU is doing useful work close to its peak FLOPs/second. This is precisely why kernel fusion (combining several memory-bound elementwise ops into one pass over the data) and why larger batch sizes (which raise the arithmetic intensity of matrix multiplies by reusing loaded weights across more rows) are two of the most effective performance levers in practice โ they're both, at bottom, ways of moving an operation further right on the roofline before it hits the memory-bandwidth ceiling.
Hands-On with assignment1-basics
Reading assignment1-basics's tests/adapters.py shows it defines 21 functions โ run_transformer_lm, run_multihead_self_attention, run_rmsnorm, run_get_batch, get_adamw_cls, and so on โ for the model and training-loop pieces you build in that assignment, but none of them are FLOPs- or memory-accounting functions. In the actual course, that reasoning is a written-analysis section of the assignment's PDF handout (its exact wording and point values aren't reproduced here), applied to the same GPT-2-scale configuration this series' Part 1 introduced.
The practical exercise this section sets up: once you have real num_non_embedding_params for a model shape (Part 1's transformer_param_count function computes this), and a target token count and batch/sequence configuration for a training run, you can answer three questions before you run any training code โ will it fit in memory, how long will it take on your hardware, and is the resulting workload compute-bound or memory-bound. The functions below do exactly that, and were executed directly (not just written) against a GPT-2-small-sized configuration to confirm the output is sane:
def transformer_training_flops(num_non_embedding_params, num_tokens): # Field-standard heuristic: forward ~= 2N FLOPs/token, backward ~= 2x forward, # so a full training step costs roughly 6N FLOPs per token processed. forward_flops_per_token = 2 * num_non_embedding_params backward_flops_per_token = 2 * forward_flops_per_token return (forward_flops_per_token + backward_flops_per_token) * num_tokens def mixed_precision_training_memory_bytes(num_params, activation_bytes=0): # bf16 compute copies of params/grads, fp32 master weights + Adam's two moments. params_bf16 = num_params * 2 grads_bf16 = num_params * 2 master_weights_fp32 = num_params * 4 adam_m_fp32 = num_params * 4 adam_v_fp32 = num_params * 4 optimizer_and_master = master_weights_fp32 + adam_m_fp32 + adam_v_fp32 return { "params": params_bf16, "grads": grads_bf16, "optimizer_and_master_weights": optimizer_and_master, "activations": activation_bytes, "total": params_bf16 + grads_bf16 + optimizer_and_master + activation_bytes, } def arithmetic_intensity(flops, bytes_moved): return flops / bytes_moved N = 124_000_000 # GPT-2-small-ish non-embedding param count, from Part 1 tokens = 300_000_000_000 flops = transformer_training_flops(N, tokens) mem = mixed_precision_training_memory_bytes(N) ai = arithmetic_intensity(flops, mem["total"]) print(f"Training FLOPs: {flops:.3e}") print(f"Memory (GB): { {k: round(v/1e9, 3) for k, v in mem.items()} }") print(f"Arithmetic intensity (illustrative): {ai:.3e}") # Executed output for this exact configuration: # Training FLOPs: 2.232e+20 # Memory (GB): {'params': 0.248, 'grads': 0.248, 'optimizer_and_master_weights': 1.488, 'activations': 0.0, 'total': 1.984} # Arithmetic intensity (illustrative): 1.125e+11
Two things worth noticing in that output. First, the optimizer-and-master-weights term (1.488 GB) is exactly 6ร the params term (0.248 GB) โ a direct consequence of the 12-bytes-vs-2-bytes ratio described above, and the single biggest reason "the model is only 250MB" does not mean training fits in 250MB of GPU memory. Second, the activation term is deliberately left at zero here: activation memory depends on batch size, sequence length, and how many intermediate tensors your implementation checkpoints versus recomputes, which is architecture- and implementation-specific in a way parameter count alone isn't โ extending this function with a real activation estimate for your own run_transformer_lm implementation is a natural next exercise once that function is built.
โ ๏ธ Confidence Notes and Gaps
tests/adapters.py contains no FLOPs/memory functions is also a direct fetch result (HIGH confidence), though it reflects an AI summarization of that one file's contents rather than a byte-for-byte manual read. The three paper citations (Kaplan et al. 2020, arXiv:2001.08361; Rajbhandari et al. 2019, arXiv:1910.02054; Williams, Waterman & Patterson, CACM 2009) were checked against their abstract pages for title, authors, identifier and the specific claim attributed to each (HIGH confidence). The memory breakdown for mixed-precision AdamW (2+2 bytes for bf16 params/grads, 12 bytes for fp32 master weights and two Adam moments) is standard engineering arithmetic rather than a figure from any single paper; real frameworks vary (some keep fp32 gradients, some drop the master copy). All numbers in the executed code block are real outputs from running the script, not invented.