Initialize training step state in the types the step produces - #668
Open
seanmor5 wants to merge 1 commit into
Open
Initialize training step state in the types the step produces#668seanmor5 wants to merge 1 commit into
seanmor5 wants to merge 1 commit into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #568.
The problem
Axon.Loop.run/4compiles the batch function once, against the initial step state and metrics as templates, and calls that compiled function on every following batch. That only works if the step state is a fixed point of the step in shape and type. On main it is not as soon as anything in the pipeline is wider than f32. TheNx.whileerror from the original report is gone (that code no longer exists), but the same defect now surfaces on the second batch of any f64 run:Three places hardcoded f32 where the step produces something else:
train_step/4seededlosswithNx.tensor(0.0); the step computesloss * i + batch_loss / (i + 1), so with an f64 loss it turns f64 after one step.mu/nupromote to f64 on the first update. Every optimizer with moments (adam, adamw, lamb, radam, rmsprop, yogi, adabelief, sgd with momentum) does this.run/4started every metric accumulator fromNx.tensor(0, type: :f32)(next to a# TODO: Can we infer here?), so a"loss"metric copied from an f64 step state, ormean_absolute_errorover f64 predictions, changed type after the first batch too.The existing integration test "f64 input test" (f64 inputs against an f32 model) fails on main for the same reason and passes with this change.
The design
Rather than special-casing each entry, the step state is initialized in the types the step produces, learned by tracing one step, not computing it.
train_step/4's init already traces the forward pass only to read the prediction's shape and type; this extends the same idea to the whole step. The step body is split out ofstep_fnso init can call it, and init casts its seed state to the traced result:cast_like/2only readsNx.type/1off the traced leaves, so nothing of the gradient expression is referenced by the returned state or lowered; the cost is one extra trace at init. One trace is enough because Nx type promotion is monotone: after one step every accumulator has already merged with the types of everything it merges with.Nx.as_type/2is a no-op where the type already matches, soi, the loss-scale state and every already-correct tensor are untouched.Metrics get the same treatment:
init_metrics/4traces one accumulation from an f32 zero over the initial step state and starts each accumulator fromzeros_likeof that value. Metrics that are read straight from the step state (the trainer's"loss") take the step state's type.Nothing is guessed by hand: the loss type is whatever the loss function returns for the model's output and targets, and optimizer state is whatever the optimizer's update produces. That is what makes this hold for custom losses, custom optimizers and every loss-scale mode without per-case code.
Usage
This now runs to completion with the default
strict?: true; the loss, the Adam moments and both metrics are f64.Tradeoffs and limitations
:running_sumkeep accumulating in f32 as they do today. Only metrics whose values are wider than f32 widen.init_fnmust be valid for the loss.run/4always initializes from a real batch so this is transparent there; two tests that calledinit_fndirectly with placeholder targets of the wrong shape have been updated to pass real ones.Tests
train_step/3with an f64 policy:lossand Adam moments are f64 at init, and the leaf types of the init state equal those of the state after one step, for:identity,:dynamicand:staticloss scales. Fails on main withlossand eight Adam tensors differing.train_step/3with an f16 policy keeps f32 loss and moments, f16 parameters, and is a type fixed point (guards against downcasting).Axon.Loop.runof an f64 policy model with:adamand amean_absolute_errormetric completes two epochs with f64 loss, parameters and metrics. Raises the template error on main.:sgdand:adamand reports an f64 loss (the same failure mode as the existing "f64 input test" integration test, which now passes).The full suite passes,
test/axon/loop_test.exspasses on EXLA, andtest/axon/integration_test.exs --include integrationpasses apart from "image classification test", which times out at 60s on main as well on the default backend.🤖 Generated with Claude Code