Flash Attention 2 — backward pass notes
Implementation log for the second half of task #47. Forward landed last
session (webgpu/attention_fa2.wgsl + tests/test_fa2_parity.mjs +
docs/fa2_forward_notes.md); this is the runway for backward.
This delivery: algorithm-level parity verified in Node
(tests/test_fa2_backward_parity.mjs). The WGSL kernel + ops.ts wiring
are left for a follow-up session because of a real binding-budget
problem documented below.
The math (FA2 backward with recompute)
Standard attention backward starts from dO = ∂loss/∂ctx and produces
dQ, dK, dV. Naive version uses the cached attention matrix P. The
FA2 trick: with the forward’s saved L = m_final + log(l_final)
(log-sum-exp per Q row), we reconstruct P = exp(S − L) from q/k
without ever reading the full [B, H, T, T] attn matrix.
Per (batch, head, q_row):
D[q_row] = ⟨dO[q_row], O[q_row]⟩ // scalar; O = ctx from forward
for t2 in causal range (t2 ≤ q_row):
S = q[q_row] · k[t2] · scale
P = exp(S − L[q_row]) // recompute
dP = ⟨dO[q_row], V[t2]⟩
dS = P · (dP − D[q_row])
dQ[q_row] += dS · k[t2] · scale // own thread
dK[t2] += dS · q[q_row] · scale // others contribute too
dV[t2] += P · dO[q_row] // others contribute too
dQ is owned by q_row’s thread — no contention. dK and dV are
gathered: every q_row ≥ t2 contributes to the same k[t2] and v[t2].
That contention shapes the kernel design (see below).
Parity verification — what’s already done
tests/test_fa2_backward_parity.mjs mirrors the planned WGSL kernel
in plain JS. Six shapes including the masked-boundary case (T=20),
the Behemoth-shaped head dim (hd=64), and the regime-crossover case
(T=256). All 18 checks (6 shapes × dQ/dK/dV) pass within 1 ULP of
the naive cached-attn reference:
$ node tests/test_fa2_backward_parity.mjs
ok fa2 dQ vs naive [B=1 T=16 C=32 H=4 hd=8] maxAbs=2.98e-8
ok fa2 dK vs naive [B=1 T=16 C=32 H=4 hd=8] maxAbs=2.98e-8
ok fa2 dV vs naive [B=1 T=16 C=32 H=4 hd=8] maxAbs=2.38e-7
...
ok fa2 dV vs naive [B=1 T=256 C=64 H=2 hd=32] maxAbs=2.98e-7
ALL PASS
dV shows higher absolute error because it sums T contributions
per output element; 2.98e-7 at T=256 is ~T·ε for f32 — clean.
Why the WGSL kernel isn’t shipped this turn
The natural binding layout for the backward kernel is 7 storage buffers:
g0 = q (read)
g1 = k (read)
g2 = v (read)
g3 = L (read) ← new in forward, saved per (b, h, t1)
g4 = dO (read)
g5 = ctx (read) ← needed to compute D[t1] = ⟨dO, O⟩
g? = dQ/dK/dV (write)
The shared bind layout in webgpu/ops.ts exposes exactly six storage
buffers + one uniform. Three workable resolutions, none of which
deserve to be rushed:
- Pre-pass kernel computes
Donce and stores it in a small[B, H, T]buffer. The main backward kernel then dropsctxand readsDinstead — fits in six bindings cleanly. Two kernels total. - Buffer aliasing trick — overwrite
g4 = dOwithdqin place. Each thread reads its fulldO[t1, :]row into private registers before the K-block loop, then writesdq[t1, :]to the same slot. No race because the buffer is per-thread (per-Q-row). One kernel, but the aliasing rule (ops.tsalready comments that WebGPU forbids aliasing the same buffer in two writable bindings of one bind group) means we need a slightly different bind-group construction path. - Two-kernel split by output role:
fa2_backward_dq(one workgroup per Q tile, dQ-only — no contention) andfa2_backward_dkv(one workgroup per K tile, walking all Q rows ≥ k_row, dK + dV). This is the FA2 paper’s actual implementation choice. Avoids float atomics entirely; uses the natural data-flow split (each kernel’s threads each own one row of their output buffer). Likely the right long-term shape.
Picking between (1) and (3) is a 20-minute design decision; doing it
right is another half-day of careful WGSL plus the parity gate
(tests/test_webgpu_train.mjs end-to-end, 5% drift bar). Today’s
delivery pins the math so whichever path the next session picks, the
algorithm itself is verified.
What the next session needs to do
- Decide on (1) vs (3) above. My recommendation: (3), because it matches the published FA2 backward and parallelises naturally.
- Modify forward to save
L = m + log(l)into a new[B, H, T]buffer (small) so backward can read it. Today the kernel savesm_finalandl_finaltovar<workgroup>arrays — we just expose them to a host-visible storage buffer. - Write the WGSL kernel(s) following the plan in
test_fa2_backward_parity.mjs. The Node mirror’s loops are the exact WGSL structure modulo cooperative loading of K/V tiles into shared memory. - Live WGSL compile check via Playwright + a real WebGPU adapter,
same pattern as
tests/test_fa2_compile.mjsdid for the forward. - Integration in
ops.ts: add anattentionBackwardFA2method that calls the new kernel(s); routeattentionBackwardto it conditionally on whetherattentionForwardused the FA2 path (today both are conditional onhd ≤ 64). - End-to-end gate:
tests/test_webgpu_train.mjs— drift < 5%. - Drop the second
attnpass from the forward kernel once backward is on the recompute path. Saves the O(B·H·T²) global memory write that the FA1-style backward currently needs. This is the other half of the FA2 memory win. - Bench: one Mega-preset (ctx=512) step with FA1 forward + FA1
backward vs FA2 forward + FA2 backward. Document in
browser/devlog.html. Single shot, per AGENTS.md “Safety rules” — no loops.
Caveats specific to the backward path
- The L = -∞ row. For Q rows where every K position is masked
(shouldn’t happen with causal attention as long as
q_row < T, but defensive code wins):L = -∞meansexp(S − L) = exp(+∞)blows up to NaN. The forward already writes zeroctxfor those lanes; the backward needs the same guard (skip rows wherel == 0). - D = ⟨dO, O⟩ as scalar. Cheap, but needs
O(= forward ctx) available. If we go with split (3),fa2_backward_dqcan compute D from O+dO on the fly;fa2_backward_dkvdoesn’t need D directly (it needsdSwhich factors through D, so it might need a small D buffer either pre-computed or duplicated in shared memory). - No dropout, no relative position bias. Same scope as the forward.
Where this lives in the codebase
docs/fa2_forward_notes.md — forward writeup (last session)
docs/fa2_backward_notes.md — this file
tests/test_fa2_parity.mjs — forward parity (last session)
tests/test_fa2_backward_parity.mjs — backward parity (this session)
webgpu/attention_fa2.wgsl — forward kernel (last session)
webgpu/attention_fa2_backward.wgsl — backward kernel (next session)
webgpu/ops.ts — integration site (next session)