A visual guide · VJP-first

Backprop for kernel writers

You don't need autodiff theory. You need to turn the gradient of the output into the gradient of the input, one op at a time — and to know what that does to your memory access pattern.

forward · activations backward · gradients

00Notation

One bar, and you can read everything below

Writing \(\partial L/\partial X\) everywhere makes formulas unreadable. The standard autodiff shorthand puts a bar over the variable:

\[\bar{X}\;\equiv\;\frac{\partial L}{\partial X}\qquad\text{— in code this is }\texttt{dX}\text{ or }\texttt{grad\_X}\]
Key property: the gradient always has the same shape as the thing it is the gradient of. \(\bar X\) has the shape of \(X\). This alone catches most bugs.

01The only idea

Backprop is repeated vector–Jacobian products

Every op is a function \(y=f(x)\). The framework hands you \(\bar y\), the gradient of the loss with respect to your output. Your job is to return \(\bar x\). That is the entire contract:

\[\textcolor{#4dd0e1}{\bar{x}}\;=\;\left(\frac{\partial y}{\partial x}\right)^{\!\!\top}\textcolor{#4dd0e1}{\bar{y}}\]
This is a vector–Jacobian product (VJP), also called the cotangent or the pullback. The Jacobian \(\partial y/\partial x\) appears in the formula but is never materialized in a kernel.

That last line is the most important sentence on this page. For softmax over a 4096-vector the Jacobian is \(4096\times4096\approx16\text{M}\) floats — per row. A good kernel computes the same VJP in \(O(N)\) time and \(O(1)\) extra memory. Learning backprop for kernels is learning these collapses.

What you can skip

Tape recording, graph topological sort, higher-order gradients, checkpoint scheduling. PyTorch and JAX own all of it — you register a backward and the framework calls it. And if you only write inference kernels (quantized GEMMs, decode attention, sampling), you can skip this page entirely and lose nothing.

02See it move

One real chain, with real numbers

A 2-input linear layer into softmax into cross-entropy. Step through it: the forward pass fills in activations, the backward pass fills in gradients right to left.

x → linear → softmax → loss
Press Step to push values forward.

Notice the payoff at the end. Softmax has a dense Jacobian and cross-entropy has a \(-1/y_t\) singularity, but composed together they cancel exactly:

\[L=-\log y_t,\qquad y=\mathrm{softmax}(h)\quad\Longrightarrow\quad \textcolor{#4dd0e1}{\bar h}\;=\;y-e_t\]
\(e_t\) is the one-hot vector for the true class. Two ugly derivatives collapse into a subtraction — which is why every framework ships a fused softmax_cross_entropy rather than two ops. This is the simplest example of the thing this whole page is about.

03Linear & matmul

Two transposes, two completely different kernels

\[Y=XW+\mathbf{1}b^\top,\qquad X\in\mathbb{R}^{M\times K},\;\; W\in\mathbb{R}^{K\times N},\;\; Y\in\mathbb{R}^{M\times N}\]
Forward. \(M\) is the batch axis, \(K\) the input features, \(N\) the output features.
\[\bar{X}=\bar{Y}W^{\top}\qquad \bar{W}=X^{\top}\bar{Y}\qquad \bar{b}=\sum_{i=1}^{M}\bar{Y}_{i,:}\]
The mnemonic that never fails: there is exactly one arrangement of the operands whose shapes agree. Forget the formula, write down the dimensions, and the transposes place themselves.

The shape-level view hides the interesting part. Write the same two results elementwise and the difference jumps out:

\[\bar{X}_{ik}=\sum_{\textcolor{#f0a04b}{n=1}}^{\textcolor{#f0a04b}{N}}\bar{Y}_{in}\,W_{kn} \qquad\qquad \bar{W}_{kj}=\sum_{\textcolor{#ef6f6c}{i=1}}^{\textcolor{#ef6f6c}{M}}X_{ik}\,\bar{Y}_{ij}\]
Same three matrices, but the reduction index differs: \(\bar X\) sums over the feature axis \(N\); \(\bar W\) sums over the batch axis \(M\). Watch what that does below.
access patterns of the two backward matmuls
The kernel consequence

\(\bar X\) has one independent output per \((i,k)\) and parallelises across the batch like an ordinary tiled GEMM. \(\bar W\) reduces over the batch — every sample contributes to every weight gradient, so partial sums must combine across thread blocks: atomicAdd, split-K, or a separate reduction pass. On large batches with small weight matrices, \(\bar W\) is usually where your backward kernel's time actually goes.

04The duality

Broadcast forward ⇄ reduce backward

This is the structural law behind most of the surprises above. If a value is copied in the forward pass, its backward must sum. Conservation of influence: a value that affected ten outputs receives gradient from all ten.

\[\text{forward:}\quad Y_{ij}=X_{ij}+b_j\qquad\Longrightarrow\qquad \text{backward:}\quad \bar X_{ij}=\bar Y_{ij},\quad \bar b_j=\sum_{i=1}^{M}\bar Y_{ij}\]
bias add: b (1×5) broadcast over a (4×5) batch

And it runs the other way. An op that sums forward must broadcast backward:

\[y=\sum_{i} x_i \qquad\Longrightarrow\qquad \bar x_i=\bar y \;\;\text{for every }i\]

Once you see this, you can guess the shape of any backward before deriving it.

If forward does

broadcast · repeat · gather · expand · tile · index-select

Backward becomes

sum · reduce · scatter-add · atomics · segmented reduce

05Softmax

A dense \(N\times N\) Jacobian that collapses to one dot product

\[y_i=\frac{e^{x_i}}{\sum_{j}e^{x_j}} \qquad\Longrightarrow\qquad \frac{\partial y_i}{\partial x_j}=y_i\left(\delta_{ij}-y_j\right)\]
Genuinely dense — every output depends on every input. Building this is \(O(N^2)\) memory. Don't.

Multiply the Jacobian by \(\bar y\) by hand and something remarkable falls out — every component shares the same scalar:

\[\bar x_i=\sum_j \frac{\partial y_j}{\partial x_i}\bar y_j =\sum_j y_j(\delta_{ji}-y_i)\,\bar y_j =y_i\bar y_i-y_i\underbrace{\sum_j y_j\bar y_j}_{\textstyle s}\] \[\boxed{\;s=\sum_j \bar y_j\,y_j\qquad\qquad \bar x_i=y_i\left(\bar y_i-s\right)\;}\]
\(O(N^2)\to O(N)\) time. \(O(N^2)\to O(1)\) extra memory. One pass to accumulate the scalar \(s\), one elementwise pass to apply it.
softmax backward: dense Jacobian vs. scalar hub

Two more things a kernel writer must know about softmax:

LayerNorm — the same shape of problem

LayerNorm couples all \(N\) elements through the mean and variance, so it also looks like it needs a dense Jacobian, and it also collapses — this time to two row-wise scalars instead of one.

\[\mu=\tfrac1N\textstyle\sum_i x_i,\qquad \sigma=\sqrt{\tfrac1N\textstyle\sum_i (x_i-\mu)^2+\epsilon},\qquad \hat x=\frac{x-\mu}{\sigma},\qquad y=\gamma\odot\hat x+\beta\]
\[g=\bar y\odot\gamma,\qquad s_1=\tfrac1N\textstyle\sum_i g_i,\qquad s_2=\tfrac1N\textstyle\sum_i g_i\,\hat x_i\] \[\bar x=\frac{g-s_1-\hat x\,s_2}{\sigma} \qquad\qquad \textcolor{#ef6f6c}{\bar\gamma=\sum_{\text{batch}}\bar y\odot\hat x},\qquad \textcolor{#ef6f6c}{\bar\beta=\sum_{\text{batch}}\bar y}\]
The pattern repeats: the per-row part is cheap and embarrassingly parallel; the parameter gradients reduce over the batch axis and need cross-block accumulation. Same story as \(\bar W\).

06Top-k & routing

Gradient as a sparse scatter

Top-k is a selection, not a computation. Forward it emits \(k\) values and their indices \(i_1,\dots,i_k\). Backward, gradient flows only into those slots:

\[y_m=x_{i_m}\qquad\Longrightarrow\qquad \bar x_j=\sum_{m\,:\,i_m=j}\bar y_m \qquad\text{(zero for every unselected }j)\]
A pure scatter-add. If indices are guaranteed distinct — as in top-k — a plain scatter suffices. If they can repeat, as in gather or embedding lookup, you must use atomic add, and the result is nondeterministic by default.
top-k, N=8, k=3

Two traps here that cost people real debugging time:

The same skeleton covers a whole family — all of them zero-FLOP backwards whose entire cost is the memory access pattern:

\[\text{ReLU: }\;\bar x=\bar y\odot\mathbb{1}[x>0] \qquad \text{max-pool: scatter to }\arg\max \qquad \text{dropout: reuse the mask}\]

07Fusion

Save vs. recompute, the central tradeoff

The moment you fuse, you own the VJP of the whole composite — you can no longer chain library backwards. And you face a decision that doesn't exist in unfused code: the forward's intermediates are gone unless you wrote them out, and writing them out is often the exact thing you fused to avoid.

Attention is the famous case. Here is the full backward, and note that step three is just the softmax collapse from §05 applied row-wise:

\[S=\frac{QK^{\top}}{\sqrt{d}},\qquad P=\mathrm{softmax}_{\text{row}}(S),\qquad O=PV\]
\[\bar V=P^{\top}\bar O,\qquad \bar P=\bar O\,V^{\top}\] \[\bar S=P\odot\bigl(\bar P-D\bigr),\qquad D_i=\textstyle\sum_j \bar P_{ij}P_{ij}\;=\;\textcolor{#7ec87e}{\textstyle\sum_d \bar O_{id}O_{id}}\] \[\bar Q=\frac{\bar S K}{\sqrt d},\qquad \bar K=\frac{\bar S^{\top} Q}{\sqrt d}\]
That green identity is a genuinely lovely trick: substituting \(\bar P=\bar O V^\top\) into \(D\) makes the \(V\) cancel, so the row-scalar can be computed up front from \(\bar O\) and \(O\) alone — no need for \(P\) yet. FlashAttention precomputes all of \(D\) in one cheap pass before touching the score matrix.

Which leaves the real question: where does \(P\) come from in backward?

\[\underbrace{\text{store }P}_{O(N^2)\text{ HBM}} \qquad\text{vs.}\qquad \underbrace{\text{store only }L_i=m_i+\log\ell_i}_{O(N)\text{ HBM}},\;\; P_{ij}=\exp\!\left(S_{ij}-L_i\right)\]
\(m_i\) is the row max and \(\ell_i\) the row sum of exponentials, both already computed in the forward's online softmax. From the single scalar \(L_i\), any tile of \(P\) can be rebuilt exactly.
attention backward · HBM traffic vs. FLOPs · N=4096
Recompute costs more arithmetic and still finishes first — because you were never compute-bound to begin with.
The general rule

Recompute when the intermediate is large and rebuilding it is cheap arithmetic on data already in registers or SRAM. Save when the intermediate is small — a scalar or one vector per row — or when regenerating it would need another trip to HBM. Softmax statistics, RNG seeds and normalisation constants are almost always worth saving. Anything \(O(N^2)\) almost never is.

Why fused backward is harder than fused forward

08Reference

The cheat sheet

ForwardBackward (VJP)Kernel note
\(Y=XW\)\(\bar X=\bar YW^{\top}\), \(\;\bar W=X^{\top}\bar Y\)\(\bar W\) reduces over batch → split-K / atomics
\(y=x+b\)\(\bar x=\bar y\), \(\;\bar b=\sum_{\text{batch}}\bar y\)broadcast ⇄ reduce
\(y=\mathrm{ReLU}(x)\)\(\bar x=\bar y\odot\mathbb{1}[x>0]\)store a 1-bit mask, not \(x\)
\(y=\mathrm{softmax}(x)\)\(\bar x=y\odot(\bar y-s)\), \(\;s=\bar y\!\cdot\!y\)needs \(y\); one row-wise dot product
\(y=\mathrm{LN}(x)\)\((g-s_1-\hat x s_2)/\sigma\)two row sums; \(\bar\gamma,\bar\beta\) over batch
\(y=x_1\odot x_2\)\(\bar x_1=\bar y\odot x_2\), \(\;\bar x_2=\bar y\odot x_1\)must save both inputs
\(y=\mathrm{topk}(x)\)scatter \(\bar y\) into the saved indiceszero FLOPs; indices are constants
\(y=\mathrm{gather}(x,i)\)\(\mathrm{scatter\_add}(\bar x,i,\bar y)\)atomics; nondeterministic by default
\(y=x/\lVert x\rVert_{\text{rms}}\)\((\bar y-\hat x\,\overline{\hat x\!\cdot\!\bar y})/\mathrm{rms}\)same collapse shape as softmax
\(y=\mathrm{GELU}(x)\)\(\bar y\odot\mathrm{GELU}'(x)\)save \(x\); tanh-approx and exact differ
attention\(\bar Q,\bar K,\bar V\) via recomputed \(P\)save logsumexp only — \(O(N)\)

How to actually learn this

The derivations you cannot look up are the ones you own. In order, each a single sitting:

Debugging order

When a backward is wrong, check in this order: (1) a transpose — shapes pass but semantics don't; (2) a missing reduction over the batch axis — correct at batch 1, wrong above it; (3) a sign; (4) a scatter that needed atomic add. That ordering catches the overwhelming majority. Always test at batch 1 and batch 7 — class (2) is completely invisible at batch 1, and 7 is prime so it won't tile evenly and will expose your boundary handling too.