[Optimizer] HybridDeviceOptimizer resumes from a checkpoint without a second set of pinned copies, and exactly - #5
Open
tigist-far wants to merge 2 commits into
Conversation
…es and resumes exactly The post-load hook rebuilt the sub-optimizers (_init_sub_optimizers): a fresh pinned CPU copy of every parameter and, at the next step, fresh pinned gradient buffers, while the loaded state's master_param entries kept the previous copies alive. A resume therefore held two full sets of pinned copies and buffers; on a 120B model with the optimizer on the host that was ~500 GB per node that never came back, and the run died at its next save. The hook now keeps the sub-optimizers, copies and buffers and only re-syncs the parameter groups and the state. Two more faults on the same path: _update_fp32_params_by_new_state indexed param_to_fp32_param for every state entry and raised KeyError for a parameter without an fp32 clone (natively fp32 parameters, e.g. the distributed optimizer's shards); and the pre-load hook only swapped parameters with fp32 clones, so torch cast the loaded state of an offloaded parameter to the GPU before the post-load hook moved it back to the host, ~90 GB per rank of transient device memory. Both hooks now work on param_to_inner_param / inner_param_to_orig_param (fp32 clone, pinned CPU copy, or the parameter itself), and the sync copies a loaded master_param into the live inner parameter and re-points the entry at it. Test: a resumed HybridDeviceOptimizer keeps its inner parameters, sub-optimizers and gradient-buffer map by identity, keeps the state on the inner parameters' devices, and continues bit-for-bit like the optimizer it was saved from, for fp32 and bf16 parameters at offload fractions 0.5 and 1.0. On the previous code the fp32 case raised KeyError and the bf16 case rebuilt the copies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nter across a checkpoint TransformerEngine's FusedAdam stores the Adam step in param_group["step"], not in per-parameter state, so it was in neither state_dict()["state"] nor the HybridDeviceOptimizer's own param groups and every load restarted the GPU half's bias correction at step 1 (the exactness test of the previous commit showed the GPU-resident parameters diverging by ~7e-4 after one post-load step while the offloaded ones were bit-exact). After each step the sub-optimizers' group steps are mirrored into the HDO's param groups, which the checkpoint already carries, and _sync_hdo_param_groups_to_sub_optimizers hands them back after a load. Checkpoints without the key load as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Summary
Resuming a run that keeps its optimizer on the host (
optimizer_cpu_offload) from a checkpoint with optimizer state failed three ways inHybridDeviceOptimizer'sload_state_dictpath, all on the same code path and all found on the hosted 2-node Nemotron 3 Super SFT run on 2026-09-09:hybrid_optimizer.pyKeyError: tensor(...)in the post-load hook, before the first step_update_fp32_params_by_new_stateindexedparam_to_fp32_paramfor every state entry; under the distributed optimizer the parameters are fp32 shards, which have no fp32 clone_init_sub_optimizers): a fresh pinned CPU copy of every parameter and fresh pinned gradient buffers, while the loaded state'smaster_paramentries kept the previous copies aliveThe fix keeps the sub-optimizers, their pinned copies and gradient buffers across a load and only re-syncs parameter groups and state; both hooks swap through
param_to_inner_param/inner_param_to_orig_param(an fp32 clone, a pinned CPU copy, or the parameter itself) so the loaded state is cast on the device it belongs to; and the state sync copies a loadedmaster_paraminto the live inner parameter and re-points the entry at it.A second, smaller commit fixes a fourth gap the exactness test exposed: TransformerEngine's FusedAdam keeps its Adam step in
param_group["step"], which the hybrid optimizer neither saved nor restored, so after any load the GPU-resident half restarted its bias correction at step 1 (parameters diverged by ~7e-4 after one step while the offloaded half was bit-exact). The sub-optimizers' group steps are now mirrored into the HDO's own param groups, which the checkpoint already carries, and handed back after a load.dummy_step, the step path and the checkpoint layout are otherwise untouched; checkpoints without the new key load as before.How a load runs now
sequenceDiagram participant DO as DistributedOptimizer.load_state_dict participant T as torch Optimizer.load_state_dict participant H as HybridDeviceOptimizer hooks DO->>H: pre-load hook Note over H: swap every group param for its inner param<br/>(fp32 clone / pinned CPU copy / itself) H->>T: cast state to the inner params' dtype and device Note over T: offloaded state stays on the CPU T->>H: post-load hook Note over H: swap back to the original params<br/>keep sub-optimizers, copies, gradient buffers<br/>re-sync param groups and state H->>H: copy a loaded master_param into the live inner param, re-point the entryBefore this change the post-load hook's last step was
_init_sub_optimizers(), i.e. the right-hand column of the table above.Areas changed
megatron/core/optimizer/cpu_offloading/hybrid_optimizer.py_update_fp32_params_by_new_statesyncs every inner parameter frommaster_paramand tolerates parameters without a clone; the sub-optimizers' per-groupstep(FusedAdam keeps it there) is mirrored into the HDO's param groups after each step so it survives a checkpointtests/unit_tests/test_optimizer_cpu_offloading.pytest_load_state_dict_reuses_the_cpu_copies_and_resumes_exactly: afterload_state_dict, inner parameters, sub-optimizers and the gradient-buffer map are the same objects; state tensors live on the inner parameters' devices; two further steps match the saved-from optimizer bit for bit — fp32 and bf16 parameters, offload fractions 0.5 and 1.0. On the previous code the fp32 cases raise the KeyError and the bf16 cases fail the identity checksMeasurements behind the change
From the hosted run (16 ranks, TP 8 × CP 2 × EP 8, optimizer fully offloaded, pods limited to 3020 GB):
The extra ~500 GB per pod is 8 ranks × (31 GB of parameter copies + 31 GB of gradient buffers), i.e. one full set; the sampler saw pinned memory climb 1141 → 1640 GB across the load phase. With the fix the resumed run's floor returns to the cold start's.
Testing
Run on flamingo through megatron-bridge-internal's
make test-shell-remote(imagemegatron-bridge:7f31fe20f, 2 × H100, this branch rsynced over the vendored3rdparty/Megatron-LM, torch 2.13, TE 2.16):test_load_state_dict_reuses_the_cpu_copies_and_resumes_exactly, this branchhybrid_optimizer.py(mutation check)KeyErrorfor the fp32 cases, the identity assertions for the bf16 casestests/unit_tests/test_optimizer_cpu_offloading.py, whole moduletests/unit_tests/training/test_checkpointing.pyThe fork's pre-commit hooks (black 26.3.0, pylint 3.2.6, isort 5.13.2) pass on both files.
On the hosted run the runtime equivalent of the first commit (nemotron
hybrid_optimizer_resume, applied after the load) brought the resumed run's pinned host memory from 1640 GB back to 894 GB per pod.