Skip to content

Add checkpoint/2 to recompute intermediate results in the backward pass - #1865

Open
blasphemetheus wants to merge 23 commits into
elixir-nx:mainfrom
blasphemetheus:feat/checkpoint
Open

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

Conversation

@blasphemetheus

@blasphemetheus blasphemetheus commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Adds checkpoint/2 to Nx.Defn.Kernel. Closes #765.

defn block(x, w1, w2) do
  checkpoint([x, w1, w2], fn x, w1, w2 ->
    x |> Nx.dot(w1) |> Nx.relu() |> Nx.dot(w2)
  end)
end

Reverse-mode differentiation keeps every intermediate of the forward pass until the backward pass has used it. Inside a checkpoint the backward pass instead reconstructs the body's intermediates by running the body again from its inputs, so only the inputs and outputs of the block stay alive across the forward pass. Outside grad a checkpoint is the same as calling the function.

How it works

checkpoint/2 builds an Nx.block carrying a %Nx.Block.Checkpoint{} struct, with the inputs passed as one container argument. The gradient rule for that block:

  1. passes the saved inputs and the incoming gradients through one barrier (a barrier: true metadata node, lowered to an optimization barrier by EXLA),
  2. calls the body again on the barrier's outputs,
  3. differentiates the fresh copy with respect to the block inputs, including any cond inside the body, and feeds the input gradients back into the outer traversal.

The barrier is what makes the memory saving possible. Without it XLA deduplicates the recomputed body against the forward body, and with the inputs alone behind it XLA may schedule the recomputation right after the forward pass. Tying the incoming gradients into the same barrier delays the recomputation until the backward pass reaches the block. JAX's checkpoint works the same way.

The gradient's value does not depend on the compiler. The memory saving does: it needs the compiler to keep the recomputation separate and schedule it late. EXLA does, on GPU and, since XLA 0.11, on CPU.

Limits

  • inputs is a tensor or container, or a list of them for a function of several arguments; the body must return a tensor or a flat tuple of tensors.
  • The body cannot read tensors from the enclosing scope. Capturing them automatically is a possible follow-up once the design is agreed.
  • The body may run more than once, so random numbers inside it need their Nx.Random key passed as an input. There is a test for that.
  • Recomputation is all or nothing per block. Selective policies would be an option on the same function and a field on the same struct.

Tests

nx/test/nx/defn/checkpoint_test.exs covers correctness against the plain function: containers, vectorization, cond, while, custom_grad, shared weights, nested and back-to-back checkpoints, higher-order gradients through nested checkpoints with linear and nonlinear conditionals, duplicated outputs, a random mask from an explicit key, and recomputation counts. exla/test/exla/defn/checkpoint_test.exs checks the compiled gradient keeps the recomputed body, recomputes only what it needs, and lowers peak scratch memory on an MLP.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s

@blasphemetheus

blasphemetheus commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Figured this gives fuller context to some of the decisions in #1862 , the envisioned api would only be possible via macros I believe which will be in a draft PR #1866 can be done with lifting i think

@blasphemetheus

Copy link
Copy Markdown
Contributor Author

If there is a viable path that diverges sharply from how this is implemented, I would close the PRs that diverge and stack PRs from the point that is used

Comment thread nx/lib/nx/defn/grad.ex Outdated
@blasphemetheus

Copy link
Copy Markdown
Contributor Author

to make this one a PR stack it'd need the branch that the PR comes from to be from this repo's branches, not a forks I believe. So to get Github Stacks to work there's a server-side object linking PRs, just doesn't cross the fork boundary

Comment thread exla/test/test_helper.exs Outdated
Comment thread nx/lib/nx/defn/grad.ex Outdated
Comment thread nx/lib/nx/defn/kernel.ex
Comment thread nx/lib/nx/defn/grad.ex Outdated
Comment thread nx/lib/nx/defn/grad.ex Outdated
@blasphemetheus
blasphemetheus marked this pull request as ready for review October 15, 2026 21:47
@blasphemetheus
blasphemetheus marked this pull request as draft October 17, 2026 17:23
@blasphemetheus

Copy link
Copy Markdown
Contributor Author

found a couple instances of weird behavior regarding differentiation, which checkpointing can run into, but isn't caused by this PR stack in either case, I'll make reproducer tests for the weird behavior and leave the original failing tests in this one, can merge after fixes if the fixes are straightforward

@blasphemetheus

Copy link
Copy Markdown
Contributor Author

hmm I force-pushed a bunch of new commits with new names, which is probably less helpful than just pushing new commits on top of all this.

  • b0fe1e7c is the new stuff, touching grad.ex in the checkpoint gradient rule

  • added a test in 3ad0d54b

@blasphemetheus

Copy link
Copy Markdown
Contributor Author

So this PR uses block to pass tensors, like: qr, Take, and TopK do. But. The body is written by the user, so it can read variables from the surrounding defn that weren't passed into the block.

Right now when that happens, there is no indication of it, The forward pass returns an unevaluated expression on the Evaluator, EXLA fails to compile the block, and the gradient of the outside tensor comes out as zeros. The current failing tests should show this behavior.

One way to fix this could be to give a block body it's own context, like the way while has its own context, so mixing in an outside tensor raises at trace time.

@blasphemetheus

Copy link
Copy Markdown
Contributor Author

#1895 seems like it will make this PR less complicated if it is merged, so I'll simplify this one if that direction goes forward

blasphemetheus and others added 20 commits October 26, 2026 14:51
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
A vectorized scale passed next to a plain activation, with the gradient
taken with respect to the scale, and a vectorized constant built inside
defn and passed as a checkpoint input. Both come from the dropped
capture macro branch, rewritten with explicit inputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
When a checkpoint returns {y, y}, both outputs refer to the same
expression ID. Seeding the backward traversal with Map.put kept only
the last output's gradient, dropping the first output's contribution.
For y = x * x and loss = a + 3 * b, x = 2 therefore produced a gradient
of 12 instead of 16. Using only the first output produced zero instead
of 4 because the unused second output overwrote its gradient.

Accumulate incoming gradients for each output expression ID. Add Nx and
EXLA regressions for differently weighted duplicate outputs and for
using either output independently.

Validation:
- Both new Nx tests fail without the fix with incorrect gradients.
- Full Nx suite: 1384 doctests, 1547 tests, 0 failures.
- Formatted changed files; git diff --check passes.
- EXLA checkpoint tests could not start: the local native library needs
  nvshmem_transport_ibrc.so.3, but the environment provides version 5.
  CXX=g++ bypassed an earlier clang/GCC-header compilation mismatch.
State that the backward pass reconstructs the body's intermediates
instead of retaining them, that correctness does not depend on the
compiler while the memory saving does, and that inputs and outputs keep
their lifetime in the surrounding computation. Give a two-layer block as
the placement example, name the flat tuple output and 253 tensor limits,
and say the body may run again so random numbers need an explicit key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
The body runs again in the backward pass, so a mask drawn from a key
passed as an input must come out the same both times. Compare value and
gradient with the plain function under Nx and EXLA and check the mask
is mixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
XLA 0.11 keeps the optimization barrier intact through common
subexpression elimination on the CPU, so the recomputed body survives
there too. The four tests that checked for it no longer need a tag or an
exclusion, and the docs no longer single out the GPU.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
…ting

The backward pass over the recomputed body moves into its own function,
the barrier outputs are unpacked once, and the conds-first traversal is
written as its own call with a pointer to where conds are registered.
The guard against a top-level id also being a tied input is gone: tied
inputs are barrier outputs, so the two sets never overlap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
Drop the advice about parameters being live anyway and the lead-in the
example already shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
The helper that differentiates a recomputed checkpoint body sat between
two update_grads clauses, which the compiler warns about and CI treats
as an error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
to_grad and the checkpoint rule both walked the conds registered under the
module name before walking a variable's own consumers. The two lines now live
in traverse_into with a comment on why conds go first, and the checkpoint rule
differentiates the recomputed body inline with it instead of through a helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
to_grad goes back to its original two traverse_parents calls and the
checkpoint rule repeats them with a comment, so the diff against main only
adds code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpGtcRtwc1jfXLCeqVzs6s
…nputs

The cond gradient rule records gradients under the ids of the variables
it is told to differentiate. The checkpoint rule walked its recomputed
body with the top-level variables, so a cond inside the body differentiated
its branches against variables the body never references and the result
had to be carried over in a second loop. Pass the tied inputs instead,
so cond gradients land under the tied input ids and the existing
hand-back loop attributes them to the original inputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Compare first and second derivatives of nested checkpoints whose bodies
use sin and cos inside an if against the same function without
checkpoints, at inputs where the inner and outer conditionals take the
same branch and where they take different branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… tensor

Checkpoint traces its body as a function of the listed inputs, so a weight
read from the enclosing scope is not available to the body. Today this is
not caught: the Evaluator returns an unevaluated expression from the
forward pass, and the gradient of the captured weight is all zeros.

The two tests state the behavior a user expects and fail with those
symptoms until the block checks for tensors from the enclosing scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nx.block now raises when the body mixes a parameter with a tensor from the
enclosing scope, so the two regressions that showed the forward leak and
the zero gradient become assertions on that error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nx.block accepts containers of tensors, so checkpoint/2 no longer has to
flatten its inputs into positional block arguments, generate a callback
of matching arity and rebuild the containers inside it. The arguments
travel as one tuple, the callback is a plain function, and the 253-input
limit is gone. The gradient rule flattens the inputs to tensors where it
needs ids and positions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
blasphemetheus and others added 2 commits October 26, 2026 15:13
checkpoint/2 traces the body into a block and the gradient rule re-traces
it from the tied inputs, so a test that only changes the arithmetic inside
the body exercises the ops, not the checkpoint. Removed tests and what
already covers them:

- chained forward, many sequential, asymmetric chain, shared function,
  dense layers, several inputs: the 3-layer matmul-relu chain and the
  back-to-back identity checkpoint
- composed tanh then sum, exp/log bitwise, broadcasting, f64, bf16,
  complex, named, reshape, transpose, slice, gather, norm, integer
  forward: the sin and sum-of-squares gradients (assert_equal is exact)
- tuple input, multiple outputs consumed separately: the container
  support describe (map, nested container, tuple outputs, duplicates)
- output larger than input: input shape differs from output shape
- jit compilation: every defn test already runs through jit
- input is the grad target: gradient flows to weights passed as inputs
- forward output used twice: the execution-count test

The three execution-count tests are now one test with three counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@blasphemetheus

Copy link
Copy Markdown
Contributor Author

new stuff is container arguments for Nx.block replacing the arity limit (capped at 253 before)

I also trimmed some of the tests that seemed overlong, specifically the ones that only varied the body, kept all the more structure-based variations

Having outputs be accepted as maps could be a future direction. The issue also mentions selective recomputation policies as a future direction. Neither are tackled in this PR

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

Support checkpoints in gradients

2 participants