Add FSDP2 activation_checkpointing_offload: offload checkpointed layer inputs to pinned CPU memory - #4175
Open
qgallouedec wants to merge 1 commit into
Open
Add FSDP2 activation_checkpointing_offload: offload checkpointed layer inputs to pinned CPU memory#4175qgallouedec wants to merge 1 commit into
activation_checkpointing_offload: offload checkpointed layer inputs to pinned CPU memory#4175qgallouedec wants to merge 1 commit into
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
…r inputs to pinned CPU memory
qgallouedec
force-pushed
the
fsdp2-activation-offload
branch
from
August 20, 2026 19:50
49e3022 to
c4ec07a
Compare
This was referenced Aug 21, 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.
With activation checkpointing, the surviving GPU activation cost is one tensor per layer - the checkpointed layer's input, held in the recompute closure for the whole forward+backward:
num_layers × seq_len × hiddenbytes. At long sequence lengths this dominates: ~39 GB for an 8B model at 131K tokens/rank.This PR adds
fsdp_activation_checkpointing_offload(FSDP2 only): each checkpointed layer's input is copied to pinned host memory after the layer's forward and its GPU storage freed (untyped_storage().resize_(0)); a shim refills the storage just-in-time when the checkpoint recompute calls back into the layer during backward.Why not
saved_tensors_hooksor reentrant checkpointingsaved_tensors_hooksnever sees them, so hook-based offloaders (e.g. torchtune-style) cannot reach the dominant cost.This implementation keeps torch's non-reentrant checkpoint untouched (grad-enabled forward), so gradients are exactly those of plain activation checkpointing.
Correctness
One GPU, one released MoE (OLMoE-1B-7B: 7B total, 1B active, 64 experts), one fixed batch, run once per configuration. A MoE is the sensitive case: each layer records its router logits for the load-balancing loss, so those are captured outside the checkpointed region and are the first thing that would break if the wrapper interfered with the graph.
Identical loss, and
none/ac/ac+offagree on both gradient norms to ~1e-8 relative, which is fp32 recompute rounding. The offload changes nothing about what the backward computes.The reentrant row is the alternative design, and it is not in that band: its router gradient is 0.40% low, five orders of magnitude beyond the noise between the other three, and it deviates five times more on the router than on the total gradient norm. Small, but not rounding.
repro_offload_correctness.pyMeasured
One GPU, the transformer backbone of a released 1.3B MoE (Granite 3.1 1b-a400m), a 32768-token sequence, run with and without the flag. The script prints memory at the end of the forward as well as the peak, plus the size of the checkpoint boundary inputs the wrapper is supposed to move (
num_layers × seq × hidden × 2 bytes), so the saving can be checked against the mechanism rather than taken on faith.The forward keeps 1.54 GB less against a predicted 1.61 GB, so the mechanism does exactly what it says. The peak is unchanged, and that is worth being explicit about: at this length the peak sits in the backward's recompute, not in what survives the forward, so removing the boundary inputs has nothing to bite on. The flag pays off once the boundary inputs are what sets the peak, which is the long-context case: at 1,048,576 tokens with
cp_size=8, Qwen3-0.6B goes from 27.9 GB to 20.8 GB (−25%) for +0.6% step time, and the 7.1 GB saved matches the 7.5 GB of boundary inputs at that length.The short-sequence row also shows the cost: +0.5 s on a 1.4 s step. The copy volume grows with sequence length but so does the compute it hides behind, so the trade only becomes free at long context. Async double-buffering is the natural follow-up for the short-sequence case.
repro_offload_memory.pyAt scale, with everything else identical:
fsdp_offload_paramsas well; no custom codeTogether with parameter offload this is what makes >=8B models fit at 1M-token sequences at all: without it the same configuration OOMs in the ring-attention backward.
Robustness checks
I also ran a few sanity checks on the wrapper's behavior, to make sure it does not pin host memory unnecessarily:
torch.no_grad, evaluation)About checkpointing
The wrapper subclasses torch's
ActivationWrapper, so it inherits the state-dict hooks that hide the wrapper from parameter names: a checkpoint written from a wrapped model has exactly the same keys as an unwrapped one and loads into it.Without that (eg, subclass
nn.Moduledirectly), saving a checkpoint fails withRuntimeError: An unexpected key, model.layers.0._checkpoint_wrapped_module.self_attn.q_proj.weight, exists. Covered by a unit test that compares state-dict keys against an unwrapped model and round-trips aload_state_dict, and verified at scale: a Qwen3-8B checkpoint saved from a 1M-token run contains 399 tensors and zero keys mentioning the wrapper.Notes
fsdp_activation_checkpointing: trueandfsdp_version: 2(validated in the plugin__post_init__).hidden_states) is offloaded, uniquely owned by its layer's closure; rope embeddings/masks are shared across layers and stay resident._checkpoint_wrapped_moduleso wrapped layers keep their own FSDP group.