The fused KDA adjoint — autopsy, and the gate that was missing
Lane: worktree wt/kda-adjoint, off d8062d1.
Brief: the fused adjoint is measured WRONG (d8fa449, 4.5e-2..2.6e-1 on
q/k/v, beta_proj, decay.b_alpha) and non-deterministic; fix it, or deliver an
autopsy.
Status: the “wrong term” hunt was aimed one scope too narrow, and the fix is one line of fixture selection. Read on.
1. The contradiction on arrival, and which side is wrong
Section titled “1. The contradiction on arrival, and which side is wrong”Two gates in this tree disagree about the same kernels, and both are green:
| gate | reference | shape | verdict on record |
|---|---|---|---|
fused_adjoint_f64.rs (814847b) |
f64 forward-mode AD + FD, ref_bwd_f64.bin |
T=16, chunk=16 — ONE chunk | GREEN, term by term |
fused_adjoint_vs_ops.rs |
burn autograd over the ops path (tier d) | T=16 and T=64 | GREEN, GRAD_REL_TOL 3e-3 |
kda_param_grads_cuda.rs (d8fa449) |
FD of its own forward + CPU NdArray |
T=256, chunk=16 — 16 chunks | 2-25% on the op’s INPUT gradients |
fused_adjoint_f64.rs’s own header says the scope out loud, and it is the whole
story (tests/fused_adjoint_f64.rs:28-41):
Do not read a green run of this file as “the fused backward is correct”. It is “the fused INTRA-chunk adjoint is correct against an independent oracle.”
BK1 (token-parallel intra-chunk) is verified. BK2 (the sequential reverse
recurrence over chunks) and the d_k_bptt / d_e_bptt / d_s_shift glue have no
f64 oracle at all — the file says so, names the two terms that lived there
(2a430cc measured d_k at 3.3e-1 and d_g at 6.2e-1 before its fix), and says
why it could not add one: “the forward’s chunk carry does not match the f64
transcription on a two-chunk sequence at all (measured 8.3e-1)”.
d8fa449’s disagreement is measured at 16 chunks. The verified scope is
1 chunk. Those are not the same function, so the two gates are not in
conflict; they are measuring BK1 and BK2.
2. THE BLOCKER WAS RETRACTED 16 HOURS BEFORE d8fa449 RAN
Section titled “2. THE BLOCKER WAS RETRACTED 16 HOURS BEFORE d8fa449 RAN”The reason no two-chunk gradient gate existed is recorded in that same header,
and it is stale. c305ec5 (09-30 18:17) fixed the cause:
two_chunks_the_forward_still_agrees_with_the_f64_transcriptionwas red at chunk 1 = 5.603e-01 for five agents’ worth of attempts… There is no such line: the fault istools/gen_bwd_f64.py:258, one line of the REFERENCE.decay_last = np.exp(g_last - G)whereg_lastwas alreadyexp(G_last)— a double exp. Three independent implementations agree (ops arm, CUDA kernel, FLA); only the oracle differed. This commit changes no production code.
After it: chunk 1 5.603e-01 -> 3.327e-08, final state 8.930e-01 -> 2.621e-07,
dstate gradient -- -> 2.039e-07.
So the “we cannot extend the oracle to two chunks” note is a stale
justification, and d8fa449 was written at 23:55, 5h38m after the thing that
invalidated it. The scope was never widened. That is the actual defect in the
lane, and it is a documentation-ordering accident, not a numerics problem.
3. The two-chunk f64 GRADIENT oracle already exists, on disk, unused
Section titled “3. The two-chunk f64 GRADIENT oracle already exists, on disk, unused”gen_bwd_f64.py:465-491 writes ref_bwd_f64_carry.bin at T = 2*chunk, same
generator, same two methods, and it contains gradients, not just the state:
ref_bwd_f64_carry.bin 18 blocks q k v g b w (1,2,32,8) state (1,2,8,8) d_out (1,2,32,8) out (1,2,32,8) out_state (1,2,8,8) loss (1,) dq dk dv dg db dw (1,2,32,8) dstate (1,2,8,8) spread (fd vs forward-mode) = 2.2599327318344914e-12Seven gradients, every coordinate, the state carry active — i.e. exactly
BK2 and the three glue terms. c305ec5 regenerated it (it is in that commit’s
diff, ref_bwd_f64_carry.bin | 60914 -> 60914) and used it for the state
gate only. fused_adjoint_f64.rs::load() hardcodes
include_bytes!("ref_bwd_f64.bin") — the one-chunk file. The oracle for the
ungated scope was committed and never pointed at.
The oracle’s own error is 2.26e-12 relative, so it is not the limiting term at any bar this crate would use.
4. What the “non-determinism” probably is, before it is measured
Section titled “4. What the “non-determinism” probably is, before it is measured”d8fa449’s correction is careful and I do not override it: 7-27% run-to-run
drift on decay.b_alpha / w_up / w_down weakens every FD number attached to
those groups, so “the fused adjoint’s b_alpha gradient is 26% wrong” is not
established. Two readings are open and the tree distinguishes them:
- (a) f32 conditioning.
d8fa449Run 1 already measured the decay path’s gradient at amax ~1e-4 against|L| = 9.9e1— a 3e-6 ratio, so a central difference resolves it to about one digit in f32 (its own words). A quantity that is ~1e-4 of the loss it came from is exactly where an autotuner choice between two legal reduction orders moves 7-27%. This predicts the drift appears in the f32 FD and NOT in an f64 reference, and it predicts the OPS arm is bit-reproducible (measured) because its op order is fixed. - (b) a real race. A missing
sync_cube(), or an uninitialised read. The BK2 kernel writesd_s(chunk_adjoint_cube.rs:365-370) and readsstate_in(:263-273); both are insideif r < cand aresync_cube()-separated, so I have not found one by reading.d_e_lastisTensor::empty(:520) but the kernel is handed the computed one (:568,d_e_last_c2), and theemptybuffer is dropped bylet _ = d_e_last;(:576).
Reading (a) predicts something specific and checkable: the fused adjoint’s decay-path gradient is deterministic in f64 and only moves in f32. That is a one-run experiment once the two-chunk gate exists, and it is the difference between “the kernel races” and “the instrument cannot see this magnitude”.
4b. MACHINE, 2026-10-01 11:45-12:00 — a --tests build is IO-poison on this box
Section titled “4b. MACHINE, 2026-10-01 11:45-12:00 — a --tests build is IO-poison on this box”cargo build --tests for burn-gdn2 + burn-kda is 20 test binaries, and cargo
links them in parallel: 20 rustc → 19 collect2 → 38 ld.mold. Measured
while it ran:
/proc/pressure/io some avg10=99.79 full avg10=86.25/proc/pressure/cpu some avg10=0.00/proc/pressure/memory some avg10=0.00IO full at 86% with CPU and memory at zero. Not a slow build — a
disk-saturated one. ld.mold links at 1.6-3.4% CPU each, i.e. every one of
them is in D-state waiting on the NVMe, and the 20 links are what is making
each other slow. Dirty: 281 MB and flush-8:0 workers in D. The 100k training
run’s own mold-free steady state is unaffected, and free -g never went below
28 GB available — so the RAM discipline in AGENTS.md §2.4 would have called this
box healthy, and it was not. CPU pressure is the signal that was missing.
Consequences, both generalisable:
- Do not build
--tests(or--all-targets) for a multi-binary crate on this box while anything else is running. One target at a time (--test <name>) links exactly one binary. This is the same law as §1.5’s “one heavy thing at a time”, one layer down: the unit is the LINK, not the crate. - A killed mold link leaks. SIGKILL on cargo/rustc leaves the
collect2→ld.moldpair in uninterruptible sleep, reparented to PID 1, and it holds its IO until the queue drains. 19 of mine were still wedged 10 minutes after the build died. Diagnose ownership withls -l /proc/<pid>/cwd(mine pointed at the worktree, which is how I knew they were mine and not another lane’s) — never by process name, because after reparentingppidreads as 1 and the whole tree looks like it belongs to the label of whatever holds the lock.
The build lock did its job throughout: the gtrain lane waited 11 minutes for
mine rather than racing it, and took the lock cleanly when mine died.
Correction, measured 10 minutes later, and it matters more than the rule
above. SIGKILL on ld.mold does not reap it while it is in D-state — the
process survives, holds 9 open fds into the target dir, and the IO queue never
drains, so a new build starves at literally 0 s of CPU (my rustc: 8m23s
elapsed, 00:00:00 CPU) and cannot finish. The leak is self-sustaining: the
thing wedging the disk is the thing that would drain it. Two consequences:
kill -9must be sent told.molddirectly, in a loop, repeatedly — not to cargo or rustc. Killing the parent orphans the link and makes it unreachable by process tree, which is why “which lane owns this?” became unanswerable fromppidand neededls -l /proc/<pid>/cwd.- Repeat the kill. A single pass left 19 alive; they only went away after a
second
kill -9sweep once their parents were gone. IOfullwent 86% -> 53% on that sweep.
The whole episode cost ~25 minutes of a ~2.5 h GPU window, and all of it was
self-inflicted: the --tests build was the trigger, the leaked links were
mine, and the only clean exit was the direct kill sweep. Recorded because the
next lane that reaches for --tests on this box will do exactly the same thing,
and because the symptom (a build that “hangs” with the box apparently idle,
free healthy, load enormous) points at nothing that is actually wrong.
5. The gate this lane adds, and the falsification
Section titled “5. The gate this lane adds, and the falsification”Extend fused_adjoint_f64.rs to run the SAME comparison at one chunk AND at two
chunks, from the two committed fixtures. Nothing new is generated, no tolerance
is moved, GRAD_BAR stays 1e-3 (the f32 side’s noise).
This is the gate the file’s own header asks for and could not have, and it is
one load(bytes) parameterisation plus a second call — the shortest diff that
converts a paragraph into a test. Its scope note has to be rewritten with it,
because “one chunk, so BK1 only” is the sentence that let a 16-chunk
disagreement be filed as a kernel defect.
5a. The oracle is regenerable, and the committed bytes are its output
Section titled “5a. The oracle is regenerable, and the committed bytes are its output”Before trusting a fixture that four lanes have now argued about, regenerate it
and compare (859f350’s lesson: no cargo test can see a fixture disagreeing
with its generator, because the committed bytes ARE what the test compares
against):
python3 tools/gen_bwd_f64.py --out /tmp/... --faults-out /tmp/... --carry-out /tmp/...regenerated vs committed ref_bwd_f64_carry.bin: worst rel 7.478e-16 on db7.5e-16 is f64 round-off — the committed two-chunk oracle is the current
generator’s output, all 18 blocks, and it is reproducible on CPU with no GPU.
The generator also self-checks: it refuses to write unless forward-mode AD and
central finite differences agree, and on the good run it reports
|fd - fwd-mode|max = 2.044e-12.
5b. FALSIFICATION: the gate’s oracle demonstrably moves (tools/falsify_fused_adjoint.sh)
Section titled “5b. FALSIFICATION: the gate’s oracle demonstrably moves (tools/falsify_fused_adjoint.sh)”Injected c305ec5’s bug back into the generator — one identifier,
np.exp(g_last_log - G) -> np.exp(g_last - G), where g_last is already
exp(G_last), i.e. the cumsum exponentiated twice — and regenerated only the
carry fixture:
| block | good vs regenerated | corrupted vs good |
|---|---|---|
out |
1.849e+00 | 2.231e+00 |
out_state |
7.339e+00 | 8.218e+00 |
dq dk dv dg db dw dstate |
2.4e-16 .. 7.5e-16 | 1.000e+00 (all seven) |
loss |
3.009570909726e+00 | 4.424118680193e+00 |
| generator’s own fd-vs-forward-mode | 2.044e-12 | 3.001e+00 |
Three things this establishes, and the third is the one that matters:
- The oracle is not a constant. A double exp in one identifier moves all seven gradients to rel 1.0 and the loss by 47%. So a green run of the two-chunk arm is a statement about this oracle, and the fixture is not carrying a stale function.
- The generator catches its own corruption (
3.001e+00against its ownFD_VS_FWDMODE_BAR = 1e-9) before the fixture is ever written. That is the gate859f350had to add by hand, working from the inside. - The two-chunk arm is the one that sees it. The forward assertion inside
each arm (
fwd < 1e-3) fires on the corrupted fixture at 1.849e+00 before any gradient is compared, and it fires in the arm whose fixture was corrupted. The one-chunk arm, on its own untouched fixture, stays green — which is theT==1fingerprint859f350documents for a head-major layout bug, and the reason a one-chunk-only gate could not have caught this class.
Not yet observed: the CUDA green run of the two-chunk arm. The card is owned
by the 100k production run (step 81800/100000 at 12:10, ~480 ms/step, so
~14:30, not the 12:15 the brief estimated). One ld.mold leak and a
--tests build cost ~25 min of that window; §4b. The generator half of the
falsification above needs no GPU and is done; the test half is the same binary
that already builds clean.
5c. A bug in my own falsification script, worth recording
Section titled “5c. A bug in my own falsification script, worth recording”The first --restore did cp "$GEN.orig" "$GEN" && rm -f "$GEN.orig" — and on
the second --regen the .orig had already been overwritten with the
corrupted text, so the restore restored corruption and deleted the only good
copy. It printed nothing wrong. Restoring from git instead needs no backup
file and cannot fail that way; the script now asserts
git diff --quiet -- gen_bwd_f64.py before continuing. Third instance tonight
of the shape .bulba/memory.md keeps collecting: an instrument that cannot
distinguish success from failure, including one written to prove a gate works.
5c-bis. The two-chunk fixture is not a copy of the one-chunk one, and the carry is LIVE
Section titled “5c-bis. The two-chunk fixture is not a copy of the one-chunk one, and the carry is LIVE”Worth stating because the generator’s own comment carries a trap: SHAPES is a
module-level dict built at import, so changing T alone leaves every tensor at
the old length and the “two-chunk” fixture becomes a byte-identical copy of
the one-chunk one (gen_bwd_f64.py:466-471 says so and rebuilds it). Read off
the committed bytes, not the generator’s intent:
fixture T=32, chunk=16 -> nt=2 chunks |dq| |dk| |dv| |dg| |db| |dw| |dstate|one chunk 6.2621e-1 8.7567e-1 4.8952e-1 4.8440e-1 4.7473e-1 5.6964e-1 9.6643e-1two chunks 7.1506e-1 9.6675e-1 6.4498e-1 6.5037e-1 4.8249e-1 9.1705e-1 1.3806e+0Every one of the seven differs, so this is a different function and not a
resized array. And the BPTT chain is not inert inside it: |dk| on chunk 0 is
9.6675e-1 and on chunk 1 is 8.0853e-1, ratio 0.836 — chunk 1’s dk receives
state-carry contributions that chunk 0’s does not, which is exactly what BK2 and
d_s_shift exist to produce. A fixture where those matched to 3 digits would
pass an adjoint with the whole carry deleted, and this is not that fixture.
5d. Regression: the whole burn-gdn2 CPU cell is green, including the two that were red
Section titled “5d. Regression: the whole burn-gdn2 CPU cell is green, including the two that were red”0e9817b recorded tools/lib_gate.sh RED on burn-gdn2 with two named tests
belonging to “whoever owns burn-gdn2’s f64 oracle”. Both are green on this
branch, and neither is mine:
cargo test -p burn-gdn2 --features binary-tests,autodiff 41 passed / 0 failed across 12 binaries oracle_breadth::gdn2_1000_cases_match_the_f64_oracle ok (was red on 0e9817b) oracle_chunk::chunk_sizes_match_the_f64_oracle ok (was red on 0e9817b) autodiff_bwd_f64 (5) including two_chunks_... ok fused_adjoint_f64 CUDA-gated, not run here859f350 is why: it regenerated ref_f64_broad.bin, the stale fixture that
made the 1000-case sweep red. So the cell’s two reds closed in a commit that is
already on main, and the only thing missing was a run after it.
6. Status of the brief’s items 4 and 5 (the path and the prize)
Section titled “6. Status of the brief’s items 4 and 5 (the path and the prize)”Not started, and deliberately: item 4 (enable the fused path in the trainer)
is downstream of this gate. d8fa449 FINDING 1 measured that on the
trainer’s backend the fused op declines under BalancedCheckpointing (the
any_tracked guard, autodiff.rs:541) and the ops path carries the gradient —
so the trainer is correct today and the fused kernel is simply not on that path.
Enabling a kernel whose inter-chunk adjoint is ungated would put an unverified
gradient into production. Order: gate BK2, then the path, then the launch-count
prize.
Also recorded, because it is a live contradiction a reader will hit: d8fa449
FINDING 3 measured a fused forward launching on the trainer’s backend whose
result is discarded (asked=1 fused_fwd=1 fused_bwd=0 declined=2 ops_path=1 custom_node_bwd=0) — ADR-0019’s third mark. That is a separate defect from the
adjoint’s numerics and it is the one that would matter for launch count, because
today the fused forward is pure overhead.