Repository navigation
Add checkpoint/2 to recompute intermediate results in the backward pass - #1865
blasphemetheus wants to merge 23 commits into
Conversation
b51ff38 to
b098d86
Compare
|
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 |
b098d86 to
3d7fad7
Compare
|
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 |
3d7fad7 to
291858b
Compare
a06f2c2 to
cf27424
Compare
|
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 |
bb52378 to
3ad0d54
Compare
|
So this PR uses 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 |
facd94e to
edc87a4
Compare
|
#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 |
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>
a11055f to
3c1d9e7
Compare
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>
|
new stuff is container arguments for 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 |
Adds
checkpoint/2toNx.Defn.Kernel. Closes #765.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
grada checkpoint is the same as calling the function.How it works
checkpoint/2builds anNx.blockcarrying a%Nx.Block.Checkpoint{}struct, with the inputs passed as one container argument. The gradient rule for that block:barrier: truemetadata node, lowered to an optimization barrier by EXLA),condinside 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
checkpointworks 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
inputsis 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.Nx.Randomkey passed as an input. There is a test for that.Tests
nx/test/nx/defn/checkpoint_test.exscovers 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.exschecks 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