Everything that happens between a prompt and a token
Fifteen mechanisms from the serving stack, each one a step-through walkthrough rather than a wall of text. Start anywhere on the map — every box opens its own page.
click any box to open it · flowing edges show which way work moves
Inside the model
One request, one GPU
Serving many requests
The loop that wraps the model
An agent is not one model call. Six pieces: the loop itself, one real instance of it, and the four problems the loop creates — context, state, reach, and what gets to enter the context.
click any box to open it · flowing edges show one turn of the loop
How an agent runs
What it carries
What enters the context
One CUDA kernel, 600 microseconds to 57
NVIDIA MLSys 2026 — sparse attention on a B200. Seven mechanisms, in the order the bottleneck moved: what the kernel is handed, the machine it lands on, and the four versions that each answered a different limit.
click any box to open it · the bar chart is measured latency, lower is better
The problem
Making it fast
Making it correct, then tuned
From one neuron to a trained network
The foundations, with real numbers: one 2-2-1 network carried from the perceptron through the forward pass, the activation functions, backprop, autodiff, mini-batch SGD and the optimizer — every value checkable by hand. Dashed boxes are the roadmap still to write.
click a solid box to open it · dashed boxes are still to do
From a neuron to a network
Learning from mistakes
Next up — the roadmap
What the next batches will cover, in the order they should be learned. Each becomes a step-through page like the five above.
Training well
- InitialisationXavier / He, and why the scale of random weights decides whether signals survive depth
- NormalisationBatchNorm, LayerNorm, RMSNorm — keeping activations at a fixed scale so gradients stay usable
- Regularisationdropout, weight decay, early stopping — why a model that fits the training set can still be wrong
- Schedules & batch sizewarm-up, cosine decay, the batch-size–learning-rate rule, gradient clipping
- Mixed precision & the loopbf16, loss scaling, gradient accumulation — the training loop as it is actually written
The transformer
- Tokenisation & embeddingsBPE, the embedding table, why the vocabulary is a design choice
- Positional encodingsinusoidal, learned, RoPE — how a set of vectors learns to be a sequence
- Attentionalready written up in the Infrastructure area — revisit it from the forward-pass side
- The transformer blockattention + MLP, residual connections, pre-norm — one layer, then N of them
- Pretraining objectivenext-token prediction, perplexity, scaling laws
- Fine-tuningSFT, LoRA, RLHF / DPO — what changes after pretraining
Classic ML, for interviews
- Linear & logistic regressionthe perceptron with a proper loss; closed form vs gradient descent
- Bias, variance, overfittingtrain/val/test, cross-validation, what the learning curves say
- Loss functionsMSE, cross-entropy, softmax with many classes, hinge, contrastive
- CNNs and RNNsweight sharing, receptive fields, why sequences moved to attention
Self-attention
how a word finds what it refers to
Attention is the operation that lets one token pull information from another. Everything else in a transformer is scaffolding around it — and every efficiency technique later in these notes is a response to what it costs.
The problem
A token's meaning depends on other tokens, but a neural network processes a fixed-size vector per position. Nothing in the word hungry tells you who is hungry.
What it buys you
It lets every token ask a question and pull the answer from wherever it lives in the sequence — learned, not rule-based. This is what resolves pronouns, agreement and long-range reference.
How it works
Each token emits a Query, a Key and a Value. The query is compared against every key to produce scores; softmax turns those into weights; the output is the weighted sum of values. Q and K decide routing; V is the payload.
Q and K decide who attends to whom; V is what gets carried. That split is why you can cache K and V, and why RoPE only touches Q and K.
Going deeper
A worked example — Resolving what a word refers to
“The cat that chased the mouse was hungry.” When the model processes hungry, that token emits a query meaning roughly who is this about? It scores highly against the key of cat and weakly against mouse, so the value it pulls in is mostly cat's.
Nothing told the model that cat is the subject. The match between one token's query and another's key is the entire mechanism — which is why attention is the part that resolves pronouns, agreement and long-range reference.
Why three projections instead of using the hidden state directly
If you compared raw hidden states, matching would collapse into plain similarity — a token could only attend to tokens that look like itself. Separate Q and K projections let the model learn an asymmetric relation: hungry can ask for a subject without resembling one.
Why divide by √d
Dot products of d-dimensional vectors grow with d. Without the scaling, scores get large, softmax saturates to nearly one-hot, and gradients through it vanish. The divisor keeps the logits in a range where softmax stays soft and trainable.
What the interviewer usually asks next
“Why is it quadratic?” Because every query meets every key — S² pairs, each a d-dimensional dot product. That single fact is the origin of FlashAttention, sparse attention, linear attention, and most of the long-context literature.
How it connects
What it costs
- Cost is quadratic in sequence lengthevery token is compared with every token, so doubling the context quadruples the attention work
- Memory toothe score matrix is S×S, which is what FlashAttention refuses to materialise
Full vs linear attention
keep every token, or keep a summary
Two ways to store the past. Full attention keeps every token individually; linear attention folds them into one fixed-size state. The difference is a compression-versus-recall trade.
The problem
Full attention pays for every token pair and stores every past key and value, so both cost and memory grow with the length of the sequence.
What it buys you
Linear attention makes per-step cost constant and memory flat, so sequence length stops being the thing that limits you.
How it works
Reorder the computation around a feature map so the past collapses into a recurrent state. Each new key and value updates the state; each query reads from it. No explicit token-by-token score matrix ever forms.
Full attention is lossless lookup at quadratic cost; linear attention is lossy lookup at constant cost.
Going deeper
A worked example — The question a fixed-size state cannot answer
“What was the variable named on line 200?” Full attention treats this as a lookup: there is a key for that token and the query can match it exactly, however far back it sits.
Linear attention has folded line 200 into a single fixed-size state along with everything else, so the name survives only as a blend. It will produce something plausible rather than something exact. Ask instead “what is this document about?” and the compressed summary answers perfectly well, at constant cost however long the document is.
Where the linear form comes from
Softmax attention cannot be reassociated because the softmax sits between Q·Kᵀ and V. Replace it with a feature map φ so scores become φ(q)·φ(k), and the computation reassociates: instead of (QKᵀ)V you compute Q(KᵀV). KᵀV is a fixed-size matrix independent of sequence length — that matrix is the recurrent state.
Why hybrids exist
Retrieval-shaped work (copying an exact token, following a reference) is what a compressed state does worst. Interleaving a few full-attention layers among many linear ones recovers that ability while keeping most of the cost saving — which is why most published “linear” models are not purely linear.
The honest comparison
Linear attention is not a strict improvement. It trades an exact, addressable memory for a lossy summary. If your workload is long-context retrieval, that trade is bad; if it is long-context summarisation, it is good.
How it connects
What it costs
- Precise recall is losta fixed-size state blurs specific tokens — what was the variable on line 200 is exactly what it is bad at
- Broad questions still worka summary answers what is this document about perfectly well, at constant cost
- Hence hybridsmostly linear layers with a few full-attention layers kept for the lookups that matter
FlashAttention
the matrix that never gets built
An IO-aware, exact attention implementation. It does not change the result and does not reduce the arithmetic — it removes memory traffic, which turns out to be where the time actually went.
The problem
Attention runs as several stages, and the huge S×S score and probability matrices get written to slow GPU memory between them — written, read, written, read. For a long sequence that matrix is larger than all the fast on-chip memory available.
What it buys you
Every one of those numbers is needed exactly once on its way to the output, so none of them ever has to exist in HBM. Removing those round trips is a large wall-clock win at zero accuracy cost.
How it works
Tile Q, K and V into blocks that fit in SRAM. Fuse score → softmax → weighted-sum into one kernel so each tile is consumed immediately. Use an online softmax that carries a running max and normaliser, so the result stays exact tile by tile.
GEMMs were already tiled — that was never the problem. FlashAttention tiles and fuses the whole attention operation, and online softmax is what makes that legal.
Going deeper
A worked example — The matrix that never needed to exist
For a long sequence, the score matrix of a single attention head can be far larger than all the fast on-chip memory available. The naive path writes that whole thing to slow memory, reads it back to apply softmax, writes the result out again, then reads it once more to multiply by V.
But every one of those numbers is needed exactly once, on its way to the output. FlashAttention loads a tile, folds it into a running total, and throws it away. Same arithmetic, same answer — the data simply never makes the round trip.
How online softmax stays exact
Softmax normally needs the whole row before it can divide by the sum. Instead, carry a running max m and running sum ℓ. When a new tile arrives with a larger max, rescale the accumulated output by exp(m_old − m_new) before adding the new contribution. The correction is exact, so the final result is bit-for-bit what the naive version produces.
Why recomputation can be the cheaper option
In the backward pass FlashAttention recomputes the attention tiles rather than storing them. That is more FLOPs but far less memory traffic — and since the kernel is bandwidth-bound, trading arithmetic for traffic is a win. It is the same reasoning as the forward pass, applied to gradients.
FA1 vs FA2 vs FA3
FlashAttention-1 introduced the tiled, fused, IO-aware formulation. FlashAttention-2 kept the idea and fixed how work is partitioned across warps and thread blocks, cutting non-matmul overhead. Later versions target newer hardware features. The idea has not changed since FA1 — the implementations got better at feeding the GPU.
How it connects
What it costs
- No accuracy cost at allit is exact, which is the whole point
- Still quadraticit removes IO, not arithmetic — saying otherwise fails the question
- Extra FLOPs are sometimes traded for less trafficrecomputing in the backward pass can be cheaper than storing
Prefill vs decode
one request, two different machines
A single request has two phases that stress completely different parts of the hardware. Almost every serving technique targets one or the other, so naming the phase is the first step in any answer.
The problem
Prefill reads the whole prompt at once and is limited by compute. Decode emits one token at a time and is limited by memory bandwidth. Treating them as one workload optimises neither.
What it buys you
Separating them tells you which lever to pull: prefix caching and chunked prefill attack prefill; GQA, KV quantisation and larger batches attack decode.
How it works
Prefill processes every prompt token in parallel and builds the KV cache — big matmuls, hardware well fed. Decode then produces one token per step, doing almost no arithmetic but re-reading the weights and the whole cache each time.
Prefill is throughput-shaped and compute-bound; decode is latency-shaped and bandwidth-bound. Name the phase before naming the fix.
Going deeper
A worked example — Summarising a long document
You paste a long report and ask for a short summary. Prefill reads the entire report in one pass — a big, efficient piece of work, and what the user experiences as the wait before anything appears.
Decode then emits the summary one token at a time. Each step does almost no arithmetic but must re-read the model weights and the whole cache, so the pace of text appearing is set by memory speed. Flip the shape — a short prompt with a long answer — and the cost moves almost entirely into decode.
Arithmetic intensity is the real distinction
Prefill multiplies a big matrix by a big matrix: many FLOPs per byte moved, so the GPU is well fed. Decode multiplies a matrix by a single vector: the same weights are read to do a fraction of the work. That ratio — FLOPs per byte — is what makes one compute-bound and the other bandwidth-bound.
Why batching helps decode but not prefill
Batching decode turns vector-matrix products back into matrix-matrix products, amortising the weight read across many requests — the whole reason continuous batching exists. Prefill is already compute-saturated, so batching it adds little and can hurt latency.
Chunked prefill, and why schedulers care
A long prefill run in one piece blocks every in-flight decode step. Splitting it into chunks interleaved with decode keeps token streams smooth at a small throughput cost — the same problem prefill–decode disaggregation solves by moving the phases apart entirely.
How it connects
What it costs
- Prefill sets time-to-first-tokenthe wait before anything appears
- Decode sets time-per-output-tokenthe pace the text arrives at
- The balance moves with workloada long document with a short summary is nearly all prefill; a short question with a long essay is nearly all decode
KV cache
what it saves, and what it costs
The optimisation that makes autoregressive generation practical — and the single biggest consumer of GPU memory in a serving system. Almost everything in the serving section exists to manage it.
The problem
Generation is autoregressive, so a naive implementation pushes the entire prefix through every layer again for each new token, rebuilding keys and values identical to last time.
What it buys you
The past does not change, so its keys and values can be computed once and reused. Work per decode step goes from growing with the sequence to flat.
How it works
Each step computes Q, K and V for the new token only, appends its K and V to the cache, and attends across everything stored. Old queries are never reused — which is precisely why it is a K/V cache and not a Q cache.
Only K and V are cached because old queries are never reused. The KV cache is why serving is a memory problem.
Going deeper
A worked example — Generating the five-hundredth token
Without a cache, producing token 500 means pushing the previous 499 tokens through every layer again, just to rebuild keys and values identical to last time. With a cache you compute one token's worth of work and read the rest.
The catch shows up with concurrency. One long conversation's cache is manageable; a hundred simultaneous long conversations can need more memory than the model weights themselves — and when the GPU fills, the server has to queue or evict, so the symptom users see is not slowness but rejected requests.
Reading the size formula
Memory ≈ 2 · L · S · Nkv · dhead · bytes. The 2 is K and V; L is layers; S is sequence length; Nkv is KV heads. Everything is linear, which is what makes the levers obvious: halve the KV heads (GQA) or halve the bytes (quantise to fp8/int8) and you halve the cache.
Why it is the capacity limit, not the compute limit
Weights are a fixed cost paid once. The KV cache is a per-request cost that grows with every token generated, so as concurrency rises it is the cache — not FLOPs — that fills the card first. This is why serving papers talk about memory almost exclusively.
What you can do when it does not fit
Four levers, in rough order of how often they are used: fewer KV heads (GQA/MQA), fewer bits per entry (quantisation), better allocation (PagedAttention), and less history to store at all (eviction, compaction, or a sliding window).
How it connects
What it costs
- Trades compute for memoryand memory is the scarcer resource in serving
- Grows with length × concurrencyone long conversation is fine; a hundred can exceed the model weights themselves
- When it fills, requests are rejectednot slowed — which is why capacity, not FLOPs, usually caps a deployment
MHA, GQA and MQA
shrinking what decode has to read
Three arrangements of the same attention mechanism, differing only in how many query heads share a set of keys and values. It is a serving decision that got made inside the architecture.
The problem
Decode is bandwidth-bound and the KV cache is the thing being read every step — so the size of that cache directly sets how fast tokens come out and how many users fit on a card.
What it buys you
Sharing KV heads across groups of query heads cuts the cache substantially while keeping enough query heads for the model to stay expressive.
How it works
MHA gives every query head its own KV head. MQA gives all of them one. GQA groups them — a handful of KV heads shared across groups. A model with 32 query heads and 8 KV groups holds a quarter of MHA's cache.
GQA sits at the knee of the curve — near-MHA quality at close to MQA memory.
Going deeper
A worked example — The same model, three cache sizes
Take a model with 32 query heads. Under MHA it stores 32 keys and values per token. Under MQA, one. Under GQA with 8 groups, eight — a quarter of MHA's cache.
That quarter is not an abstract saving: it is roughly four times as many concurrent users on the same GPU, or four times the context length for one user. MQA would save more still, but collapsing 32 heads onto a single shared key/value is where quality starts to show strain — which is why 8 is the sort of number real models pick.
What is actually lost
Query heads sharing a KV head can still weight it differently — their queries differ — but they can no longer attend to different things. Heads within a group are forced to look at the same set of keys, which removes some of the specialisation multi-head attention exists to provide.
It interacts with tensor parallelism
Under TP, heads are split across GPUs. With very few KV heads, either some GPUs hold no KV head at all or the KV must be replicated — so the KV-head count is not a free parameter; it is constrained by how you plan to shard.
Why it is decided at training time
Unlike batching or paging, this is architectural. You cannot convert an MHA checkpoint to GQA without retraining or at least a distillation-style conversion step, which is why it is chosen with serving economics already in mind.
How it connects
What it costs
- Fewer KV heads means less representational freedomMQA can measurably hurt quality; GQA mostly does not
- A quarter of the cache is roughly four times the concurrencyor four times the context for one user
- It is baked in at training timeunlike most serving choices, you cannot change it later
PagedAttention
why KV memory is stored in blocks
Virtual memory, applied to the KV cache. It changes nothing about how attention is computed and everything about how many requests fit on a GPU.
The problem
Requests have unpredictable lengths. Reserving a contiguous worst-case region for each one wastes most of it, and when requests finish they leave holes that no new request fits into — classic fragmentation.
What it buys you
Removing reservation waste and fragmentation, so nearly all of the memory does real work. It also makes prefix sharing cheap, which is what prefix caching is built on.
How it works
Store KV in fixed-size blocks with a per-request block table, so a request's cache no longer has to be contiguous. It claims another block only when it needs one, and two requests sharing a prompt can point at the same blocks.
PagedAttention is about where KV lives, not how attention is computed.
Going deeper
A worked example — You cannot know how long an answer will be
One request asks for a yes-or-no answer; the next asks for a long essay. Both arrive looking identical. Reserving the worst case for every request wastes most of the memory; reserving too little means a request dies halfway through.
Blocks dissolve the problem — each request claims another block only when it actually needs one. The bonus: two chat sessions that open with the same system prompt can point at the same blocks rather than each holding a copy.
The operating-system analogy, and where it stops
Block table ≈ page table; KV block ≈ page; the allocator ≈ the OS memory manager. The difference is that there is no swap — a GPU cannot page to disk cheaply — so when blocks run out the system must preempt a request and recompute its cache later, rather than transparently faulting it back in.
Why block size is a real tuning decision
Large blocks mean less bookkeeping but more waste in the final partly-filled block. Small blocks waste less but add indirection and kernel overhead. Typical values are a handful of tokens per block — small enough that internal fragmentation is negligible.
Copy-on-write makes branching cheap
Sharing blocks is what lets parallel samples from one prompt, or beam-search branches, share a prefix instead of duplicating it. When one branch diverges, only the block it writes to is copied.
How it connects
What it costs
- Needs a custom attention kernelone that can follow the indirection
- Bookkeeping overheadblock tables and an allocator to maintain
- Pure capacity winit makes nothing faster — it stops memory being wasted
Continuous batching
rescheduling every decode step
Iteration-level scheduling. The insight is not a faster kernel but a better moment to make the decision.
The problem
Generation lengths vary wildly. A fixed batch runs until its slowest member finishes, so a batch of eight where seven finish early leaves seven slots idle while one long request runs.
What it buys you
Keeping the GPU saturated on variable-length work. Throughput rises substantially without any individual request getting faster.
How it works
Re-decide batch membership at every decode iteration. Finished requests are evicted immediately and waiting ones admitted in their place, so a slot never sits empty behind someone else's long answer.
It is iteration-level scheduling rather than request-level. Nothing got faster — the hardware stopped waiting.
Going deeper
A worked example — One long request holding up seven short ones
A batch of eight requests starts together. Seven finish quickly; one is writing a long document and keeps going.
With a fixed batch, those seven slots sit empty until the long one finishes — the GPU doing a fraction of the work it could. With continuous batching, each slot is refilled from the queue the moment it frees up, so the long request stops being everyone else's problem.
What makes it possible at all
Decode steps are independent across requests: each one reads its own KV cache and produces one token. Nothing couples request A's step to request B's, so membership can change between iterations without breaking anything. Paged KV is what makes joining and leaving cheap in memory terms.
Admission control is the hard part
Adding a request needs KV capacity for its whole future, which you cannot know. Admit too eagerly and you run out mid-generation and must preempt someone; admit too cautiously and the GPU idles. Real schedulers reserve headroom and can preempt-and-recompute the newest request when they guess wrong.
What it does to your latency numbers
Throughput rises, but per-request latency becomes dependent on load. A p50 measured on an idle server tells you nothing about production. This is why serving benchmarks quote latency at a given throughput, not alone.
How it connects
What it costs
- Scheduler complexityadmission, eviction and fairness all become real problems
- Latency becomes contextuala request's speed now depends on what else is running
- Contrast with dynamic batchingwhich only picks when to launch, then runs to completion
Prefix caching
reuse across requests, not within one
Also called RadixAttention. The observation is simple: a token's KV depends only on the tokens before it, so an identical prefix always produces identical KV — no matter which request it belongs to.
The problem
Agents, chat threads and tool-heavy prompts resend nearly the same preamble every call. The same system prompt, tool definitions and document context get prefilled again and again.
What it buys you
Prefill cost and time-to-first-token collapse to the size of what is genuinely new, which for an agent on its twentieth step is a tiny fraction of the prompt.
How it works
Cached prefixes are indexed in a radix tree. A new request walks it to find the longest prefix already computed, reuses that KV, and prefills only the unmatched tail.
Cached tokens are still fully attended to. What is skipped is recomputing their K and V, not attending to them.
Going deeper
A worked example — A coding agent on its twentieth step
Every call this agent makes begins the same way: system prompt, tool definitions, repository context. Only the tail — the latest action and its result — is new. Without prefix caching, step 20 re-prefills that entire preamble from scratch, as it did on all nineteen previous steps.
The fragility is worth remembering: change one character near the start — inject a timestamp, reorder the tool list — and the shared prefix ends there, so the reuse mostly disappears. Keeping the volatile parts of a prompt at the end is a real engineering decision.
Why a radix tree rather than a hash map
A hash map can only answer “have I seen this exact prompt?” A radix tree over token sequences answers the question you actually have: “what is the longest prefix of this prompt that I have already computed?” Branches naturally share their common ancestors, so many conversations sharing one system prompt store it once.
It is not free memory-wise
Cached prefixes occupy the same KV memory that running requests need, so the cache needs an eviction policy — typically LRU with a bias toward keeping long, widely-shared prefixes, since those are the expensive ones to rebuild.
Where it changes system design
Because reuse depends on an exact match, prompt construction becomes a caching problem: stable instructions first, per-request content last, and no timestamps or randomised ordering near the top. It also gives the router a reason to send a request to a specific replica — cache affinity.
Compaction is its natural enemy
A prompt that only ever grows at the tail is a perfect cache workload — every request is a prefix of the next. Context compaction rewrites the middle, which invalidates everything after the edit. In one measured agent run the append-only baseline served 70% of prompt tokens from cache and the compacted run managed 57%. Both techniques are right; they just pull against each other, and raw token counts overstate what compaction actually saves.
How it connects
What it costs
- Only exact prefix matches helpchange one character near the start and the reuse ends there
- So put volatile content lasta timestamp at the top of a system prompt destroys the whole benefit
- Costs cache memory and an eviction policythe tree is not free
Service mesh
routing between services
The infrastructure layer that handles service-to-service communication. Worth knowing precisely because of where it stops.
The problem
Once a platform runs many services and many replicas, every one of them needs retries, timeouts, health checks, TLS and routing. Reimplementing that inside each application is duplicated, inconsistent, and impossible to change centrally.
What it buys you
Networking concerns move out of application code into infrastructure that can be configured in one place, so failover and routing policy change without touching any service.
How it works
A proxy — commonly Envoy — sits beside each service and handles its traffic: the data plane. A controller — commonly Istio — configures all those proxies centrally: the control plane.
A service mesh routes between services; the inference scheduler decides execution inside one replica.
Going deeper
A worked example — Where the mesh stops and the engine starts
A request arrives at the gateway and the mesh picks one of three healthy inference replicas. That is the last decision the mesh makes.
Inside that replica, an entirely separate scheduler decides which requests share the next decode iteration, which KV blocks to allocate, and when to admit a queued request. The mesh has no visibility into any of it — which is exactly why LLM-aware routing has to be added on top.
Data plane vs control plane
The data plane (Envoy sidecars) carries actual traffic and must be fast. The control plane (Istio) never touches a request — it distributes configuration. If the control plane dies, existing proxies keep routing with their last known config; that split is the point of the design.
Why LLM traffic strains the usual assumptions
Mesh defaults assume short, uniform, idempotent requests. LLM calls are long-lived and streamed, so a default timeout can kill a legitimate generation, and a retry can duplicate expensive work. Timeouts, retry policy and streaming support all need revisiting.
What it gives you that is genuinely hard otherwise
Uniform mTLS between services, consistent retry and circuit-breaking, and per-service traffic telemetry — without shipping that logic in every application in every language you use.
How it connects
What it costs
- Another hop and another moving partlatency and operational surface both grow
- It knows nothing about LLM costprompt length, KV pressure and cache locality are invisible to it
- Different granularity entirelythe mesh routes requests; the engine scheduler decides tokens
LLM-aware load balancing
why the busiest replica can be cheapest
Ordinary load balancing assumes requests cost about the same. For LLM serving that assumption is wrong in a way that flips the routing decision.
The problem
Two requests that look identical on arrival can differ by orders of magnitude in cost — prompt length, output length, prefill versus decode mix, KV pressure, and whether a replica already holds a reusable prefix.
What it buys you
Routing on expected serving cost rather than connection count, which can cut prefill work dramatically by sending a request where its cache already lives.
How it works
Score replicas on signals a web balancer never needed: queue depth, GPU and KV-cache pressure, prompt length, estimated output length, and above all prefix-cache affinity.
The least-busy server is not always the cheapest server. Route on estimated total serving cost.
Going deeper
A worked example — When “least connections” picks wrong
Replica A: five active requests, 70% utilised — but it already caches the 15K-token prefix this request shares. Replica B: two active requests, 40% utilised, no useful cache.
Least-connections sends it to B, which must prefill all 15K tokens. The cache-aware router sends it to A, reuses the prefix, and prefills only the new tail. A is busier and still cheaper. The catch worth volunteering: if every request with that prefix routes to A, A becomes a hotspot.
Why queue length is a poor proxy
Two requests in a queue could be 100 tokens or 100K. Without prompt length, expected output length and KV pressure, a count tells you almost nothing about how long the queue will take to drain.
Estimating output length
Nobody knows how long a generation will be, so routers approximate: request type, prompt shape, historical averages per endpoint, or an explicit max_tokens. Getting it wrong is survivable — the router only needs to rank replicas, not predict precisely.
The tension to name out loud
Cache affinity pulls traffic toward one replica; load balancing pushes it apart. Production routers blend them — affinity as a strong preference, overridden once a replica's pressure crosses a threshold.
How it connects
What it costs
- Needs live telemetry from every replicaand a cost model that can be wrong
- Affinity fights balancingrouting every shared-prefix request to one replica creates a hotspot
- So real routers blend the twoand fall back once a replica saturates
Prefill–decode disaggregation
stopping the two phases fighting
Take the prefill/decode distinction seriously enough to put them on different machines.
The problem
On a shared pool a long prefill occupies the GPU while it runs, so every other user who was mid-answer stops receiving tokens. Their stream visibly stutters.
What it buys you
Interference disappears, and each pool can be sized and tuned for its own bottleneck — compute on one side, memory bandwidth on the other — and scaled independently.
How it works
Prefill workers ingest the prompt and build the KV cache; that cache is transferred over the interconnect to a decode worker, which streams tokens out.
Disaggregation removes interference but pays a transfer; chunked prefill reduces interference and moves nothing.
Going deeper
A worked example — A 20K prompt and a 500-token answer
On a shared pool, that 20K-token prefill monopolises the GPU while it runs, and every other user's tokens pause.
Disaggregated: a prefill worker builds the KV cache, ships it to a decode worker, and that worker streams the 500 tokens while the prefill pool moves on to the next prompt. Nobody's stream stutters. The bill: a 20K-token KV cache is large, and it crossed the network to get there.
Do the transfer arithmetic before believing the design
The KV cache to move is roughly 2 · L · S · Nkv · dhead · bytes — for a long prompt this is gigabytes. On NVLink or InfiniBand that is milliseconds; over ordinary Ethernet it can exceed the prefill you were trying to protect. This is why the technique appears in large deployments and not small ones.
It lets the two pools differ in kind
Prefill wants raw compute; decode wants memory bandwidth and capacity. Once separated you can give them different GPU types, different batch policies, even different parallelism strategies — impossible when one pool does both.
Layer-wise streaming hides some of the cost
The transfer does not have to wait for the whole prefill to finish: each layer's KV can start moving as soon as it is computed, overlapping transfer with the remaining compute.
How it connects
What it costs
- The KV cache must cross the network on every requestpure overhead, and it is what decides whether this pays off
- Compare with chunked prefillsame pool, prefill sliced and interleaved — less interference, nothing moves
- So it is an interconnect questionfast links favour disaggregation; slow ones favour chunking
Tensor, pipeline and expert parallelism
three ways to cut a model
When a model does not fit on one GPU it has to be split. There are three places to cut, and each creates a different communication pattern — which is what actually decides the choice.
The problem
Weights and activations exceed a single GPU's memory, and every way of splitting introduces communication the single-GPU version never paid.
What it buys you
Models far larger than one device become trainable and servable, provided the communication pattern matches the hardware topology.
How it works
TP splits the matrices inside a layer — GPUs cooperate on one layer and exchange partial results constantly. PP gives each GPU a different block of layers — data crosses only at stage boundaries. EP puts different MoE experts on different GPUs and routes tokens to the ones they selected.
TP splits tensors, PP splits layers, EP splits experts — and the choice follows the hardware topology, not preference.
Going deeper
A worked example — Why TP stays inside the node
With TP=4, four GPUs split every layer's matrices and must combine partial results at each one — many times per forward pass. Put those four on NVLink inside one node and it works; stretch them across a slower network and communication dominates, making four GPUs slower than one would have been if the model fit.
With PP=4, GPU 0 holds layers 1–20, GPU 1 holds 21–40, and data crosses only at the three boundaries. That tolerates a slower link — which is why the common layout is TP within a node, PP across nodes.
Row-parallel and column-parallel, and why they pair
Splitting a weight matrix by columns lets each GPU compute an independent slice of the output with no communication. Splitting the next matrix by rows means each GPU consumes exactly the slice it already holds, and one AllReduce at the end combines the result. Transformer MLP and attention blocks are deliberately arranged as a column-parallel layer followed by a row-parallel one, so each block costs one collective rather than two.
What each strategy costs in memory
TP shards weights and activations, so it reduces per-GPU memory the most. PP shards weights by layer but each stage must hold activations for every in-flight micro-batch. EP shards only the expert weights — attention layers are still replicated or TP-sharded.
Why real systems combine all three
A large MoE model might run TP=8 inside a node, PP=4 across nodes, and EP across the whole cluster. The dimensions are orthogonal; the art is matching each one's communication pattern to the link that can carry it.
How it connects
What it costs
- TP communicates constantlyso it belongs on the fast links inside one node
- PP communicates rarely but bubblesstages wait on each other
- EP routing is data-dependenta popular expert becomes a straggler everyone waits for
Pipeline bubbles and micro-batching
keeping the stages full
Pipeline parallelism is sequential by construction, so its first version wastes most of the hardware. Micro-batching is the fix, and it has its own cost.
The problem
Stage 2 cannot start until stage 1 hands something over. With one batch in flight, exactly one stage is busy and the rest idle — the bubble.
What it buys you
Keeping several micro-batches in flight overlaps the stages, so after a short warm-up all of them are working on different micro-batches at once.
How it works
Split the batch into micro-batches fed in back to back. Gradients accumulate across them before a single optimizer step, so correctness is unchanged. Schedules like 1F1B interleave forward and backward passes to cut activation memory.
PP creates the pipeline; micro-batches keep it full. You want many more micro-batches than stages.
Going deeper
A worked example — Four stages, one batch versus four micro-batches
One batch of 64 through four stages: stage 1 works while 2, 3 and 4 wait; then stage 2 works while the rest wait. Three quarters of the hardware is idle at any moment.
Split it into four micro-batches of 16 and after a short warm-up all four stages are busy on different micro-batches at once. Same total work, most of the idle time gone — but each stage is now doing a smaller, less efficient chunk, which is the trade you accept.
The bubble fraction, roughly
With p stages and m micro-batches, the pipeline spends about (p−1) steps filling and (p−1) draining out of roughly (m + p − 1) total — so the wasted fraction is approximately (p−1)/(m+p−1). Push m well above p and it shrinks; it never reaches zero.
What 1F1B is actually for
The naive schedule runs all forwards then all backwards, so every micro-batch's activations are held simultaneously — peak memory scales with m. One-forward-one-backward starts backward passes as soon as possible, so activations are freed early and peak memory scales with p instead. It is a memory optimisation, not a speed one.
Interleaved stages trade communication for a smaller bubble
Giving each GPU several non-contiguous chunks of layers instead of one contiguous block shortens the fill and drain, at the cost of more boundary crossings — worth it when the interconnect can absorb them.
How it connects
What it costs
- Smaller chunks are less efficient per GPUsmaller matrices use the hardware worse
- More in-flight micro-batches means more stored activationswhich is what 1F1B claws back
- Warm-up and drain never disappearthe bubble shrinks toward zero but never reaches it
GPU collectives
four shapes of exchange
Split a model across GPUs and they must constantly exchange tensors. Collectives are how that exchange is expressed so the library can use the whole topology at once.
The problem
Done naively — point to point, one pair at a time — communication becomes the bottleneck and the extra GPUs stop paying for themselves.
What it buys you
Topology-aware implementations that saturate NVLink, NVSwitch or the network, turning communication from the limiting factor into a manageable cost.
How it works
AllReduce: combine, everyone gets the full result. AllGather: collect shards, everyone ends with the whole. ReduceScatter: combine, each keeps one slice. All-to-All: everyone sends different data to everyone. Note that AllReduce = ReduceScatter + AllGather, which is how ring implementations are built.
Match the collective to the parallelism: TP leans on AllReduce, EP leans on All-to-All.
Going deeper
A worked example — The four, concretely
Two GPUs holding [1,2] and [3,4]: AllReduce → both end with [4,6]. AllGather → both end with [1,2,3,4]. ReduceScatter → one keeps 4, the other keeps 6.
All-to-All is the odd one: each GPU sends a different chunk to each destination. In MoE, tokens scatter to whichever GPUs host their chosen experts, then a second All-to-All brings the results home — which is why MoE performance lives and dies on interconnect quality.
Why ring AllReduce is the standard implementation
A ring moves each shard around the GPUs in two passes — reduce-scatter then all-gather — so every GPU sends roughly 2(N−1)/N of the data regardless of how many GPUs there are. Bandwidth cost is near-constant in N; only latency grows. That is why AllReduce = ReduceScatter + AllGather is an implementation fact, not just an identity.
Latency-bound vs bandwidth-bound collectives
Small tensors are dominated by per-hop latency, so tree algorithms win. Large tensors are dominated by bandwidth, so ring algorithms win. NCCL picks per call based on size and topology — which is why a microbenchmark at one message size can badly mispredict real performance.
Overlap is where the real wins are
Communication that cannot be removed can often be hidden: launch the collective on a separate stream and keep computing while it runs. Gradient bucketing in data-parallel training exists precisely so that AllReduce for early layers overlaps with the backward pass of later ones.
How it connects
What it costs
- Collectives synchronisethe slowest GPU sets the pace and one straggler stalls everyone
- Scaling is bounded by interconnect, not FLOPsthis is the systems point to make
- Topology dictates placementwhich is why TP stays inside a node
The agent loop
what an agent actually is
An agent is not one model call. It is a loop — the model picks an action, something executes it, an observation comes back, and round it goes. Nearly every hard problem in agent systems comes from the loop rather than the model.
The problem
A single model call can only answer from what it was given. It cannot look something up, run a test, check whether its answer was right, or take a second attempt informed by the first.
What it buys you
Wrapping the model in a loop with tools turns prediction into something closer to work: the agent can act on the world, see what happened, and revise. It is also what makes the system expensive, stateful and risky.
How it works
Each turn: the model reads the context and emits an action; the orchestrator checks permissions and runs it in a sandbox; a tool executes; an observation returns; state updates. Then it repeats — often dozens of times — until a stopping rule fires.
An agent is a model in a loop with tools, state and a stopping rule. The model reasons, the orchestrator controls, the tools act, and the observations are what make it expensive.
Going deeper
A worked example — Where each layer's responsibility actually sits
Ask an agent to fix a failing test. The model emits run_tests("payments"). It does not run anything — it produces a decision.
The orchestrator decides whether that is allowed, runs it in a sandbox, and enforces a timeout. This layer is yours to build; no model provides it. The observation comes back as 400 lines of stack trace — larger than the entire conversation so far. That asymmetry is why agent context behaves nothing like chat context.
The loop is where the engineering lives
Tool schemas, permission policy, sandboxing, retries, timeouts, budget limits, and the stopping rule are all code you write around the model. Improving the model rarely fixes a badly designed loop, which is why agent quality differs so much between products using the same model.
Stopping rules, and how they fail
Common conditions: the goal is verifiably met, a step budget is exhausted, no progress across N steps, or a human must approve. Each fails differently — verification is often impossible, budgets truncate mid-task, and no-progress detection needs a definition of progress. Most production agents combine several.
Why observations dominate the budget
In chat, both sides produce sentences. In an agent, one side produces sentences and the other produces files, logs and pages. Trimming, summarising or referencing observations rather than inlining them is usually the highest-leverage change available.
Hidden reasoning can quietly eat the context
Some providers attach a full chain of thought to every response. Storing the whole response object in history resends all of it forever. One measured chess run carried 204,027 characters of reasoning against 7,585 characters of actual board observations — games took 31 minutes instead of 3.5. Reasoning is per-turn scratch work, not conversation: keep visible text and tool calls, drop the rest.
Parallel tool calls are safe on reads, unsafe on writes
A model can emit several calls in one response, all chosen from the same picture of the world. If they only read, that is a free speed-up. If any of them writes, every call after the first was chosen from a world that no longer exists. The chess harness executes the first move and rejects the rest with an explanation, because the opponent has already replied.
How it connects
What it costs
- Observations dominate the contexta single test log or file can outweigh many turns of dialogue
- The orchestrator is code you writepermissions, approval gates and sandboxing are not provided by the model
- Termination is a design decisionan agent without a clear stopping rule either quits early or burns budget rediscovering dead ends
Coding agent architecture
the verification loop
A coding agent is the clearest instance of the agent loop, because its environment can answer back. Tests turn a guess into a fact, which is what separates it from retrieval plus generation.
The problem
A real repository is orders of magnitude larger than any context window, so the agent cannot read it. And a plausible-looking patch is worth nothing — the only thing that counts is whether the change actually works without breaking anything else.
What it buys you
Retrieval brings in only the code that matters; the verification loop replaces confidence with evidence. The deliverable is a patch plus proof that it passes.
How it works
Search the repo — grep and symbol search for exact names, semantic search for intent, dependency paths for what calls what. Localize to the code on the real execution path. Hypothesise a root cause before editing. Make the smallest change that tests it. Run the tests. A failure is a new observation, so go back to localization with more information.
Search → localize → hypothesise → edit → test → replan. RAG retrieves and generates; a coding agent retrieves, acts, observes and repeats — the interactive verification loop is the whole difference.
Going deeper
A worked example — Why hypothesis-first beats edit-first
“The payments test fails intermittently.”
Edit-first: change three things that look suspicious, re-run, still fails. You now know less than when you started — which of the three was wrong?
Hypothesis-first: state the claim — “the retry path shares a connection pool across threads” — then make the single smallest change that tests it. Now the result carries information either way: it confirms the root cause, or rules it out cleanly and you move to the next hypothesis.
Localization is the step that decides the outcome
Empirically, most failures on repository benchmarks are localization failures, not editing failures: the model could have written the right patch if it had been looking at the right file. This is why retrieval quality and dependency-aware search matter more than raw code-generation ability.
Intermittent failures break the loop's core assumption
The loop assumes the test result is a signal. A flaky test makes it noise — a passing run may mean nothing. Handling that means running repeatedly, seeding randomness, or isolating the nondeterminism before trusting any result at all.
What the sandbox is really protecting
The agent runs shell commands it wrote itself against a repository it only partly understands. Scoped file access, no network by default, resource limits, and no credentials in the environment turn a destructive mistake into a failed step rather than an incident.
How SWE-bench actually grades
Each instance gives the repository before the fix, the issue text, and hidden tests. The grading is two-sided: FAIL_TO_PASS proves the bug is fixed, and PASS_TO_PASS proves nothing else broke. Only the pair is meaningful — the second is what catches an agent that made things green by deleting a test.
How it connects
What it costs
- Localization quality dominatesget the wrong files and every step after it is wasted
- Large speculative edits make failure uninterpretablewhen tests still fail you cannot tell which part was wrong
- Verification costs real timerunning the suite is slow, and running only the targeted test risks silent regressions
Agent context growth
why long loops get expensive
An agent is a loop of model calls, each appending to a history that every later call re-reads. The cost behaviour surprises people because two quantities get conflated.
The problem
The history grows linearly in steps, but since call t re-reads everything before it, the total input processed across the run grows quadratically in the number of steps.
What it buys you
Separating context size (what one call sees) from cumulative work (what the run paid for) tells you which fix applies — and they are not interchangeable.
How it works
Two different levers. Caching avoids recomputing history that is still present — cost falls, the window still fills. Compaction summarises or drops old history — the bars actually come down.
Caching fixes cost; compaction fixes window pressure. Diagnose which one you have before prescribing.
Going deeper
A worked example — A thirty-step debugging agent
The agent reads a file, runs the tests, reads the failure log, edits, re-runs. Each step appends its observations — and a single test log or source file can dwarf everything said so far.
By step 30 the model is re-reading the output of steps 1 through 29 to decide one more action, and most of it is long dead: a file it already fixed, a log it already diagnosed. The cost per step keeps climbing even though the useful state — what is broken and what has been tried — stays small.
Where the quadratic comes from
If each step appends d tokens, call t carries about d·t tokens, so the total across T calls is d(1+2+…+T) = d·T(T+1)/2 — quadratic in steps, even though the history itself only grows linearly. Conflating those two quantities is the classic mistake.
Caching and compaction fix different problems
Caching removes the cost of re-reading history that is still present; the window fills at exactly the same rate. Compaction removes the history itself. If you are hitting a cost ceiling, cache. If you are hitting the context limit, only compaction helps.
Designing against drift
Compaction discards silently, and you find out several steps later. Practical mitigations: keep a structured summary the agent maintains deliberately rather than a free-text one, keep artifact references rather than artifact contents, and keep the failed approaches — re-trying a ruled-out fix is the most common drift symptom.
What the ratio actually looks like
In one measured coding run the agent spent 463,000 prompt tokens against 13,000 completion tokens — roughly 35 to 1. The bill is for re-reading, not for thinking, which is why every lever in this section targets the prompt rather than the generation.
How it connects
What it costs
- Caching does not reduce the windowonly the cost of re-reading it
- Compaction risks driftyou discard something that turns out to matter, and find out several steps later
- Agent context is observation-dominatedone test log can outweigh many turns of dialogue
Agent state and checkpointing
surviving the interruption
Long agent runs hit crashes, restarts, truncated context and human-approval waits. What you persist decides whether any of those means starting over.
The problem
If the only record is conversation history, resuming means re-deriving where the agent was from a transcript — and if that transcript was truncated, the information is simply gone.
What it buys you
Structured execution state makes resume mechanical: load the checkpoint, see which steps are done, carry on.
How it works
Checkpoint the goal and plan, completed and pending steps, key observations, artifacts touched, pending actions and approval state, and environment references. Snapshot saves state directly; replay rebuilds it from an event log; real systems combine both.
Memory is knowledge worth keeping, a checkpoint is execution progress, conversation history is the raw trace.
Going deeper
A worked example — The payment that must not run twice
An agent charges a card, then crashes before recording the result. On resume it re-reads its plan: “step 7, charge the card — not marked complete.” With no durable action record it charges again.
With an idempotency key written before the call and reconciled after, the runtime can ask the payment provider whether that key already succeeded, and skip it. This is why a state machine matters: “we sent it and don't know the outcome” is a different state from “we never sent it”.
Snapshot, replay, or both
Pure replay rebuilds state by re-running an event log — exact, but slow and dangerous if events had side effects. Pure snapshots are fast but lose everything since the last one. Production systems take periodic snapshots and replay only the log since, which bounds both recovery time and loss.
Write the intent before the action
The durable record has to exist before the side effect, not after — otherwise the crash window between acting and recording is exactly the case you cannot recover from. Write “about to charge, key=abc”, then charge, then reconcile.
What belongs in a checkpoint and what does not
In: goal, plan, step status, pending action and approval state, artifact references, environment handles. Out: the full transcript, and anything reconstructible. A checkpoint that grows with the conversation has become another context problem.
Outcome metrics hide process failures
A run can reach a correct final state while a quarter of its steps produced no action at all. Grading only the outcome misses that entirely. Inspect trajectories for the behaviour you care about — steps with no tool call, repeated identical actions, retries after a permanent failure — and A/B the harness, not just the model, because the interface is as much of the system as the weights are.
How it connects
What it costs
- Costs writes and a schema to maintainand deciding what to persist is genuinely hard
- Too little and resume is impossible; too much and it becomes another context problem
- Irreversible actions are the hard parttrack action IDs and idempotency keys, or recovery sends the email twice
MCP
standardising how agents reach the world
The Model Context Protocol standardises the boundary between an agent runtime and external capabilities. It sits at a different layer from function calling, and that distinction is what gets tested.
The problem
Every agent that wants GitHub, a database or a file system writes its own integration, each with its own auth, schema and failure modes. N agents times M services means N×M integrations.
What it buys you
Wrapping each service once and teaching each agent the protocol once turns N×M into N+M, and makes servers substitutable between hosts.
How it works
A host contains an MCP client that speaks to an MCP server wrapping one external system. Servers expose Tools (actions), Resources (data) and Prompts (templates).
Function calling is the model deciding which tool and what arguments. MCP is how the runtime discovers and reaches it.
Going deeper
A worked example — The layers in one call
You ask an agent to file an issue. The model emits a function call: create_issue(repo, title, body) — that is function calling. The orchestrator checks permissions and approval, then routes it through the MCP client to a GitHub MCP server, which translates it into a real GitHub API call.
Swap GitHub for Linear and the model's half is unchanged — a different server handles it. That substitutability is the entire point. Note also that MCP bypasses nothing: permission and sandbox checks still belong to the orchestrator.
Tools, resources and prompts are not the same thing
A tool is an action with side effects the model may choose to call. A resource is addressable data the host can read into context without the model deciding anything. A prompt is a reusable template the server offers. Conflating tools and resources is the most common misreading of the protocol.
Discovery is what makes it a protocol
A host can ask a server what it offers at runtime and present those capabilities to the model, instead of having them hard-coded at build time. That is the difference between a standard and a convention — and it is what turns N×M integrations into N+M.
The security surface it creates
Every connected server widens what the agent can reach, and tool descriptions are text the model reads — so a malicious server can attempt to influence behaviour through them. Permission checks, approval gates and treating server-supplied text as untrusted data remain the host's responsibility.
How it connects
What it costs
- Another layer and another process to run
- A standard interface can hide capabilities a native API exposes
- It widens what an agent can reacha security surface, which is why permission checks stay with the orchestrator
RAG vs memory
two retrievals, two questions
Both put retrieved text into the working context and both may sit on the same vector store. What differs is the semantics of what is stored — and how each one fails.
The problem
The working context is small and the candidates are vast: external corpora on one side, everything this agent has already learned and decided on the other.
What it buys you
Knowing which one you need. RAG supplies knowledge the agent never had; memory supplies conclusions it already reached.
How it works
RAG asks what external knowledge is relevant to this task? — retrieving from docs, code or databases by vector search, BM25 or hybrid. Memory asks what from earlier should persist? — decisions, preferences, failed hypotheses.
RAG says what does the world know; memory says what did we learn. Same plumbing, different semantics.
Going deeper
A worked example — The same question, two retrievals
“Fix the flaky test in the payments module.”
RAG pulls the module's source, the test file, and the framework docs — external knowledge, equally available to anyone. Memory pulls “we tried bumping the timeout last week and it did not help” and “this user wants the root cause, not a retry” — things that exist only because of prior interaction.
Drop RAG and the agent lacks the code. Drop memory and it cheerfully re-tries the timeout fix it already ruled out.
Memory needs a write policy; RAG does not
Documents already exist — nobody decided they were worth writing for the agent's benefit. Memories must be chosen, which adds a whole problem RAG never has: what, when, and at what granularity to save, and when to overwrite something now false.
Why retrieval quality dominates both
The model can only use what enters the context. Chunk boundaries that split a function in half, or an embedding that matches on topic rather than intent, fail silently — the answer looks confident and is simply missing the relevant material. Hybrid retrieval (lexical + semantic) exists because each covers the other's blind spot.
Memory can compound its own errors
A wrong conclusion written once gets retrieved forever, and each retrieval makes it look better established. Practical defences: store what was observed rather than what was concluded, timestamp entries, and allow later evidence to supersede rather than accumulate.
Why production coding agents do not use a vector database for the transcript
Retrieval assumes the query resembles what you need. Much of what an agent must not forget fails that test: “I already tried X and it failed” has to be present regardless of what the next step is about, and nothing in that step’s text would retrieve it. Summaries also preserve order and causality, which retrieved fragments lose — and retrieval hands back the raw output you were trying to shed. Retrieval does belong in coding agents, but on the codebase, not the transcript.
How it connects
What it costs
- Both spend context on text that may not help
- RAG is only as good as its chunking and index
- Memory has a write problem RAG does notdocuments already exist; memories must be chosen — and a wrong one gets retrieved forever
The prompt is the state
four roles, one structural rule
A chat completion is stateless: text in, text out. Everything an agent knows on step 40 is in the message list you rebuild and resend on step 40 — so memory, persistence and continuity are all properties of that list, not of the model.
The problem
Because the whole transcript is resent every step, the context window becomes a hard ceiling on run length, and cost becomes the area under a rising curve — quadratic in steps rather than linear.
What it buys you
Getting the protocol right is what makes everything downstream possible: tool results that the model can act on, errors it can correct, and a history that compaction can safely rewrite.
How it works
Four roles with rules the API enforces. system carries standing instructions (exactly one, first), user poses the task, assistant carries the model's text and its tool calls, and tool carries one observation tagged with the tool_call_id it answers.
The prompt is the state, and the state is a budget. Nothing about an agent's memory lives in the model.
Going deeper
A worked example — Why a broken call still needs a reply
The model emits execute({"cmd": ) — malformed JSON. The tempting implementation raises, logs it, and moves on.
But the assistant message with that tool call is already in the history, and the API requires every call to be answered. The next request is now structurally invalid and fails before the model sees anything.
So the dispatcher returns a string: {"error": "Arguments were not valid JSON"}, carrying that call's id. The model reads it and fixes the call on the next step. Nothing raises.
The assistant message is a commitment
Once the model has emitted a tool call, the harness has no way to un-emit it. Every path out of the dispatcher — success, bad JSON, unknown tool, wrong argument type, non-zero exit — has to produce exactly one observation carrying that id. Treating it as a total function rather than one that can throw is the design.
Why cost is quadratic and not linear
Step t resends everything from steps 1..t−1. If each step adds d tokens, the sum across T steps is d·T(T+1)/2. Completion tokens grow linearly; prompt tokens grow as the square. That is why one measured run showed 463k prompt against 13k completion — a ratio of about 35 to 1.
Provider-specific fields need a deliberate decision
Some models attach hidden reasoning to every response. Storing the whole response object in history resends all of it forever. In one run that was 204,027 characters of reasoning against 7,585 characters of actual observations — the prompt was 96% scratch work. Keep visible text and tool calls; drop the rest.
How it connects
What it costs
- Every tool call must be answeredeven a malformed one — an unanswered call makes the next request invalid, which is why errors return as text rather than raising
- History cannot be cut at an arbitrary indexslice between an assistant message and its tool reply and the observation is orphaned
- Prompt tokens dominateone measured coding run spent 463k prompt tokens against 13k of completion — the bill is for re-reading, not thinking
Context compaction
the sawtooth, and its caching bill
Rewriting the old part of a transcript into a written summary so the prompt stops growing. The interesting part is not the mechanism — it is what the summary is instructed to preserve, and the hidden cost it creates.
The problem
A prompt that grows about 600 tokens a step and never comes down makes total cost quadratic, and eventually the context window ends the run outright.
What it buys you
Capping the prompt turns that area from quadratic into roughly linear. Measured on a real SWE-bench instance: half the tokens and a quarter of the peak prompt, with both runs still producing a patch that resolved the issue.
How it works
At a threshold, the harness makes a separate model call — its own system prompt, no tools — whose only job is to write working memory. That replaces the old prefix; system and task messages and the recent steps stay verbatim. The cut lands on an assistant boundary, and each new memory merges the previous one rather than stacking on it.
Compaction is lossy by construction, so what the summary is told to preserve is the design. And name the caching tension unprompted — it is the difference between reading the docs and paying the bill.
Going deeper
A worked example — What five compactions actually bought
Same model, same prompts, same 200-step budget on django__django-15368. One run compacting at a 6,000-token threshold, one with compaction disabled.
Compacted: 44 steps, 5 compactions, 221,187 tokens, peak prompt 5,974. Full context: 39 steps, 475,642 tokens, peak prompt 24,463. Both produced a patch that resolved the instance.
Half the tokens and a quarter of the peak — while taking five more steps and paying for five extra summariser calls. The baseline climbs about 600 tokens per step and never comes down, so its total is the area under a rising line.
Merge the memory, never stack it
Each compaction is handed the previous working memory along with the new prefix, and told to drop what later steps made obsolete. A memory that only appends recreates the exact problem it was built to solve — it just grows more slowly.
Render the prefix as text before summarising
The summariser reads a readable transcript, not raw JSON message objects. It is a one-off rendering cost that buys a markedly better summary, because the model is being asked to read a conversation rather than parse a data structure.
Check the summariser's finish_reason
Two of five summaries in the measured run came back with finish_reason length — they hit the 1,200-token output cap mid-sentence and the tail was lost. Nothing failed, nothing logged an error, and the run still succeeded. Either raise the cap or keep more recent steps verbatim, but first make the truncation visible.
How it connects
What it costs
- It fights prompt cachingan append-only prompt caches beautifully; rewriting near the front invalidates everything after it — cache hits fell from 70% to 57%, so the real saving is smaller than the token counts suggest
- It is lossy by constructionwhat the summary is told to keep is the entire design — drop the failed approaches and the agent retries what it already ruled out
- The summariser can be truncated silentlytwo of five summaries hit the output cap mid-sentence; the run still succeeded, which is what makes it dangerous
Error policy
recoverable, or terminal
Every failure an agent hits falls into one of two classes, and confusing them is expensive in both directions — too eager to recover gives an infinite loop, too eager to crash gives brittleness.
The problem
A harness that treats all failures as recoverable will retry a permanently broken environment forever. One did exactly that: 165 wasted steps and 17.1 million tokens after a sandbox hit its lifetime limit mid-run.
What it buys you
Separating the two classes turns that same failure into a single step, while still letting the model correct its own mistakes without human intervention.
How it works
Failures the model can fix — bad arguments, malformed JSON, an illegal move, a failing test — are formatted into the observation so it reads the error and corrects itself. Failures nothing can fix — the environment is gone — must terminate. On a transport error, probe whether the environment is alive before deciding which it is.
Graceful degradation without a termination condition is an expensive infinite loop. Every retry policy needs to separate what the model can fix from what nothing can fix.
Going deeper
A worked example — The 17-million-token retry loop
A chess game hit its 200-step limit having played only 22 moves. The other 165 steps produced nothing at all.
The sandbox had reached its 30-minute lifetime and been reclaimed mid-game. Every subsequent move returned a transport error — correctly formatted as a recoverable observation — and the model dutifully tried again, 165 times, at roughly 100k tokens of prompt each time.
The error policy had no notion of a failure the model cannot possibly fix. An illegal move is the model's problem; a dead environment is not.
Probe, then abort — and keep a backstop
On a transport failure, ask the environment whether it is still alive and abort with a clear message if it is not. Add a five-consecutive-failure backstop for the case the probe itself succeeds over a dead tunnel. Illegal moves never increment the counter, and any success resets it.
The two failure modes are symmetric
Too eager to recover gives an infinite loop billed by the token. Too eager to crash gives brittleness — a single flaky network call kills a run that would have succeeded on retry. Both are error-policy bugs; only the first one is expensive enough to notice immediately.
This is where checkpointing earns its keep
A deliberate abort is only cheap if the run can resume. Terminating on a dead sandbox and then starting the whole task over is not much better than retrying. The termination condition and the checkpoint are two halves of the same design.
How it connects
What it costs
- A health probe can itself lieso add a consecutive-failure backstop — five in a row aborts even if the probe says fine
- Recoverable failures must not count toward itan illegal move is not evidence the world is broken, and any success resets the counter
- Cost per retry is what makes it urgentat 100k tokens a retry, a policy gap stops being untidy and becomes a bill
Three levers on the context
truncation, compaction, disclosure
Three different things flood a context window, and each needs its own lever. Reaching for only one is why “just summarise the history” is an incomplete answer to how you run an agent for hours.
The problem
A single observation can swamp the prompt on its own; well-behaved observations still accumulate across forty steps; and the description of what the agent can do costs context in every request before any work happens.
What it buys you
Bounding all three keeps a long run inside the window and keeps capability count independent of prompt size.
How it works
Truncation caps one result — keeping the head and the tail, because the end of a stack trace is the part that identifies the failure. Compaction bounds their accumulation. Progressive disclosure advertises each capability as a name plus one line and loads the full instructions only when the model asks — a two-line catalogue entry against a 4,771-character body.
Truncation bounds one observation, compaction bounds their accumulation, progressive disclosure bounds what you carry before work starts.
Going deeper
A worked example — A two-line catalogue against a 4,771-character body
A skill is a folder with a SKILL.md whose frontmatter gives a name and a description. Loading it splits the file in two.
The catalogue entry — two lines — goes into the system prompt on every request. The full body, 4,771 characters of strategy, is revealed only when the model calls invoke_skill.
Ten skills therefore cost ten lines of prompt, not ten documents. And with no skills loaded, the agent is never told about the submission protocol at all — which is exactly what the tests check.
Head-and-tail, not head
Truncating a 120,000-character result to its first 10,000 throws away the end of the stack trace — usually the line that names the failure. Keeping the first and last 4,900 with an elision notice between them costs the same tokens and keeps the part that identifies the problem.
The same reasoning applies to tool descriptions
Progressive disclosure is usually discussed for skills or documents, but a tool schema with a long description is the same cost in the same place. Anything carried in every request to describe a capability the agent might not use is a candidate for the split.
Which lever to reach for, diagnostically
If a single step blew up the prompt, you need truncation. If the prompt crept up over forty steps, you need compaction. If the prompt was already large before the agent did anything, you need progressive disclosure. The symptom tells you which.
How it connects
What it costs
- Truncation can cut the part that matteredhead-and-tail is a heuristic, not a guarantee
- Progressive disclosure costs a round tripthe model must ask before it can act on the full instructions
- They are complementary, not alternativesnaming all three is what separates a real answer from “summarise the history”
Programmatic tool calling
code that composes the tools
Instead of emitting one tool call per turn, the model writes a snippet that calls the tools as ordinary functions, and the harness runs it somewhere isolated. The unit of work stops being a round trip.
The problem
One call per response caps how much a single step can do — which caps how deep a search or how long a procedure the agent can execute at all. A two-ply search over twenty candidates is 21 round trips, each resending the whole transcript.
What it buys you
Collapsing those 21 round trips into one step. Loops, branching and search stop costing a model call each, so procedures too long to spell out as individual calls become routine.
How it works
The tools already exist as functions inside the sandbox. The model sends code — in the measured run, base64-encoded Python — that calls them in a loop, evaluates the results, and commits only the final choice as its last statement. The tools ran 21 times; the model was called once.
It changes the unit of work from a call to a procedure — the difference between an agent that takes twenty actions and one that runs an algorithm.
Going deeper
A worked example — Twenty-one round trips, or one step
A two-ply search over twenty candidate moves needs twenty simulate_move calls and one play_move. As individual tool calls that is 21 round trips, each a full model call resending the entire transcript.
As a snippet it is one step: the code loops over the candidates, calls simulate_move inside the sandbox where it is just a function, keeps the best line, and calls play_move exactly once as its last statement.
The tools still ran 21 times. The model was called once.
It raises the ceiling, not just the speed
The saving in round trips is the obvious part. The less obvious part is that procedures too long to spell out as individual calls become possible at all — a deeper search, a retry loop, a multi-stage transformation. The unit of work stops being the thing that limits the algorithm.
Re-read the world afterwards
A snippet may have changed live state several times before returning. The harness cannot assume its cached picture is still accurate, so after every snippet it re-reads the environment. Skipping this is exactly the stale-state bug, just at procedure scale.
The debuggability cost is real
A sequence of individual tool calls leaves a readable trail: you can see which call failed and what it returned. A failed snippet gives you one opaque observation. That is a genuine argument for keeping ordinary tool calls as the default and reaching for code only when the work is loop-shaped.
How it connects
What it costs
- You are executing model-written codeit belongs in the sandbox beside the tools, never in the agent process
- The world may have movedafter a snippet runs, the harness must re-read state — the snippet may already have changed it
- Harder to inspecta failed procedure gives you one opaque observation instead of a readable trail of calls
Sparse attention, as a kernel
what you are handed, and why it is hard
DeepSeek Sparse Attention picks the 2,048 most relevant historical positions with a lightweight indexer, then attends only to those. The indexer is someone else's problem. What lands on the kernel is an arbitrary list of indices — and that is where the difficulty starts.
The problem
Cutting 100,000 tokens down to 2,048 removes almost all the arithmetic, but the 2,048 survivors are scattered through the KV cache. The kernel needs KV[17], KV[831], KV[9201], KV[45002] — not a slice. Every gather can land on a different HBM address, so nothing streams.
What it buys you
Naming this correctly is the whole setup: sparse attention trades a compute problem for a memory-access problem. Once you have said that, every optimisation that follows has an obvious motive.
How it works
The kernel receives Q for T query tokens plus an index list per query. For each tile of indices it gathers the corresponding K and V into shared memory, runs QK on Tensor Cores, updates an online softmax, accumulates PV, and moves on. Gather does not make the source contiguous — it pulls the currently needed subset on chip, and pays the scatter again next tile.
Sparse attention does not make attention easy — it converts an arithmetic problem into an irregular-memory problem. Everything after that is a response to the scatter.
Going deeper
A worked example — What K = 2,048 does and does not mean
A common misreading: K = 2048 does not mean the context is 2,048 tokens long. It means each query receives 2,048 selected historical KV positions out of however many exist.
The context can be 100,000 tokens. The Lightning Indexer scores all of them and keeps 2,048. Our kernel then does attention over those — and the indices it gets look like [17, 831, 9201, 45002, 103, …], not KV[0:2048].
That difference — a set of indices rather than a slice — is the entire difficulty of the kernel.
Is a “query” the user's question?
No, and the confusion matters. A query here is an attention query vector for one token: the model projects a token's hidden state into a query and uses it to decide which past information should flow in. So T = 8 means the kernel is processing eight query tokens — not eight user requests. That is why the benchmark's tiny T is a hardware problem rather than a product decision.
Did the kernel choose the 2,048 tokens?
No. The kernel assumes the sparse indices already exist; selection happens upstream in the indexer. Saying this clearly scopes the work: everything we did is about performing attention over an arbitrary scattered index list efficiently, not about deciding which tokens are relevant.
Does gathering make the entries contiguous?
Only on chip, and only for the current tile. The source stays scattered in HBM forever — gather pulls the currently needed subset into a small shared-memory tile where the layout is regular enough for Tensor Cores, and then pays the scatter again for the next tile. Which is why hiding the gather (cp.async) and having more warps to hide it behind (Split-K) are both worth doing.
QK, weights, PV — the vocabulary
QKᵀ gives attention scores; softmax(QKᵀ) gives attention weights; softmax(QKᵀ)V is the attention output. The mental model worth carrying: QK decides where to look, PV collects the information.
How it connects
What it costs
- Fewer FLOPs, worse access patterndense attention streams; sparse attention random-accesses, and bandwidth utilisation collapses
- Correctness is boundedthe competition required matching the BF16 reference within abs_err 0.1, which rules out the cheap low-precision shortcuts
- The indexer is a separate costthe kernel is only fast in the context of a pipeline that already paid to choose the indices
The B200, and the one rule
148 SMs, and a block that cannot move
You cannot reason about this kernel without a hardware model. The one that matters here is small: a GPU is 148 independent SMs, a block is assigned to exactly one of them, and shared memory belongs to the SM.
The problem
Performance intuition usually stops at “the GPU is parallel”. That is not enough to explain why a perfectly good kernel ran at 5% utilisation — for that you need to know what the scheduler can and cannot do with the blocks you launch.
What it buys you
One fact does most of the work: a CUDA block is assigned to one SM and stays there. Its threads share one shared-memory allocation and one __syncthreads() domain, so the hardware cannot lend half a block to an idle neighbour. Launch 8 blocks on 148 SMs and 140 SMs idle — permanently.
How it works
HBM and L2 are GPU-wide. Each SM has warp schedulers, CUDA cores, Tensor Cores, a register file, and up to 228 KB of shared memory carved into a private region per resident block. A warp is 32 threads scheduled together; 128 threads is 4 warps, 256 is 8, 512 is 16. More resident warps means more chances that something is ready to issue while another warp waits on memory.
A block never leaves its SM. That single sentence explains why the grid shape is a design decision and not an implementation detail.
The memory hierarchy, in one picture
Read it as ownership: a thread owns its registers, a block owns its slice of shared memory, and the whole grid shares L2 and global memory. Each step down is slower, larger, and visible to more threads — which is why fast kernels stage data from global memory into shared memory once and reuse it from there. Block 0 cannot see block 1’s shared memory: ordinarily blocks only meet in global memory (thread block clusters are the exception — see “Going deeper”).
| Memory | Who can see it | Lives as long as | On a B200 | Rough cost |
|---|---|---|---|---|
| Registers | one thread | the thread | 64K × 32-bit per SM (256 KB), at most 255 per thread | fastest — read directly as instruction operands |
| Local memory | one thread | the thread | register spills and large per-thread arrays, physically in global memory | as slow as global (cached in L1/L2) — “local” means private, not close |
| Shared memory | every thread in one block | the block | 228 KB per SM, up to 227 KB for one block; shares a 256 KB pool with L1 | on-chip, tens of cycles |
| L2 cache | every SM on the GPU | managed by hardware | 126 MB | a few hundred cycles (~150 ns measured) |
| Global memory (HBM3e) | every thread of every block, and the host via copies | until freed — survives across kernel launches | up to 180 GB, ~8 TB/s | the slowest — what every optimisation tries to touch less |
Sizes from NVIDIA’s Blackwell Tuning Guide (compute capability 10.0) and the DGX B200 datasheet; L2 latency from Chips and Cheese’s B200 measurements. Cycle counts are orders of magnitude, not specs.
Going deeper
A worked example — Eight blocks on a hundred and forty-eight processors
The benchmark launches with T = 8 query tokens and one block per token. The scheduler places those eight blocks on eight SMs. There are 148.
Nothing is wrong with the code. The GPU is not confused. It simply has no mechanism to help — a block cannot be divided and migrated, so the remaining 140 SMs have no work to take.
8 / 148 ≈ 5%. Every microsecond of optimisation inside the block is being applied to 5% of the machine.
Why a block cannot span SMs
The threads in a block need a single shared-memory allocation and a single __syncthreads() barrier domain. Both are SM-local resources. Splitting a block across two SMs would mean shared memory that spans two physical memories and a barrier across two schedulers — which is precisely the cost the block abstraction exists to avoid. The indivisibility is the price of shared memory being fast.
Shared memory is per-SM, not per-GPU
Each SM has its own shared-memory resource (up to 228 KB on B200), and CUDA carves a private region of it for every resident block. Ordinarily blocks never see each other's (clusters, below, are the one exception). The practical consequence shows up everywhere in this project: more shared memory per block means fewer blocks resident per SM, which means fewer warps available to hide a stall.
Why more warps hide memory latency
A warp that issues a scattered load and misses cache stalls for hundreds of cycles. If another warp is resident and ready, the scheduler issues from that one instead and the execution units stay busy. With regular, coalesced access this matters less; with a gather from arbitrary HBM addresses it matters a lot — which is why the final split kernel doubled its warp count.
The exception: thread block clusters
Since Hopper, blocks can be grouped into a cluster that the hardware guarantees to schedule together on neighbouring SMs. Blocks in a cluster can read and write each other's shared memory directly — distributed shared memory — instead of round-tripping through global memory. On B200 the portable cluster size is 8 blocks, with 16 available as a non-portable option. It refines the picture rather than overturning it: each block still lives on exactly one SM; the cluster just lets neighbours reach across.
Local memory is not local
When a thread needs more registers than it is allowed (at most 255), or indexes a per-thread array dynamically, the compiler spills to “local memory” — which is private to the thread but physically lives in global memory, cached in L1/L2. A spill-heavy kernel quietly turns its fastest storage into its slowest. nvcc -Xptxas -v reports spill bytes; it is worth checking whenever a kernel is mysteriously slow.
Occupancy is a means, not a metric
It is tempting to treat high occupancy as the goal. It is not — it is one way to buy latency hiding. A kernel that is genuinely compute-bound gains nothing from more resident warps, and a kernel that spends shared memory on deeper buffering may be better off with fewer. The question is always which resource is actually idle.
How it connects
What it costs
- Shared memory buys occupancy or buys buffering, not bothmore per block means fewer resident blocks per SM
- Occupancy is not the goalit is a means to latency hiding; a kernel that is compute-bound gains nothing from more warps
- The rule is a constraint, not a bugindivisible blocks are what make shared memory and __syncthreads() cheap in the first place
WMMA and Tensor Cores
v1 · make the arithmetic fast
The first version moved QK and PV off the scalar CUDA cores and onto the Tensor Cores through WMMA. It was a real speedup, and it did not touch the actual bottleneck — which is the more interesting half of the story.
The problem
Attention is two matrix multiplies with a softmax between them. Written as scalar fused multiply-adds, the hardware spends its issue slots and register bandwidth on bookkeeping rather than on arithmetic.
What it buys you
Tensor Cores are fixed-function hardware for exactly one shape of work: a small matrix multiply-accumulate. One instruction does a whole tile, so the arithmetic stops being the thing you are paying for.
How it works
WMMA — Warp Matrix Multiply Accumulate — lets one warp cooperatively issue an MMA. Here: m16n16k16 tiles in BF16, used for both QK and PV, with FP32 accumulation so the running sums stay stable. Low precision in, higher precision in the accumulator, which is the standard shape.
Tensor Cores made the arithmetic fast. That mattered, but the kernel was never arithmetic-bound — which is exactly why v1 was still 600 microseconds.
Going deeper
A worked example — One instruction instead of a loop
A 16×16×16 matrix multiply-accumulate written with scalar arithmetic is thousands of fused multiply-adds, each costing an issue slot and register-file traffic.
WMMA m16n16k16 BF16 hands that whole tile to dedicated hardware: one cooperative instruction per warp, operands in BF16, accumulation in FP32.
We used it for both matmuls in attention — QK and PV — which is what made the arithmetic stop being the expensive part. And then the kernel was still 600 microseconds, because the arithmetic was never the expensive part.
Why BF16 in, FP32 out
Fewer bits per number means less data movement and much higher Tensor Core throughput, but accumulating thousands of products in low precision loses bits fast. The standard shape — low-precision multiply, higher-precision accumulate — keeps the throughput while holding the running sum stable. It is the same reason FlashAttention accumulates its softmax state in FP32.
The correctness bound is what limits precision tricks
The competition required matching the BF16 reference within abs_err under 0.1. That is a hard wall: it rules out FP8 shortcuts and approximate softmaxes. Worth mentioning unprompted — it turns “we used BF16” from a buzzword into a decision made inside a constraint.
WMMA is not FlashAttention
They solve different bottlenecks and get conflated constantly. WMMA maps matrix math onto Tensor Cores. FlashAttention-style tiling stops intermediate score and probability matrices from being written to HBM at all. You can have either without the other; this kernel has both, for different reasons.
Why fixing the wrong bottleneck first was still right
It is easy to call v1 wasted effort. It was not — until the arithmetic was fast, you could not see that memory was the limit, and the measurement that showed 600 µs after a large compute speedup is exactly the evidence that pointed at the gather. Optimisation is mostly a sequence of measurements that relocate the bottleneck.
How it connects
What it costs
- It fixed the wrong bottleneck firstv1 still ran ~600 µs, because the scattered gather was untouched
- Precision is a budget, not a free levermatching the BF16 reference within abs_err 0.1 is what ruled out more aggressive quantisation
- WMMA is not FlashAttentionone maps math onto hardware, the other removes memory round-trips — conflating them is a common interview tell
cp.async and double buffering
v2 · stop waiting for the gather
With the arithmetic fast, the kernel spent its time waiting for scattered KV entries to arrive. Double buffering does not move fewer bytes — it stops the Tensor Cores from standing still while the bytes move.
The problem
One shared-memory buffer forces a strict alternation: load a tile, compute on it, load the next, compute on that. The DMA path and the Tensor Cores take turns, and each is idle while the other works.
What it buys you
They are different hardware, so they can run at the same time. Two buffers let the gather for tile B be issued before tile A has been consumed, and the stage cost drops from the sum of the two to roughly the larger of the two.
How it works
cp.async issues an asynchronous global→shared copy that does not block the issuing warp. With buffer A and buffer B, the loop computes on one while filling the other, then swaps. Serially, T = T_load + T_compute; pipelined, T ≈ max(T_load, T_compute).
Double buffering hides latency; it does not remove traffic. The 130 microseconds it bought are exactly the amount of load that happened to fit behind the compute.
Going deeper
A worked example — Ten units becomes five
With one buffer, the loop alternates: load A, compute A, load B, compute B. Four tiles at one unit each costs eight units, and one of the two pieces of hardware is idle the whole time.
With two buffers, the load for tile B is issued while tile A is being consumed. The loads occupy one track, the computes occupy another, and the total is the length of the longer track plus one stage of fill — five units instead of eight.
Same bytes. Same FLOPs. The difference is that the DMA engine and the Tensor Cores are different hardware, and they were taking turns for no reason.
Why the win was 130 microseconds and not 300
In steady state the stage cost is max(T_load, T_compute), not the average. If the gather costs 10 and the compute costs 2, you hide 2 and the other 8 are still on the critical path. The measured 600 → 470 µs says the gather was much larger than the compute — which is the diagnostic that pointed at the next optimisation.
Why not three or four buffers
Deeper pipelining is possible, but each stage costs shared memory per block, and shared memory caps how many blocks stay resident per SM. Past a point you are spending occupancy — which is itself a latency-hiding mechanism — to buy more overlap. The two levers compete for the same budget.
cp.async is asynchronous, which is the whole trick
An ordinary global load occupies the issuing warp until it returns. cp.async issues a global→shared copy that the warp does not wait on, so the warp can continue issuing MMA work and synchronise on the copy later. Without the async form, two buffers would buy you nothing — the warp would still be blocked.
When pipelining is the wrong lever entirely
If the kernel is saturating memory bandwidth, overlap does not help — there is no idle transfer capacity to fill, and the answer is to move fewer bytes. Here the problem was latency on scattered accesses rather than raw bandwidth, which is why hiding it worked at all.
How it connects
What it costs
- Bounded by the larger sideif the load is 10 and the compute is 2, you hide 2 — 600 µs to 470 µs, not 600 to 300
- More buffers cost occupancyeach extra stage is more shared memory per block, so fewer blocks stay resident — you buy latency hiding with the occupancy that was doing the hiding
- It does not reduce traffic at allif you are actually bandwidth-limited, pipelining is the wrong lever
Split-K
v3 · the change that actually mattered
The benchmark ran eight query tokens on a 148-SM GPU. One block per query means eight blocks, which means eight SMs. No amount of making a block faster fixes a grid that small — you have to change the decomposition.
The problem
With T = 8 and one block per query token, the launch produced 8 blocks. A block cannot span SMs, so about 5% of the GPU had work and the other 140 SMs sat idle for the whole kernel.
What it buys you
Splitting each query's 2,048 entries across S blocks turns 8 blocks into T × S blocks. At S = 32 that is 256 blocks — enough independent work that every SM has something to run, and a queue behind it. This was the single largest jump in the project: 470 µs → 95 µs.
How it works
Each query's index list is cut into S chunks of 2048 / S entries. Each block processes one chunk and writes a partial online-softmax state — (M, LSE, O) — to scratch. A second reduce kernel merges the S partials per query into the final output.
Split-K is a change of parallel decomposition, not a memory trick. The question it answers is ‘how do I get enough independent blocks to fill 148 SMs’, and the answer costs a reduction.
Going deeper
A worked example — Cutting the index list, not the matrix
Before: one block per query token owns all 2,048 selected KV entries and tiles through them. T = 8 gives 8 blocks.
After: each query's 2,048 entries are cut into S = 32 chunks of 64. Each chunk gets its own block. T × S = 8 × 32 = 256 blocks.
Every block does less work, more blocks exist, and the grid finally exceeds the SM count. 470 µs → 95 µs — the largest single step in the project, and it came from changing the shape of the launch rather than the contents of the kernel.
The misreading to avoid: it is not about fitting
Split-K is often explained as “the working set doesn't fit in shared memory, so split it”. That is not what is happening here. A single block could already stream all 2,048 entries with tiling and double buffering — nothing overflowed. The purpose is to expose more independent block-level parallelism. Getting this right is the difference between reciting the name and understanding the decision.
It adds work and still wins
Split-K creates S partial states per query, writes them to global scratch, and requires a second kernel to read them back and merge. Total work goes up. Wall-clock latency goes down, because far more of the machine is computing simultaneously. Latency and throughput are different quantities and this is the cleanest example of them diverging.
Which is why S has an optimum
More splits means more parallelism and more reduction overhead. At small S the GPU is underfilled; at large S the reduce kernel dominates and each block's tile is too small to amortise its own setup. Somewhere in between is the minimum, and where it sits depends on the workload — hence v4.
The same instinct appears at cluster scale
“The work does not divide into enough independent pieces to fill the hardware” is the same complaint as a pipeline bubble or an unbalanced tensor-parallel shard. The fix is always to re-cut the problem along a different axis and pay a combination cost. Here the axis is the KV index list and the cost is an online-softmax reduction.
How it connects
- The B200, and the one rulethe indivisible-block rule that makes this necessary
- Merging the splitswhat makes splitting mathematically legal
- Autotuning the split factorchoosing S, which turns out not to be a formula
- Tensor, pipeline and expert parallelismthe same instinct at cluster scale — re-cut the work to fit the machine
What it costs
- It adds work rather than removing itS partial states per query, scratch traffic to write them, and a whole second kernel to read them
- Wall-clock still fallsbecause far more of the machine computes at once — latency and total work are different quantities
- So S must be tuned, not maximisedpast some point the reduction overhead eats the parallelism gain
- It is not about fitting in shared memoryone block could already tile through 2,048 entries — saying otherwise is the most common misreading of Split-K
Merging the splits
the state that makes Split-K legal
Once 32 blocks each see a slice of the same query's scores, something has to reassemble one softmax out of them. You cannot normalise the slices independently — and the fix is the same running state FlashAttention uses.
The problem
Softmax is not additive over a partition. Chunk A with scores [10, 9] and chunk B with scores [1, 0] both normalise to roughly [0.73, 0.27], even though globally B should contribute almost nothing. Local normalisation destroys exactly the information that says so.
What it buys you
Keeping sufficient statistics instead of a normalised answer lets independently-computed chunks be merged into the exact global result — not an approximation.
How it works
Each block emits mᵢ = max(scores), lᵢ = Σ exp(s − mᵢ) and oᵢ = Σ exp(s − mᵢ)·V. The reduce kernel takes m = max mᵢ, rescales every partial onto it with exp(mᵢ − m), sums, and divides: O = o / l. Exact, and order-independent.
Online softmax is what makes it legal to look at attention scores in pieces — whether the pieces are tiles inside one block, or blocks spread across the GPU.
Going deeper
A worked example — Why you cannot average two softmaxes
Chunk A has scores [10, 9]. Chunk B has scores [1, 0]. Normalise each on its own and both give roughly [0.73, 0.27].
But globally, a score of 1 next to a score of 10 should contribute essentially nothing. Adding the local softmaxes gives chunk B the same total weight as chunk A — a completely different answer from the true attention.
The information that would have said so — how big were your scores compared to everyone else's — was thrown away by the local normalisation. So you keep it instead: the maximum, the un-normalised sum, and the un-normalised output.
The three numbers, and why they are sufficient
mᵢ is the chunk's maximum score, lᵢ = Σ exp(s − mᵢ) is its un-normalised denominator, and oᵢ = Σ exp(s − mᵢ)V its un-normalised output. Rescaling by exp(mᵢ − m) onto a common maximum m converts any chunk's statistics into the global frame, so the merge is exact and does not depend on the order chunks arrive in.
Subtracting the max is not cosmetic
It is what keeps exp from overflowing. Attention scores can be large; exp(score) in FP32 overflows well before the scores become unusual. Carrying a running max and exponentiating only differences is the numerically stable form — the same reason single-kernel softmax implementations do a max pass first.
The same state, two different granularities
FlashAttention carries (m, l, o) from tile to tile inside one block so the S×S score matrix never reaches HBM. Split-K carries the identical state from block to block so 32 processors can reconstruct one softmax. Recognising that these are the same trick at different scales is a good thing to say out loud.
Exactness is a requirement here, not a preference
The correctness bar was matching the BF16 reference within abs_err 0.1. An approximate merge — averaging, or dropping small chunks — would have failed outright. Split-K is only available as an optimisation because an exact merge exists.
How it connects
What it costs
- It is exact, which is the pointan approximate merge would have failed the abs_err 0.1 correctness bar outright
- But it is not freeS partial states per query must be written to and read from global scratch
- The reduction scales with Smore splits means more to merge — the other half of why S has an optimum
Autotuning the split factor
v4 · why a formula could not do it
Once the shape of the kernel was right, the remaining question was what value of S to use. v3 guessed with a heuristic. v4 stopped guessing and measured — and the measurements did not agree with the heuristic.
The problem
v3 chose S ≈ 2·SMs / T, aiming for roughly 256 blocks on the theory that block count is what fills the GPU. But two configurations can produce the same grid and completely different per-block work: T=8, S=32 is 64 KV per block; T=32, S=8 is 256 KV per block.
What it buys you
Benchmarking the candidates picks the configuration that is actually fastest for this workload, and the winner moves between workloads. Together with the warp changes: 95 µs → 57 µs.
How it works
Sweep the valid powers of two that divide TOPK / BLOCK_K, benchmark each, keep the fastest. Alongside it, two warp-configuration fixes: the reduce kernel went from 4 warps covering 16 heads in four serial passes to 16 warps, one per head, in one pass; the split kernel went from 4 warps to 8, so a warp stalled on a scattered read is covered by another.
Block count alone does not determine performance. Changing S changes memory traffic, execution time, register and shared-memory pressure, and reduction cost all at once — no single formula captures six things, so you measure.
Going deeper
A worked example — Two configurations, same grid, different machine
v3 picked S with a heuristic: S ≈ 2·SMs / T, aiming for roughly 256 blocks. The reasoning is that block count is what fills the GPU.
But T = 8, S = 32 gives 64 KV entries per block, and T = 32, S = 8 gives 256. Both are 256 blocks. They differ in memory traffic per block, execution time per block, register pressure, shared-memory pressure, reduction cost and scheduling behaviour.
No single formula captures six things at once — so v4 sweeps the valid powers of two that divide TOPK / BLOCK_K, benchmarks each, and keeps the winner. It is frequently not where the heuristic pointed.
The two warp changes that came with it
The reduce kernel ran 4 warps over 16 heads — four serial passes. v4 uses 16 warps (512 threads), one per head, in a single pass. The split kernel went from 4 warps (128 threads) to 8 (256), so more independent warps are resident to cover a stalled scattered read. Neither is clever; both are about not leaving units idle.
Autotuning finds the winner without explaining it
A sweep tells you which configuration is fastest, not why. That is fine for shipping and unsatisfying for understanding — the honest answer in an interview is that the measurement decides and the profiler explains. If you can say which resource the winning configuration stopped wasting, you are ahead of the sweep.
Where autotuning stops being practical
It costs compilation and benchmarking time, which is acceptable offline and awkward when shapes arrive at runtime. The usual production compromise is a cached table keyed by shape, with a heuristic as the fallback for anything unseen — which is roughly what v3 was, used correctly.
Measured beats predicted, repeatedly
It is the same lesson as least-outstanding-requests beating round-robin in a load balancer: a model of the system is a simplification, and where the simplification is wrong the measurement is not. Reaching for a formula is the right instinct; refusing to check it against the hardware is not.
How it connects
What it costs
- Tuning time is real timea sweep costs compilation and benchmarking, which is fine offline and awkward if shapes change at runtime
- It is workload-specific by constructionthe tuned value is a fact about one shape, not a law
- It explains, but does not predictautotuning finds the winner without telling you why — profiling is still what turns a number into understanding
The perceptron
a neuron is a weighted vote, and a vote is a line
The smallest possible learner: one neuron, one line. Everything in deep learning is this unit, repeated and stacked — and the one thing it cannot do, XOR, is the reason networks have hidden layers at all.
The problem
You have labelled points and want a rule that classifies a new one. The rule has to be learned from the data, not written by hand, and it has to be something a machine can adjust.
What it buys you
A weighted sum plus a threshold draws a line through the input space, and a one-line update rule moves that line toward every mistake until none remain — guaranteed, as long as a separating line exists.
How it works
Compute s = w·x + b and fire if s > 0. On a mistake with true label t, set w ← w + t·x and b ← b + t: adding a point to w pulls the line toward it, subtracting pushes it away. A second layer of neurons bends the space so that XOR, impossible for one line, becomes separable.
A neuron draws a line. A layer bends the space so the next line works. Depth is repeated bending.
Going deeper
A worked example — One update, by hand
Start with w = (0, 1), b = −3 — the line x₂ = 3. The point (2, 3) has label +1 but s = 0·2 + 1·3 − 3 = 0, which is not > 0, so it is misclassified.
Apply the rule: w ← (0, 1) + (+1)·(2, 3) = (2, 4), b ← −3 + 1 = −2. Now s = 2·2 + 4·3 − 2 = 14 > 0: the point is on the right side. The line rotated toward the point because adding x to w makes w·x larger for that x in particular.
Six updates later, w = (1.4, 1.4), b = −5 separates all eight points. Different update orders give different final lines — the theorem promises a separator, not the best one.
Why XOR needs a hidden layer, in one sentence
A single neuron's decision boundary is the set where w·x + b = 0 — a line — and XOR's two classes sit on opposite corners of a square, so no line puts both +1 corners on one side without one −1 corner. Two hidden ReLUs, h₁ = ReLU(x₁ + x₂) and h₂ = ReLU(x₁ + x₂ − 1), map the four corners to (0,0), (1,0), (1,0), (2,1), which are separable.
From the perceptron rule to gradient descent
The rule w ← w + t·x is gradient descent on the loss max(0, −t·(w·x + b)) with learning rate 1: the gradient of that loss on a mistake is exactly −t·x. Swap the step output for a sigmoid and the hinge for cross-entropy and you have logistic regression — same neuron, differentiable loss, and the gradient now carries how wrong, not just whether.
What the interviewer usually asks next
“Why do we need nonlinear activations?” Because a stack of linear layers is one linear layer: W₂(W₁x + b₁) + b₂ = (W₂W₁)x + b′. The activation between them is the only thing that lets depth draw anything but a line.
The classic pictures
The TensorFlow Playground lets you watch a tiny network bend the plane on exactly these datasets, XOR included, live in the browser. Rosenblatt’s 1958 paper and Minsky & Papert’s Perceptrons (1969) are the historical bookends; the Wikipedia article has the convergence theorem and the original learning rule.
How it connects
What it costs
- Only linear boundariesone neuron is one line; XOR needs a hidden layer
- Converges to a line, not the best linethe margin is whatever the update order produced — logistic regression and SVMs fix this
- No notion of confidencea step output is ±1; replacing it with a sigmoid gives a probability, which is what makes a loss differentiable
The forward pass
weighted sums and bends, layer by layer
What a network actually computes when you call it. Two inputs, two hidden neurons, one output, every number by hand — and the same two lines are exactly what a trillion-parameter model does at scale.
The problem
A single neuron can only draw a line. To represent anything curved, the outputs of neurons have to become the inputs of other neurons — and something has to stop the stack from collapsing back into one line.
What it buys you
Alternating a linear map (matmul + bias) with a nonlinearity (ReLU, sigmoid) gives a composition that can represent essentially any function — and whose every intermediate value can be stored for the backward pass.
How it works
Layer 1: z = W₁x + b₁, then h = ReLU(z). Layer 2: u = w₂·h + b₂, then ŷ = σ(u), a probability. The loss L = −log ŷ (cross-entropy, y = 1) turns how wrong into one number. With x = (1.0, 0.5): z = (0.5, 1.0), h = (0.5, 1.0), u = 0, ŷ = 0.5, L = 0.693.
Forward = matmul, bend, matmul, bend. Store everything — backward is about to need it.
Going deeper
A worked example — The numbers, end to end
x = (1.0, 0.5). W₁ = [[0.7, −0.6], [0.3, 1.0]], b₁ = (0.1, 0.2), w₂ = (0.4, −0.8), b₂ = 0.6.
z₁ = 0.7·1.0 + (−0.6)·0.5 + 0.1 = 0.5 · z₂ = 0.3·1.0 + 1.0·0.5 + 0.2 = 1.0 · h = ReLU(z) = (0.5, 1.0).
u = 0.4·0.5 + (−0.8)·1.0 + 0.6 = 0.0 · ŷ = σ(0) = 0.5 · with y = 1, L = −log 0.5 = 0.693.
The network is exactly undecided and the loss is ln 2 — the loss of a coin flip. Every page in this group reuses these numbers.
Which activation, and why ReLU won
Sigmoid and tanh saturate: for large |z| their slope is nearly zero, so gradients vanish through deep stacks. ReLU's slope is exactly 1 wherever it is on, so signals pass through unchanged, and it costs one comparison. Its flaw is units that are off for every input (dead ReLUs); GELU and SiLU, used in transformers, smooth the corner so there is always some slope.
Why cross-entropy and not squared error for a probability
With a sigmoid output and squared error, the gradient at the logit is (ŷ − y)·ŷ(1 − ŷ), which goes to zero exactly when the network is confidently wrong (ŷ near 0 or 1) — the worst time to stop learning. With cross-entropy the σ′ factor cancels and the gradient is just ŷ − y: largest when most wrong.
The classic pictures
3Blue1Brown’s neural-network series (chapter 1) animates exactly this forward pass on handwritten digits. Stanford’s CS231n notes draw the layer-as-matrix picture used here.
Shapes at real scale
For a batch of B examples, X is B × d_in and the layer computes X W + b with W of size d_in × d_out — one matmul of B·d_in·d_out multiply-adds. A transformer's MLP has d_in = 4096, d_out = 16384; with B = 4096 tokens that is 2.7 × 10¹¹ multiply-adds in one line. The forward pass is the reason the infrastructure notes exist.
How it connects
What it costs
- Every intermediate is kept for trainingz, h, u, ŷ — activation memory, the reason training needs several times the memory of inference
- Cost is dominated by matmulbatch × d_in × d_out per layer — which is what GPUs are built for
- The bend is not optionalwithout it depth buys nothing: W₂(W₁x) is one linear map
Activation functions
the bend, and the slope backprop multiplies by
Step, sigmoid, tanh, ReLU, GELU, softmax. Each one is a different answer to the same two questions: how does it bend the signal going forward, and what does it multiply the gradient by going back? The second question decided the history of deep learning.
The problem
A network needs a nonlinearity between layers or depth means nothing — but backprop multiplies one slope per layer, so a function whose slope is small or zero starves every layer before it of gradient.
What it buys you
ReLU and its smooth relatives keep the slope at 1 wherever the unit is active, so gradients survive depth; sigmoid and softmax are kept for the output, where a bounded probability is the point.
How it works
Sigmoid squashes to (0, 1), slope at most 0.25. tanh to (−1, 1), slope at most 1; both saturate. ReLU = max(0, z): slope 1 when on, 0 when off. GELU/SiLU smooth the corner, used in transformers. Softmax turns a vector of logits into a distribution; with cross-entropy its gradient at the logits is p − onehot(y).
Pick the hidden activation for its slope and the output activation for its range. ReLU/GELU inside, softmax at the end — that is every LLM.
Going deeper
A worked example — Ten layers, three activations
Backprop multiplies one slope per layer. At a typical pre-activation |z| ≈ 1: sigmoid′(1) = 0.20, tanh′(1) = 0.42, ReLU′(1) = 1.
Across ten layers the gradient reaching the first layer is scaled by 0.20¹⁰ ≈ 10⁻⁷ (sigmoid), 0.42¹⁰ ≈ 2 × 10⁻⁴ (tanh), or 1 (ReLU, if every unit on the path is on). With a learning rate of 0.01, the first layer of the sigmoid network moves by 10⁻⁹ per step: it is frozen.
Softmax, by hand: logits (2.0, 1.0, 0.1) → e^z = (7.389, 2.718, 1.105), sum 11.212 → p = (0.659, 0.242, 0.099). With the true class first, the gradient at the logits is p − onehot = (−0.341, 0.242, 0.099): push the right logit up, the others down, in proportion to how much probability they stole.
Why ReLU, and then why GELU
ReLU won in 2012 because its slope is 1 and it costs a comparison; the price is dead units and a kink at zero. GELU (z·Φ(z)) and SiLU (z·σ(z)) are smooth, never have slope exactly zero, and in practice give slightly better transformers at the same cost — GPT-2/3 and BERT use GELU, Llama uses SiLU inside a gated unit (SwiGLU).
Why the output layer is different
Hidden activations are chosen for their slope. The output activation is chosen to match the loss: sigmoid + binary cross-entropy for one probability, softmax + cross-entropy for a distribution, identity + squared error for a regression. In each matched pair the gradient at the logits collapses to prediction − target — the same ‘gradient = error’ every time.
The classic pictures
The table on Wikipedia’s activation-function page plots every function with its derivative. Stanford’s CS231n notes (neural networks part 1) walk through sigmoid, tanh, ReLU and leaky ReLU with the same ‘what does it do to the gradient’ framing used here. The GELU paper is Hendrycks & Gimpel, arXiv:1606.08415.
How it connects
What it costs
- Saturating functions vanishten sigmoid layers multiply a gradient by ~10⁻⁷; early layers never learn
- ReLU units can diez < 0 for every input means slope 0 forever; leaky ReLU, GELU and a sane learning rate prevent it
- The output is chosen by the losssigmoid for one probability, softmax for a distribution, identity for a number — not by taste
Backpropagation
the chain rule, run backwards
How a network finds out which way each of its weights should move. It is the chain rule applied once per node, in reverse, reusing stored forward values — nothing more exotic than that, and worth doing by hand exactly once.
The problem
Lowering the loss means knowing ∂L/∂w for every weight, and a weight deep in the network affects L only through everything after it. Deriving each gradient separately would repeat the same work millions of times.
What it buys you
One backward sweep. The gradient at the output is just the error ŷ − y; each layer multiplies the incoming signal by its local derivative and hands it back. Every ∂L/∂w falls out as upstream signal × the input that weight multiplies.
How it works
Start at the end: ∂L/∂u = ŷ − y = −0.5. Output weights: ∂L/∂w₂ = −0.5·h = (−0.25, −0.5). Back through the weights: ∂L/∂h = −0.5·w₂ = (−0.2, 0.4). Through ReLU: multiply by 1 where z > 0. First-layer weights: ∂L/∂W₁ = ∂L/∂z ⊗ x. Eight gradients, one pass.
Gradient of a weight = upstream signal × the input it multiplies. Going backwards through a weight means multiplying by that weight.
Going deeper
A worked example — Every gradient, derived
Stored from the forward pass: x = (1.0, 0.5), z = h = (0.5, 1.0), u = 0, ŷ = 0.5, y = 1.
Output. ∂L/∂u = ŷ − y = −0.5. ∂L/∂w₂ = (∂L/∂u)·h = (−0.25, −0.5). ∂L/∂b₂ = −0.5.
Back through w₂. ∂L/∂h = (∂L/∂u)·w₂ = −0.5·(0.4, −0.8) = (−0.2, +0.4).
Through ReLU. Both z > 0, so ∂L/∂z = (−0.2, 0.4) unchanged.
First layer. ∂L/∂W₁ = ∂L/∂z ⊗ x = [[−0.2·1.0, −0.2·0.5], [0.4·1.0, 0.4·0.5]] = [[−0.2, −0.1], [0.4, 0.2]]. ∂L/∂b₁ = (−0.2, 0.4).
One chain checked by hand: ∂L/∂w₁₁ = (∂L/∂u)(∂u/∂h₁)(∂h₁/∂z₁)(∂z₁/∂w₁₁) = (−0.5)(0.4)(1)(1.0) = −0.2. ✓
Why ŷ − y, and why that matters
∂L/∂ŷ = −1/ŷ and ∂ŷ/∂u = ŷ(1 − ŷ); multiply and the ŷ cancels, leaving ŷ − y. The same cancellation happens for softmax + cross-entropy in a classifier with many classes: the gradient at the logits is simply (probabilities − one-hot). That is why those two are always paired, and why the error signal at the output of a classifier is literally the error.
Where the 2× comes from
For a layer h = W a, backward needs two products: ∂L/∂W = (∂L/∂h) aᵀ to update the weights and ∂L/∂a = Wᵀ (∂L/∂h) to keep going. Each is the same size as the forward matmul, so backward ≈ 2× forward, and a training step ≈ 3× a forward pass — the figure behind every FLOPs estimate for training.
The classic pictures
3Blue1Brown’s Backpropagation calculus draws the chain-rule tree for a one-neuron-per-layer network, the same shape as the chain panel here. Karpathy’s micrograd (and its two-hour video) builds backprop in 100 lines of Python on a graph just like the one on the autodiff page.
Vanishing and exploding gradients
Each layer multiplies the signal by its weight matrix and by the activation's slope. Across 50 layers that product either shrinks toward zero (sigmoid, small weights) or grows without bound. Residual connections add an identity path so the product always has a 1 in it; LayerNorm keeps the scale of activations fixed; careful initialisation starts the products near 1.
How it connects
What it costs
- About twice the forward costtwo matmuls per layer going back — one for ∂W, one for ∂h — so training ≈ 3× forward FLOPs
- Needs the forward activationsor recomputes them: activation checkpointing
- Dead units learn nothinga ReLU that was off passes zero gradient, so its weights get no signal from that example
- Signals shrink or blow up with depthvanishing and exploding gradients — the reason residual connections and normalisation exist
Automatic differentiation
what loss.backward() actually does
Nobody writes backprop by hand. The framework records the forward computation as a graph, and one call walks it backwards. Knowing what it records — and what it has to keep in memory — is the difference between using PyTorch and understanding it.
The problem
A modern model is thousands of operations whose derivatives would be impossible to derive and maintain by hand, and the gradient is needed with respect to every one of billions of parameters.
What it buys you
Each operation knows only its own local derivative. Recording operations as they run (define-by-run) and visiting them in reverse order assembles the full gradient from local pieces — one sweep for all parameters, at about twice the cost of the forward pass.
How it works
Forward: every op appends a node holding its grad_fn and saves the tensors its derivative needs. loss.backward() seeds 1 at the loss and visits nodes in reverse topological order; each multiplies the incoming gradient by its local derivative (a vector–Jacobian product) and passes the result to its inputs. Whatever reaches a leaf accumulates into .grad.
Each node knows only its local derivative. Reverse mode multiplies them from the loss backwards, so one sweep gives every parameter's gradient.
Going deeper
A worked example — Reading the graph the framework built
h = torch.relu(W1 @ x + b1) creates two nodes (a matrix–vector product, then an add) and a ReLU node; yhat = torch.sigmoid(w2 @ h + b2) adds three more; loss = -torch.log(yhat) two more. Each node stores a grad_fn and the tensors its derivative needs — ReLU keeps the mask (z > 0), sigmoid keeps ŷ, every matmul keeps its input.
loss.backward() walks them in reverse. At −log: incoming 1, local −1/ŷ = −2.0. At sigmoid: −2.0 × ŷ(1 − ŷ) = −0.5 — the framework never used the shortcut ŷ − y, it multiplied the two local pieces. At the second linear node it produces three outputs at once: a gradient for h (−0.5·w₂) that continues, and gradients for w₂ and b₂ that stop at those leaves.
Afterwards W1.grad is [[−0.2, −0.1], [0.4, 0.2]] — the hand-derived answer.
Forward mode vs reverse mode
Both use the same local derivatives. Forward mode pushes a perturbation of one input through the graph (a Jacobian–vector product) and gets the sensitivity of every output to that one input; reverse mode pulls a weight on one output back through the graph (a vector–Jacobian product) and gets the sensitivity of that one output to every input. Training has one output (the loss) and billions of inputs, so reverse mode wins by a factor of billions.
What is actually in memory during training
Weights, their gradients, the optimizer state (two more copies for Adam), and the saved activations — which for a transformer are every layer's input, attention scores and MLP intermediate for every token in the batch. The activations usually dominate at long sequence lengths. Activation checkpointing keeps only the layer boundaries and recomputes the rest during backward, trading ~30% more compute for a large memory cut.
The classic pictures
PyTorch’s Autograd mechanics note is the authoritative description of what gets saved and when; Baydin et al., Automatic differentiation in machine learning: a survey, has the canonical forward-vs-reverse mode tables. Karpathy’s micrograd is the smallest working implementation worth reading end to end.
Define-by-run vs a static graph
PyTorch records the graph fresh on every forward call, so Python control flow (if, loops, early exit) just works, at the cost of re-recording each time. Static-graph systems (TensorFlow 1, JAX's jit, torch.compile) capture the graph once and can optimise it — fuse kernels, plan memory — but need the structure to be fixed. Modern practice is eager for development and compiled for the training run.
How it connects
What it costs
- Activation memoryevery saved tensor lives until backward consumes it — batch × sequence × depth of them
- Reverse mode pays off for scalar outputsone output, many inputs is exactly training; forward mode would need one sweep per parameter
- Gradients accumulatewhich is why you call zero_grad(), and how a tensor used twice gets the sum of both paths
SGD and mini-batches
the gradient you can afford
The exact gradient is an average over the whole dataset, which no one can afford per step. SGD takes the average over a handful of examples instead: a noisy estimate, thirty times cheaper, and the noise turns out to be a feature. Every large model is trained this way.
The problem
One full-batch gradient step costs a pass over the data. On real datasets that is hours per step, and most of the pass is spent confirming what the first few hundred examples already said.
What it buys you
A mini-batch gradient is an unbiased estimate of the true one, with noise ∝ 1/√B, at cost B instead of N. Many cheap noisy steps beat one exact step by a mile for the same compute — and the noise seems to steer training toward flatter minima that generalise better.
How it works
Shuffle the data each epoch; take it in batches of B; one step per batch, so N/B steps per epoch. Batch size sets the noise and the cost; the learning rate scales up with the batch (the linear scaling rule) up to a critical size, with warm-up. Because the noise never vanishes, η is decayed over training so the walk settles.
SGD is gradient descent with a cheap, noisy gradient. B sets the noise, η scales with B, and the noise is why learning-rate schedules exist.
Going deeper
A worked example — One parameter, 1,000 examples
Fit w to 1,000 numbers drawn around μ = 3.03 (σ ≈ 1), loss = average of ½(w − xᵢ)². The exact gradient is w − μ. From w = 0 with η = 0.3, full-batch GD takes one step per pass over the data: w = 0.91 after one epoch, 1.99 after three.
A random batch of 32 has mean 2.94, so its gradient is −2.94 instead of −3.03: nearly the same direction at 1/31 of the cost. Thirty-one such steps per epoch put w ≈ 3.0 before the first epoch is a tenth over. The noise per step is σ/√B ≈ 0.18; near the minimum w jitters by about ησ/√B ≈ ±0.07 and never fully settles — the reason to decay η.
Batch size, learning rate and the critical batch
Doubling B halves the gradient variance, so the same per-epoch progress survives doubling η too — the linear scaling rule (Goyal et al., arXiv:1706.02677, trained ImageNet in an hour on 8k-image batches with it, plus a warm-up). It stops working past the critical batch size, where the gradient is already accurate and extra examples per step buy nothing; beyond that only more steps help.
Sharp vs flat minima
Keskar et al. (arXiv:1609.04836) observed that large-batch training lands in sharper minima and generalises worse, and attributed it to the missing noise. The picture is widely used and well supported but still argued about — later work showed the gap can mostly be closed by scaling η and training longer. In an interview, say ‘tends to’, not ‘always’.
The classic pictures
The loss-landscape renders everyone has seen — the spiky ResNet-56 surface that skip connections smooth out — are from Li et al., Visualizing the Loss Landscape of Neural Nets. The optimizer-race animations (SGD, momentum, NAG, Adagrad, Adadelta, RMSprop racing through a saddle and a valley) are Alec Radford’s, reproduced in CS231n notes part 3, which also covers batch size, learning-rate schedules and gradient checks. Sebastian Ruder’s overview of gradient descent optimization algorithms is the standard written tour.
How it connects
What it costs
- Noise never fully goes awaythe jitter near the minimum is ∝ ησ/√B, which is why schedules exist
- Batch size is a hardware decision tooa bigger batch is a bigger matmul; GPUs want it large, generalisation sometimes wants it smaller
- Shuffling mattersun-shuffled batches give correlated, biased gradient estimates
Optimizers
from a gradient to an update — GD, SGD, momentum, Adam
The gradient says which way is downhill; the optimizer decides how far to step, and in which direction once momentum and scaling get involved. Most of what makes large-model training work or fail lives here, in one hyperparameter and two running averages.
The problem
Plain gradient descent follows the raw gradient with one learning rate for every parameter. On a surface that is steep one way and flat another — which is what real loss surfaces look like — it zigzags across the steep direction and crawls along the flat one, and a learning rate that is safe for one is wrong for the other.
What it buys you
Momentum averages out the zigzag. Adam normalises each parameter's step by the typical size of its own gradient, so steep and flat directions move at the same pace. A schedule changes the learning rate over time so early steps are safe and late ones settle.
How it works
GD: w ← w − η∇L. SGD: the same with a mini-batch gradient. Momentum: v ← βv + ∇L, w ← w − ηv. Adam: running means of the gradient (m) and of its square (v); step = η·m̂/√v̂. AdamW: weight decay added as its own term. Warm η up from zero, then decay it.
GD is the idea, SGD is what you can afford, momentum fixes the zigzag, Adam fixes the scale, and AdamW with warm-up and cosine decay is the default.
Going deeper
A worked example — The same valley, five walkers
f(w) = ½(w₁² + 10w₂²), starting at (−9, 1.4). The gradient is (w₁, 10w₂): ten times steeper in w₂.
GD, η = 0.15. In w₂ each step multiplies by 1 − 1.5 = −0.5: it overshoots and flips sign. In w₁ each step multiplies by 0.85: a crawl. After 12 steps w₁ = −1.3.
η = 0.21. The w₂ multiplier is −1.1, magnitude above 1: the walk diverges, f goes from 1 to 39. η = 0.03 is stable but reaches only w₁ = −6.2.
Momentum, β = 0.8, η = 0.04. Alternating w₂ steps cancel in the velocity; the consistent w₁ steps accumulate. Reaches f = 0.15 — smoother and faster.
Adam, η = 0.8. Each coordinate's step is normalised by its own gradient scale, so w₁ (gradient 9) and w₂ (gradient 14) move about the same distance per step. The steepness difference stops mattering.
Why Adam's step is roughly ±η
m is a running mean of g and v a running mean of g², so m/√v is g divided by its own typical magnitude: about ±1 while the gradient keeps its sign, smaller when it oscillates. The step is therefore about η per parameter whatever the gradient's scale — which is what lets one η serve embeddings, attention and MLP weights whose gradients differ by orders of magnitude.
AdamW, and why decay is decoupled
L2 regularisation adds λw to the gradient — but Adam then divides that term by √v, so heavily-updated parameters are barely regularised. AdamW instead subtracts ηλw directly from the weight, outside the adaptive scaling. It is the default for transformer training; typical λ is 0.1, far larger than the 1e-4 people used with SGD.
The classic pictures
Alec Radford’s optimizer animations in CS231n notes part 3 are the race through a valley and over a saddle that this page’s plot imitates. Adam is Kingma & Ba, arXiv:1412.6980; AdamW is Loshchilov & Hutter, arXiv:1711.05101; Ruder’s overview walks the whole ladder with the same pictures.
Warm-up and cosine decay
Adam's v̂ is a poor estimate for the first few hundred steps and the initial weights are random, so a full-size η early on can wreck the run; warming η up linearly from zero avoids it. Decaying η afterwards (cosine to ~10% of peak is the common choice) lets the walk settle into the minimum instead of bouncing around it. Batch size interacts: larger batches tolerate larger η, roughly proportionally up to a point.
How it connects
- SGD and mini-batchesthe noisy gradient every optimizer here consumes
- Backpropagationwhere the gradient comes from
- Automatic differentiation.grad is what optimizer.step() reads
- The forward passthe loss being descended
- Tensor, pipeline and expert parallelismoptimizer state is what sharded training has to split
What it costs
- η is the hyperparametertoo large diverges, too small stalls — tune it first
- Adam keeps two extra copies of every parameterm and v: optimizer state is 2× the model, the largest piece of training memory
- Noise is a featuremini-batch noise is what makes large-scale training affordable, and it seems to help generalisation