Add training loop fusion - #658
Open
seanmor5 wants to merge 1 commit into
Open
Conversation
Closes #101. By default, `Axon.Loop.run/4` dispatches one computation per batch. Every iteration crosses the boundary between the BEAM and the compiler's runtime, and the step state crosses back with it. For small models that dispatch costs more than the training step itself. `fuse: true` stacks a chunk of batches, moves it to the device at once, and steps through it in a device-side `while` loop which carries the step state and the metrics, so the loop synchronizes with the host once per chunk instead of once per batch. The same bytes cross to the device either way. `:fuse` also takes the chunk size, which trades device memory for dispatch amortization, and defaults to 32. Both paths run the same batch function, so a fused and an unfused loop compute the same thing. Measured on EXLA CPU with a batch size of 32 and 100 batches per epoch, excluding compilation: model params unfused fused speedup mlp-tiny 1.1K 266us/it 68us/it 3.9x mlp-small 25K 306us/it 115us/it 2.7x mlp-large 1.2M 1052us/it 770us/it 1.4x Iteration events cannot fire from inside the computation: a host callback per iteration measures at roughly twice the dispatch it would save. Instead the metrics of every iteration in a chunk are recorded into a device-side buffer, and `:iteration_started` and `:iteration_completed` fire on the host once the chunk is done, in the same order the unfused loop fires them, with real counters and the metrics as of their iteration. Their `step_state` is `nil`, since it only exists on the device while a chunk runs. Epoch level handlers are unaffected. Halting from an iteration handler therefore takes effect at the end of its chunk; `fuse: 1` halts exactly, as does halting from `:epoch_completed`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
polvalente
approved these changes
Aug 16, 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 #101.
By default,
Axon.Loop.run/4dispatches one computation per batch. Every iteration crosses the boundary between the BEAM and the compiler's runtime, and the step state crosses back with it. For small models that dispatch costs more than the training step itself.fuse: truestacks a chunk of batches, moves it to the device at once, and steps through it in a device-sidewhileloop which carries the step state and the metrics, so the loop synchronizes with the host once per chunk instead of once per batch:The same number of bytes crosses to the device either way, they just cross in one transfer instead of one per batch.
:fusealso takes a positive integer chunk size, which trades device memory for dispatch amortization, and defaults to 32.Both paths run the same batch function, so a fused and an unfused loop compute the same thing — the tests assert that directly on parameters, metrics, and event counts.
Results
bench/loop_fusion.exs, EXLA CPU, batch size 32, 100 batches/epoch, steady state (compilation excluded):Chunk size sweep for mlp-small:
1→ 0.99x,4→ 1.23x,8→ 1.51x,16→ 2.02x,32→ 2.55x,64→ 2.67x.Why the data is device resident
The first design pulled each batch from the host inside the loop with
Nx.runtime_call/4, which works (including insidewhile, on EXLA:hostand:cuda), but measures at roughly twice the cost of the dispatch it removes. Timing the three shapes of the same loop on a small model:So the fused path uses no host callbacks at all.
Callbacks and hooks
:started,:epoch_started,:epoch_completed,:epoch_halted,:haltedand:completedare unaffected: host side, between epochs, complete loop state.:iteration_startedand:iteration_completedfire on the host once the chunk containing their iteration is done, in the same order the unfused loop fires them, with real counters and the metrics as of their iteration, recorded into a device-side buffer during the chunk. Theirstep_stateisnil, since it only exists on the device while a chunk runs; metrics and handler metadata they return are kept, a returnedstep_stateis discarded.fuse: 1halts on the exact iteration, as does halting from:epoch_completed.A
trainer(log: 5)loop withvalidate/4andearly_stop/3produces byte-identical log output and identical parameters fused and unfused.Limitations
Nx.stack/2.jit_compile?: true, which is the default.Tests
test/axon/loop/fused_test.exs— 19 tests, most of which assert the fused loop matches the unfused one exactly (parameters, metrics, event counts, halting paths), plus chunk padding, data exhaustion,:iterationsbounds, batch consumption, and stream halting. Suite is green under bothNx.Defn.EvaluatorandUSE_EXLA=1.🤖 Generated with Claude Code