Skip to content

Pass tensors read from the enclosing scope into checkpoint automatically - #1866

Closed
blasphemetheus wants to merge 15 commits into
elixir-nx:mainfrom
blasphemetheus:feat/checkpoint-capture
Closed

blasphemetheus wants to merge 15 commits into
elixir-nx:mainfrom
blasphemetheus:feat/checkpoint-capture

Conversation

@blasphemetheus

Copy link
Copy Markdown
Contributor

Turns checkpoint/2 into a macro that passes tensors read from the enclosing scope into the checkpoint, and adds checkpoint/1 for a body that takes everything from scope.

defn layer(x, w, b) do
  checkpoint(x, fn x -> Nx.dot(x, w) + b end)
end

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 in inputs. checkpoint/1 raises at compile time for that case.

Nx.Block.Checkpoint gains a saved field 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.exs under "captured tensors":

  • captured weights, a captured map of parameters, and checkpoint/1 compute the same gradient as the plain function
  • a captured tensor that is the grad target, and one that is not
  • the body may rebind a name from the enclosing scope
  • indexing a captured variable passes it whole, checked on the expression tree
  • checkpoint/1 with a non-inline function raises at compile time
  • captured vectorized tensors keep their axes in the forward pass and in the gradient, including a vectorized constant built inside the function

Two tests in exla/test/exla/defn/checkpoint_test.exs check 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, the saved field and its use in the gradient rule, and the new tests, as reviewed on the fork draft blasphemetheus#25. Full nx suite and the EXLA checkpoint tests on CUDA pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s

blasphemetheus and others added 15 commits October 9, 2026 14:54
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
@blasphemetheus

Copy link
Copy Markdown
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.

Comment thread nx/lib/nx/defn/kernel.ex
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__)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@blasphemetheus

Copy link
Copy Markdown
Contributor Author

closing this one as diverging and not the right way

Sign up for free to 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.

2 participants