Repository navigation
Pass tensors read from the enclosing scope into checkpoint automatically - #1866
Closed
blasphemetheus wants to merge 15 commits into
Closed
blasphemetheus wants to merge 15 commits into
blasphemetheus wants to merge 15 commits into
Conversation
A block node stored its arguments as the vectorized tensors it was called with, while every other node stores devectorized ones. Under jit the Evaluator then evaluated an argument against the wrong shape: a block over a vectorized parameter returned a single value for the whole batch, and a block over a vectorized intermediate raised. Eager execution was unaffected because it never goes through the node. The node now stores devectorized arguments and results. The body is traced on the vectorized view its author wrote it for, the result is revectorized on the way out, and when any argument is vectorized the callback is wrapped the same way for backends that invoke it themselves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Follow the repository convention of calling vectorized axes vectorized_axes, keep devectorized arguments under their own name instead of shadowing args, bind the vectorized parameters before the apply, and compute the tensor count once in the wrapper. Generate wrapper clauses up to 253 arguments. The wrapper takes the struct and captures the body, and both count toward Erlang's limit of 255 function arguments. Co-authored-by: Paulo Valente <16843419+polvalente@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
A tensor captured from outside the function becomes a constant node, and the Evaluator returned it as stored, with its vectorized axes, while the node and everything around it are typed devectorized. Operators tolerate that because they go back through Nx, but the vectorized block wrapper does not: it raised when vectorizing an already vectorized value, and the gradient of a block over such a tensor raised before this change too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
A barrier takes a container of tensors and returns the same container. Its value is the identity, but compilers keep the computations on either side of it apart instead of merging them. The Evaluator passes the tensors through. EXLA lowers the node to an optimization barrier, which has that meaning in XLA. The node stores devectorized tensors like every other node and revectorizes its outputs, so it works on vectorized inputs under jit and under grad. Gradients pass through it unchanged. This is groundwork for gradient checkpointing, which needs to recompute a segment of the forward pass during the backward pass without the compiler folding the recomputation back into the original. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Follow the repository convention of calling vectorized axes vectorized_axes and keep the devectorized tensors under their own name instead of shadowing tensors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Name the node and its outputs, restore vectorized axes with Nx.vectorize directly since empty axes are a no-op, and comment each step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
The node's tensors are its inputs, the lambda accumulator no longer shadows grads, and the gradients are always a list because the node has tuple type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
# Conflicts: # nx/lib/nx/defn/expr.ex
Reverse-mode differentiation keeps every intermediate result of the forward pass until the backward pass has used it, so the memory of grad grows with the depth of the function. checkpoint(inputs, fun) runs fun on its inputs and marks the intermediates as not worth keeping: the forward pass keeps only the inputs, and the backward pass runs fun again from them when it needs the intermediates. Each checkpoint costs one extra evaluation of its body and changes no computed value. The checkpoint is an Nx.block with the Nx.Block.Checkpoint struct, so the forward pass compiles like any other block and the Evaluator runs the body once. The gradient rule differentiates the block when its incoming gradient is known. It passes the inputs and that gradient through one barrier, recomputes the body from the barrier's outputs, differentiates the recomputation and sums the gradient of every input. Tying the gradient to the inputs is what keeps the compiler from merging the recomputation back into the forward pass, which would hold the intermediates and undo the saving. Every tensor the body uses must be passed in inputs. Containers are flattened to their tensor leaves and rebuilt inside the block. The EXLA tests tagged :rematerialization check the compiled program: the recomputed body survives optimization and the peak scratch memory of a four-checkpoint MLP gradient drops by at least one activation. They run on the GPU client only, because the XLA version EXLA pins expands optimization barriers before common subexpression elimination on the CPU. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
…kpoint The body must return a tensor or a tuple of tensors, as a block body must; a container result now raises with that message instead of a CaseClauseError from inside the block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
The cost analysis shows one extra exponential per element in the checkpointed gradient: the gradient of sin needs exp(x) again, and the recomputed sin feeds nothing, so the compiler removes it. The memory test uses a wider batch so that one activation is far larger than the matrix multiplication workspaces the autotuner may pick, which vary from run to run under load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
checkpoint/2 becomes a macro. When the function is written inline, the variables its body reads from the enclosing scope are appended as extra block inputs, so weights and other parameters no longer have to be listed. checkpoint/1 covers a body that takes everything from scope. The struct records which inputs the checkpoint stands in for in `saved`. Only those are tied to the incoming gradient through the barrier; the captured tensors are read as they are, since parameters are alive for the whole program anyway. With no explicit inputs every tensor is saved. Tests cover captured weights and parameter maps, a captured tensor that is or is not a grad target, a body rebinding a name from the enclosing scope, checkpoint/1, indexing a captured variable, the compile-time error for a non-inline function, and captured vectorized tensors and constants, under the Evaluator and under EXLA. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Contributor
Author
|
wew, ok that's the last of the stacked PRs for the version that I've made so far. This one is to get the passing a function to work correctly and adds tests for that api. |
josevalim
reviewed
Oct 11, 2026
| When `fun` is not written inline, such as `&layer/3`, nothing is read | ||
| from the enclosing scope and every tensor must be passed in `inputs`. | ||
| """ | ||
| defmacro checkpoint(inputs, fun), do: checkpoint_macro(inputs, fun, __CALLER__) |
Contributor
There was a problem hiding this comment.
I don't think this is a good design for a macro because it makes me think this is doing to work:
callback = fn x -> Nx.dot(x, w) + b end
checkpoint(x, callback)
But it won't. Ideally we don't rely on macros here. Perhaps we can reuse the block API and either add an option or use Expr.metadata to annotate the block with checkpoint information.
Contributor
Author
|
closing this one as diverging and not the right way |
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.
Turns
checkpoint/2into a macro that passes tensors read from the enclosing scope into the checkpoint, and addscheckpoint/1for a body that takes everything from scope.Before this change every tensor the body used had to appear in
inputs, so each layer of a model listed its weights twice: once as an input and once as a parameter of the function. Now only the activation is listed. The gradient of a captured tensor flows through the checkpoint like that of any other input.How it works
The macro looks at the inline
fn, collects the variables its body reads that are bound in the caller and not by the function head, and appends them as extra parameters of the function and extra inputs of the block. A variable the body rebinds under a name from the caller is collected too; that only adds an unused input. A body that is not written inline, such as&layer/3, cannot be inspected, so nothing is captured and every tensor must be passed ininputs.checkpoint/1raises at compile time for that case.Nx.Block.Checkpointgains asavedfield listing the positions of the explicit inputs. The gradient rule ties only those to the incoming gradient through the barrier. Captured tensors are read directly, because they are usually parameters that are alive for the whole program, so tying them buys nothing. With no explicit inputs every tensor is saved.Why captures are read directly
The barrier in the gradient rule exists to keep the recomputation from being scheduled early or merged with the forward pass. For that it is enough that the activations and the incoming gradient go through it; the recomputation cannot start until both are available. Pushing the weights through the same barrier would add nothing, and keeping them out leaves the compiler free to place their reads wherever it wants.
Tests
The existing tests pass unchanged, since a function with no captures compiles to the same block as before. New tests in
nx/test/nx/defn/checkpoint_test.exsunder "captured tensors":checkpoint/1compute the same gradient as the plain functioncheckpoint/1with a non-inline function raises at compile timeTwo tests in
exla/test/exla/defn/checkpoint_test.exscheck a captured weight and a captured vectorized tensor under EXLA.Stacked on #1865, which stacks on #1860 and #1862. The diff shows all of them until they merge; the change itself is the macro in
Nx.Defn.Kernel, thesavedfield and its use in the gradient rule, and the new tests, as reviewed on the fork draft blasphemetheus#25. Fullnxsuite and the EXLA checkpoint tests on CUDA pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s