Optionally keep gradients in the training step state - #661
Open
seanmor5 wants to merge 2 commits into
Open
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guards the init_fn tracing the backward pass for the :gradients template: with bf16 parameters the gradients are f32, so zeros of the parameter type would not match the step output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
polvalente
reviewed
Aug 24, 2026
|
|
||
| %{ | ||
| step_state = %{ | ||
| i: Nx.tensor(0), |
Member
There was a problem hiding this comment.
Suggested change
| i: Nx.tensor(0), | |
| i: Nx.u64(0), |
polvalente
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #577.
Axon.Loop.train_step/4computes the gradients of every batch and then drops them once the optimizer has consumed them. Nothing downstream of the step can see them, so there is no way for a metric or an event handler to report a gradient norm, spot layers whose gradients have vanished, or log the raw gradients while debugging a model that does not train. The usual workaround is to write a custom step function, which means giving uptrainer/4, loss scaling, and the rest of the supervised loop.Design
train_step/4andtrainer/4gain akeep_gradients?option (defaultfalse). When it is set, the step state carries one extra key,:gradients, holding a container with exactly the structure ofAxon.ModelState.trainable_parameters/1: nested string-keyed maps of tensors, frozen parameters excluded. The value is the gradient of the loss with respect to each trainable parameter for the batch that was just processed, after loss-scale unscaling and before any optimizer transformation, which is exactly what the optimizer'supdate_fnreceives.init_fninitializes the key to zeros of the same shapes and types.When the option is off, the step state is byte-for-byte what it was before: the key is absent rather than
nil, so existing pattern matches, checkpoints, and compiled-function templates are unaffected.The metric transform has to be the list
[:gradients]rather than the bare atom, because a list of fields is whatmetric/5applies as the argument list of the metric function. The docs call this out.Why the init traces the backward pass
Axon.Loop.run/4compiles the batch function once, with the step state returned byinit_fnas the template, and then feeds each step's output back in. Strict compilation requires the:gradientsentry the init produces to have the same shapes and types as the one the step produces.zeros_like(trainable_parameters)is not a safe template:Nx.Defn.Gradseeds the backward pass with an f32 constant and never casts back to the parameter type, and the static and dynamic loss scales unscale by multiplying with an f32 scalar, so for bf16 or f16 parameters the gradient type can differ from the parameter type. Instead the init does what it already does fory_pred: it traces the computation (herevalue_and_gradplusunscale_grads) and reads only shape and type off the result withzeros_like/1. The traced expression is never referenced by the returned state, so it is never lowered. The cost is a little extra Elixir-side tracing at init time, only when the option is on.Tradeoffs and limitations
Axon.Loop.checkpoint/2serialize the whole step state, so they grow by the size of the trainable parameters when the option is on.:gradientsis not added to the donatable step-state keys: the step never reads the previous gradients back, so donating them would be pointless.donate_state?: truekeeps working with the option on.%Axon.ModelState{}withfrozen_parametersset byAxon.ModelState.freeze/2loses that field when passed back through a model'sinit_fn, becausemerge_model_state!/2inAxon.Compileronly merges:data. That is pre-existing onmainand not touched here; the test freezes from inside a{init_fn, apply_fn}model tuple so the frozen state actually reaches the step.Tests
test/axon/loop_test.exsgains adescribe "keep_gradients?"block::gradientskey, before and after a step;sgd(learning_rate: 0.1), the kept gradient equals both the analytic gradient (5wformean((w * x)^2)withx = [1, 2]) and(w - w') / lr, so it is exactly what the optimizer received; the init template is zeros with the parameter's shape and type, and the init template and step output have identical shapes and types for every leaf;dense_0frozen, onlydense_1gradients are kept anddense_0is unchanged by the step;:staticand:dynamicloss scaling match those under:identity, and in each case the init template matches the step output;init_fn(zeros of the parameter type would fail this);trainer/4with the option exposes the gradients to a[:gradients]metric and to an:iteration_completedhandler through a fullAxon.Loop.run/4, which exercises the strict compilation path;donate_state?: truewith the option on converges to the same model state and gradients as the non-donating run, and the returned state is not donatable.All six substantive tests fail on
mainwithout the library change.mix testpasses on the default backend (883 tests) andUSE_EXLA=1 mix test test/axon/loop_test.exspasses as well, including theexla_onlybuffer-donation tests.🤖 Generated with Claude Code