Add chunked GPU optimizer-state offloading - #6544
Draft
yanring wants to merge 3 commits into
Draft
Conversation
The OptimizerStateOffloader introduced in NVIDIA#2987 currently has no caller: the train-loop wiring (offload after the optimizer step, release after the param-buffer copy, reload during grad finalization) was lost in a main->dev sync merge, so --offload-optimizer-states allocates the offloader and never uses it. Restore that wiring and extend the feature: - Add --offload-optimizer-states-chunk-numel: when > 0, the optimizer step runs in chunks of at most this many local parameter elements (reload one chunk H2D, run the Adam update on it, offload it back D2H) so only one chunk of optimizer state is GPU-resident at a time. The first step initializes states incrementally under the same bound. - With offloading enabled, initialize mcore-managed fp32 master weights directly in pinned CPU buffers instead of materializing them on GPU first, removing the corresponding init-time GPU memory peak. - Offload states before the first checkpoint load when optimizer state is not loaded (--no-load-optim / --finetune), and re-offload after a mid-run optimizer load. - On loss-scale inf/nan step skips, synchronize the pending H2D reload via a new MegatronOptimizer.on_step_skipped hook so a skipped step does not leave async copies in flight. - Call FusedAdam.initialize_state() with store_param_remainders on TE >= 2.1.0.dev0, which requires it as a positional argument. - Extend tests/unit_tests/test_optimizer_state_offloading.py with equivalence, checkpoint round-trip, memory-residency, and step-skip coverage for the chunked and non-chunked offload paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Zijie <zijie@periodiclabs.ai> Signed-off-by: Zijie Yan <zijie@periodiclabs.ai>
TE FusedAdam.state_dict() unscales every state tensor, launching bf16->fp32 kernels on offload-released (zero-storage) states -- an asynchronous illegal memory access that surfaces at the next device sync and can silently corrupt the checkpoint written in between. - Pack the common state dict with torch's base Optimizer.state_dict() when offloading is active; both callers only consume param_groups, and state values for checkpoints come from the per-param read path. - Reload released states before a mid-run load_state_dict rewrites them and before the legacy dp-zero save path writes into them. - Sync pending H2D copies in the offload read gate: an enqueued reload() flips _offloaded off before its copies finish, and callers then fall back to raw GPU reads. Also fixes _clear_h2d_pending() sitting after a return. - Assert loudly on any raw read of a released state outside the read gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Zijie Yan <zijie@periodiclabs.ai>
After the distributed optimizer casts updated master weights back to FP8 and all-gathers them, post_all_gather_processing eagerly (re)creates the columnwise storage for every FP8 parameter. Those transposed copies then sit resident through checkpoint save, inflating its GPU memory peak, even though outside of CUDA graphs TE can create columnwise storage lazily in the next forward pass. Add ddp_config.preserve_fp8_columnwise to make the eager path opt-in: - quantize_param_shard() only requests manual post-all-gather processing when preserving columnwise storage. - The post-all-gather hook skips plain FP8 params when the flag is off; NVFP4 and grouped quantized params keep the eager path unchanged. - megatron/training enables the flag automatically when CUDA graphs are in use (graph capture requires stable storage pointers), and leaves the lazy path on otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Zijie Yan <zijie@periodiclabs.ai>
Contributor
Author
|
cc @hxbai |
6 tasks
6 tasks
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.
What does this PR do ?
Makes optimizer state stop being a GPU-memory problem: Adam moments and
master weights live in pinned CPU memory and only come back to GPU when they
are actually being used. In chunked mode, the optimizer step and the
first-step state init keep roughly one chunk of state resident instead of
all of it, and checkpoint save reads the CPU copies directly without
rematerializing anything on GPU.
The problem
Adam state is one of the largest GPU-memory consumers in training, and it is
only touched during the optimizer step. The rest of the time it sits there,
taking memory away from activations, which is exactly what long-context and
memory-heavy post-training runs are short of. The lifecycle also has hidden
spikes: state init materializes everything at once, and checkpoint save used
to bring every offloaded tensor back to GPU, so runs can OOM at the first
save even when the steady state fits.
Megatron has two mechanisms in this space today, and neither covers this:
--optimizer-cpu-offload(HybridDeviceOptimizer) moves the Adam computeitself to CPU. It saves the memory but pays with a much slower step.
--offload-optimizer-states([Dev] [Reapply] Optimizer State and Master Weight Offloading #2987) had the right idea: keep Adam on GPUand move only the storage to CPU between steps. But its training-loop
wiring was lost in a later main-to-dev sync merge, so today the flag builds
an offloader that nothing ever calls (grep for
offload_states()callers:there are none).
What this PR does
Revives #2987 and finishes the idea: optimizer state lives on CPU and GPU
residency is bounded at every point where the implementation can bound it.
Two places still temporarily materialize the full state on GPU: the
unchunked step (chunk-numel 0, the restored #2987 behavior, which reloads
everything before stepping) and an optimizer checkpoint load, which fills
the GPU tensors before the load path re-offloads them.
moments and master weights copy D2H on a side stream and their GPU storage
is freed. The H2D reload for the next step is kicked off during grad
finalization, so it overlaps the grad reduce-scatter. Adam itself still
runs on GPU at full speed; only the residency moves.
--offload-optimizer-states-chunk-numel N).Full reload still needs the whole state on GPU during the step. In chunked
mode the step walks groups of whole local parameter shards packed up to
roughly N elements: reload one chunk, run FusedAdam on it, offload it
back, move on. Chunks split only between parameters, so a single shard
larger than N forms its own chunk and the effective residency bound is
max(N, largest local shard). The lazy first-step state init follows the
same bound, so even step 1 never holds the full state.
mcore-managed master weights, the fp32 masters are built directly in
pinned CPU buffers instead of being materialized on GPU first; TE-managed
optimizer state is initialized lazily chunk by chunk during the first
step. Unchunked offload keeps [Dev] [Reapply] Optimizer State and Master Weight Offloading #2987's behavior (build on GPU, then
offload).
offloaded CPU copies directly instead of reloading everything to GPU.
While making that work we hit an actual illegal memory access: TE
FusedAdam.state_dict()unscales every state tensor, which launcheskernels on freed (zero-storage) tensors; the async IMA surfaces at the
next device sync and can silently corrupt the checkpoint being written.
Save now packs the common state dict with the base
torch.optim.Optimizer.state_dict(), per-param values go through a readgate that returns the CPU copies, and any raw read of released storage
asserts loudly instead of corrupting data.
optimizer load writes into them and re-offloaded afterwards;
--no-load-optim/--finetuneruns offload before the first checkpointload.
shards back to FP8 per chunk under
--fp8-param-gather, FP8 load targetscan stage on CPU to avoid a full-model dequantization peak, and a new
ddp_config.preserve_fp8_columnwiseswitch stops the eagerly recreatedcolumnwise copies from sitting resident through checkpoint save: outside
CUDA graphs TE creates them lazily in the next forward instead. The eager
path stays on automatically under CUDA graphs, which need stable storage
pointers. Note this changes the default for non-CUDA-graph FP8 runs from
eager to lazy.
H2D copies through a new
MegatronOptimizer.on_step_skipped()hook insteadof leaving them pending into the next iteration.
Numerics are unchanged by design: state dtypes, the fp32 grad buffer, and
the Adam math are all untouched. Offloading only changes where the bytes
live between uses, which is also why the equivalence checks below can demand
exact matches rather than tolerances.
Constraints
--optimizer-cpu-offload(pick one strategy),--async-save(save readsthe offloaded CPU copies; an async writer would race the next step's
offloads overwriting those same buffers),
--optimizer-cuda-graph(acaptured step would replay on freed storage pointers), and
--rl-offload-optimizer-during-inference(its raw optimizer reads wouldtouch released storage). Chunked mode additionally rejects
--fp4-param-gatherand--reuse-grad-buf-for-mxfp8-param-ag.the CPU bytes as-is, and TE stores fp16/fp8 states scaled.
fp8/ds-fp8 param handling),
main_params_dtypemust be fp32 and--store-param-remaindersmust be off, for the same byte-exact-savereason.
Cost
The D2H/H2D traffic overlaps grad reduce-scatter and next-step compute in
full-reload mode. Chunked mode serializes per-chunk copies with the chunk
updates, so the optimizer step gets somewhat slower in exchange for the
residency bound; we run production with chunk sizes around 1e9 elements
where this cost is small relative to a training step.
Validation
This is the offload path we run in production training. Correctness there is
gated by an equivalence harness: a no-offload baseline and an offload run
with identical seed, data, and topology, FP8 param gather enabled, required
to produce exactly equal per-step loss and grad norm, while the offload
run's peak allocated memory must come in strictly below the baseline (an
offload that silently no-ops fails the harness). Checkpoint save/load/resume
is exercised the same way.
In this repo,
tests/unit_tests/test_optimizer_state_offloading.pyisextended from the #2987 suite (all 6 original cases kept) with
chunked/unchunked equivalence, checkpoint save/load round-trip, memory
residency, FP8 blockwise, and step-skip coverage: 24 passed / 2 skipped
(both by-design opt-outs) on 1 node x 8 GPUs, torch 2.11 + TE 2.17; enabling
the env-gated FP8 integration test gives 25 passed / 1 skipped. black /
isort / ruff pass with the repo configs.
Issue tracking
Linked issue:
Pre-checks