Skip to content

feat(layers): migrate cached-for-backward intermediates to SaveForBackward / live-input recompute (ADR 006, T2.3) - #848

Merged
dndungu merged 7 commits into
mainfrom
feat/save-for-backward-migration
Jun 11, 2026
Merged

feat(layers): migrate cached-for-backward intermediates to SaveForBackward / live-input recompute (ADR 006, T2.3)#848
dndungu merged 7 commits into
mainfrom
feat/save-for-backward-migration

Conversation

@dndungu

Copy link
Copy Markdown
Contributor

Implements plan task T2.3 of docs/plan-gpu-training-hardening.md: a field-based audit of every graph.Node / layer Backward in zerfoo for struct fields written during Forward and read during Backward, plus migration of the training-critical set to the ztensor ADR 006 contract (graph.SaverAware / SaveForBackward, landed in zerfoo/ztensor#132 @ 1cc7793) or to live-inputs ... recompute.

Refs #847, zerfoo/ztensor#128.

Why

Nodes caching forward intermediates in struct fields and reading them in Backward are corrupted by GPU arena reuse before Backward runs (#842 LayerNorm variance, #845, the Wolf QK-norm cached inverse). The audit is AST-based (receiver fields assigned in Forward ∩ read in Backward, following same-receiver helper calls), not comment-based.

Audit method

A go/ast walker over every non-test .go file: for each type with pointer-receiver ForwardandBackward, intersect {fields assigned in Forward} with {fields read in Backward} (transitively through same-receiver method calls). 45 types flagged; every one is classified below.

Audit table

Classification: SAFE = host Go value, no device/arena storage. RECOMPUTE = derived from the live inputs ... Backward receives (preferred for cheap intermediates; the #842 LayerNorm pattern). SAVE = expensive to recompute; registered via SaveForBackward (node keeps the Go handle, the contract owns the memory lifetime).

Migrated in this PR (training-critical path: core, activations, normalization, regularization, attention, transpose, reducesum)

TypeCached field(s)ClassResolution
activations.BaseActivation (ReLU/Tanh/Erf/…)lastInputRECOMPUTEcache removed; Backward derives from inputs[0]
activations.GelulastInputRECOMPUTEcache removed; Backward derives from inputs[0]; OutputShape from host []int
activations.LeakyReLUlastInputRECOMPUTEcache removed; Backward derives from inputs[0]
activations.SigmoidlastOutput (lastInput was dead)SAVESaverAware; output registered each Forward; dead lastInput removed
activations.SoftmaxoutputSAVESaverAware; softmax output registered each Forward
activations.SwiGLUlastInput; gate, siluX1RECOMPUTE + SAVEre-splits live inputs[0]; gate/silu registered; Saver fans into composed Sigmoid
normalization.LayerNormalizationmean, variance, normedInput, inputShapeRECOMPUTEcompletes #842: the generic engine-op path (f64/non-mixed) now recomputes stats from inputs[0] like the mixed f32 path; caches removed
normalization.RMSNorminputTensor, rmsRECOMPUTEnew rmsRecomputeStats helper; also fixes fused-GPU forward leaving rsqrt nil
normalization.SimplifiedLayerNormalizationinvStdDev, normalizedInputRECOMPUTEsame helper; backward-before-forward now legal
normalization.BatchNormalizationnormalized, std, scaleBCSAVEBackward only receives X (not scale/mean/var) so stats are not recomputable from its live inputs; SaverAware + register all three
regularization.DropoutmaskSAVErandom ⇒ not recomputable; SaverAware + register (training mode only)
regularization.FeatureDropoutmaskSAVEsame
attention.ScaledDotProductAttentionq, k, v, attentionWeightsSAVEnot itself a graph.Node (owners call Backward with nil q/k/v); gains SetSaver; owners fan their Saver in so saves attribute to the owning node
attention.AttentionHead— (composite)SAVE (fan-out)SaverAware; fans into SDPA
attention.GroupedQueryAttentionattnOutputFinalSAVESaverAware; registers attnOutputFinal (read by wo backward) + fans into SDPA
attention.GlobalAttention, attention.LocalAttention— (wrap GQA)SAVE (fan-out)SaverAware; fan into GQA
attention.MultiHeadLatentAttentioncachedCKV, cachedAttnOutSAVESaverAware; registers both + fans into SDPA
attention.FusedSDPAhadMaskSAFEhost bool; no change
core.FFNinputTensor; w1Output, w3Output, swiGLUOutputRECOMPUTE + SAVEinput from live inputs[0] (signature previously ignored them); matmul outputs registered; Saver fans into composed SwiGLU
core.FiLMlastFeature, lastScale (lastBias dead)RECOMPUTE + SAVEfeature from inputs[0]; generated scale registered; dead caches removed
core.PolynomialExpansionlastInputRECOMPUTEcache removed; previously preferred the stale cache over live inputs — order inverted and cache deleted
core.TemporalConvEncoderlastConv1Out, lastConv2Out, lastPooledSAVESaverAware + register
core.VariableSelectionlastHidden, lastWeights (lastInput dead)SAVESaverAware + register; dead cache removed
core.RotaryEmbeddinginnerSAFElazily-built Go struct handle (its internal cos/sin tables are the embeddings FOLLOW-UP below)
transpose.TransposepermSAFEhost []int lazily defaulted in Forward
reducesum.ReduceSumSAFEno Forward-written/Backward-read fields (already live-input based)
functional.GELUBackward(wrapper)RECOMPUTEpassed the live input to Gelu.Backward instead of forward-seeding the removed cache (also removes a wasted forward per call)
core.Linear/Bias/Add/MatMul/Dense/GemmSAFEno cached intermediates; Backward already uses live inputs
timeseries engines (timeseries/*, layers/timeseries/*)SAFEbackward helpers are pure functions of explicit arguments; audit flagged no Forward-written/Backward-read fields

FOLLOW-UP (classified, not migrated here; tracked under #847)

TypeCached field(s)Class
core.MatMulNBitsdequantizedWeights, cacheValidSAVE* — persistent cross-step cache; per-backward pinning does not cover it (contract gap, see below)
core.MixtureOfExpertscachedAssignments/ExpertOuts/GateWeight/HiddenStates/Indices/WeightsSAVE
core.MoEGatecachedGateWeight/HiddenStates/Indices/Probs (cachedNumExperts SAFE int)SAVE
embeddings.RotaryPositionalEmbeddingcosAngles, sinAnglesRECOMPUTE (deterministic tables) or SAVE
embeddings.TokenEmbeddinginputTokenIDsRECOMPUTE (live inputs)
hrm.HModule, hrm.LModulefwdCombinedInput (shape/bool fields SAFE)SAVE
recurrent.SimpleRNNlastInput (RECOMPUTE); hiddenStatecross-step recurrent state — same contract gap as MatMulNBits
ssm.BCNormcachedInput, cachedNormRECOMPUTE + SAVE
ssm.MambaBlock / ssm.MIMOMambaBlock / ssm.ComplexSSMState13–14 cached tensors eachSAVE (large set)
transformer.BlockfwdInput (RECOMPUTE); fwdNorm1Out/fwdNorm2Out/fwdPostAttn/fwdResidual1SAVE
training/fp8.FP8LinearlastInputRECOMPUTE
training/lora.LoraLinearlastAxSAVE
training/loss.MSE/BCELoss/CorrLoss/RoutingContrastivepredictions, targets, scoresRECOMPUTE (live inputs)
training/loss.CrossEntropyLosspredictions, targets (RECOMPUTE); softmaxOutputSAVE
training/nas.DARTSLayerlastInput (RECOMPUTE); opOutputs, weightsSAVE

Counts: 45 types flagged ⇒ 27 resolved in this PR (11 RECOMPUTE migrations, 15 SAVE registrations/fan-outs, plus SAFE documentation), 18 FOLLOW-UP types classified above. Re-running the audit on the migrated tree shows the prioritized packages contain only SAVE-registered or SAFE fields.

Wiring

ztensor Builder.Build (1cc7793) calls SetSaver on every SaverAware node, so anything constructed through the graph builder (zerfoo inference/arch_*, Wolf's CrossAsset graph) is wired automatically. Composite layers fan the Saver into children (SwiGLU→Sigmoid, FFN→SwiGLU, AttentionHead/GQA/MLA→SDPA, Global/Local→GQA) so child saves attribute to the parent node and release when the parent's Backward returns. Nodes used outside a Graph tolerate a nil Saver (no-op), keeping CPU behavior identical.

Regression tests

  • SAVE:TestDropout_SaveForBackward_RegistersMask / TestFeatureDropout_… (mask registered in training mode, nothing in eval), TestSoftmax_SaveForBackward_RegistersOutput, TestSwiGLU_SaveForBackward_RegistersGateAndSilu — stub graph.Saver recording exactly which tensors are registered.
  • RECOMPUTE:TestGelu_Backward_ReadsLiveInputs, TestLayerNorm_MixedBackward_ReadsLiveInput (f32), TestLayerNorm_GenericBackward_ReadsLiveInput (f64, the path newly migrated here) — the input buffer is overwritten in place between Forward and Backward (exactly what arena reuse does) and gradients must match a fresh layer that only ever saw the new values.
  • Obsolete cache-dependency tests updated: backward-before-forward now legal for recompute layers (RMSNorm, SimplifiedLN).

Gates

  • go build ./...
  • go vet ./...
  • gofmt ✓ on all touched files (pre-existing drift in untouched files left alone)
  • go test -race -count=1 ✓ on all changed packages (layers/activations, layers/normalization, layers/regularization, layers/attention, layers/core, layers/functional)
  • go test ./... -count=1 full suite ✓ except cmd/benchTestBenchHarness, which is pre-existing flaky (fails identically with -count=5 on an untouched main checkout; timing-based ThroughputTs > 0 assertion)

Contract gaps found (feeding ztensor#132 open questions)

  1. Cross-step caches (MatMulNBits.dequantizedWeights, SimpleRNN.hiddenState): the contract releases pins when the node's Backward returns, but these caches must survive across steps until invalidated. Needs either a pin-until-invalidate API or a guarantee that such caches are allocated outside the arena. This is related to ztensor#132 open question 2 (realloc between save and backward).
  2. MarkStepBoundary release hook (ztensor#132 open question 3): not needed by any layer migrated here — next-Forward + end-of-Backward release covers every zerfoo training/inference loop encountered.
  3. Direct-Backward callers: several zerfoo wrappers called Backward without passing inputs, relying on caches (functional.GELUBackward, FFN tests). The graph always passes live inputs, but a lint (vet check or contract doc) that Backward implementations must not depend on Forward-time tensor state would prevent regressions; the gradcheck-under-poison harness (T1.2/S2.3.1) is the runtime backstop.

Not done here

  • DGX/GPU poison-mode run (S2.3.1) — separate Spark task per plan.
  • FOLLOW-UP migrations listed above (ssm/hrm/transformer/loss/lora/nas/MoE/embeddings).

Pulls graph.Saver/graph.SaverAware and the arena Pin/Unpin lifecycle
(zerfoo/ztensor#132, SHA 1cc7793f9acc) needed for the T2.3 migration.
Refs #847, zerfoo/ztensor#128.
…intermediates (ADR 006, T2.3)
- BaseActivation, Gelu, LeakyReLU: Backward recomputes the derivative
from the live inputs the graph passes in; the lastInput struct-field
cache is removed (arena could reuse it before Backward, #842
bug class).
- Sigmoid, Softmax, SwiGLU: implement graph.SaverAware; the cached
forward outputs consumed by Backward (sigmoid output, softmax output,
gate and silu(x1)) are registered via SaveForBackward so arena-backed
storage stays pinned until Backward. SwiGLU re-splits the live input
instead of caching it; the Saver fans out to the composed Sigmoid.
- Regression tests: stub-Saver SAVE assertions for Softmax and SwiGLU;
live-input perturbation RECOMPUTE proof for Gelu.
Refs #847, zerfoo/ztensor#128.
…Norm SaveForBackward (ADR 006, T2.3)
- LayerNormalization: the generic engine-op Backward (used for f64 /
non-mixed types) now recomputes mean/variance/normedInput from the
live inputs, completing the #842 fix that previously covered
only the mixed-precision f32 path. The mean/variance/normedInput
struct-field caches are removed.
- RMSNorm, SimplifiedLayerNormalization: Backward recomputes the RMS
statistics from live inputs via the new rmsRecomputeStats helper;
inputTensor/rms/invStdDev/normalizedInput caches removed. This also
fixes the fused-GPU forward path that could leave rsqrt nil.
- BatchNormalization: Backward only receives X (not scale/mean/var),
so its normalized/std/scaleBC caches cannot be recomputed from live
inputs; the layer now implements graph.SaverAware and registers them
via SaveForBackward.
- Regression tests: live-input perturbation proofs for both LayerNorm
backward paths; cache-dependency tests updated to the new semantics
(backward-before-forward now succeeds for recompute layers).
Refs #847, zerfoo/ztensor#128.
… 006, T2.3)
Dropout and FeatureDropout masks are random and cannot be recomputed in
Backward, so they are the canonical SAVE case: both layers implement
graph.SaverAware and register the mask every training-mode Forward,
pinning arena-backed storage until Backward consumes it. Stub-Saver
regression tests assert the mask is registered in training mode and
nothing is registered in eval mode.
Refs #847, zerfoo/ztensor#128.
…d (ADR 006, T2.3)
ScaledDotProductAttention is not itself a graph.Node (owning nodes call
its Backward with nil q/k/v), so its cached q/k/v and attention weights
are SAVE-class intermediates: SDPA gains SetSaver and registers them
during Forward. AttentionHead, GroupedQueryAttention, GlobalAttention,
LocalAttention, and MultiHeadLatentAttention implement graph.SaverAware
and fan the graph-provided Saver into their composed SDPA/GQA; GQA
additionally saves attnOutputFinal (read by wo backward) and MLA saves
its compressed-KV latent and pre-wO attention output.
FusedSDPA's hadMask is a host bool (SAFE, no change).
Refs #847, zerfoo/ztensor#128.
…SN/Polynomial (ADR 006, T2.3)
- FFN: Backward reads the original input from the live inputs the graph
passes in (inputTensor cache removed); w1Output/w3Output/swiGLUOutput
are matmul outputs (expensive) and are registered via SaveForBackward.
The Saver fans into the composed SwiGLU.
- FiLM: the feature tensor is read from live inputs; the generated scale
(a Dense forward output) is registered via SaveForBackward; the unused
lastFeature/lastBias caches are removed.
- TemporalConvEncoder: conv1/conv2/pooled outputs registered via
SaveForBackward.
- VariableSelection: hidden activation and selection weights registered
via SaveForBackward; unread lastInput cache removed.
- PolynomialExpansion: Backward now requires and reads the live input;
the stale-cache-first preference (cache over live inputs) is removed.
MatMulNBits (persistent dequantized-weight cache), MixtureOfExperts and
MoEGate are classified as FOLLOW-UP in the T2.3 audit table.
Refs #847, zerfoo/ztensor#128.
…seeding the cache (ADR 006, T2.3)
Gelu's Backward now recomputes from the live inputs it receives; the
forward-seed step is unnecessary and the cached-input path no longer
exists. This also removes a wasted full GELU forward per backward call.
Refs #847, zerfoo/ztensor#128.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@dndungu