Repository navigation
Conversation
With CPU optimizer offload (model.offload = "optimizer", the default, or "full"), AppState.load_state_dict never applied the loaded param groups. The optimizer state tensors are loaded in place through the template that state_dict() hands to DCP, but the template's param groups are copies: Optimizer.state_dict() builds new group dicts, and full offload's checkpoint optimizer copies its groups too. So the saved lr, step, betas, weight_decay, ... were written into the template and never reached the live optimizer. Linear and cosine schedules compute the next lr from the current group["lr"], so a resumed run kept the lr its scheduler set at construction. With a warmup that is 5e-13 instead of 5e-5 until the schedule's next phase starts, which for a warmup-only linear schedule, or a cosine schedule resumed after its warmup, is the rest of the run. Resuming during a decay without warmup restarted the decay near the peak lr instead. With a warmup-only linear schedule, SFT's ckpt.skip_scheduler rebuilt its warmup on top of that lr, so it topped out at 5e-13. dion's Muon keeps its step counter in the param groups, so it restarted at 1 and the AdamW sub-optimizer's bias correction made the first updates of the non-Muon parameters too large (1.66x right after a resume at step 60 in a small run), even with the default constant schedule. After dcp_load, copy every loaded param group entry into the matching live group, except "params" and Muon's "distributed_mesh_name". The latter names a mesh of the current run's layout (whether expert parallelism is on), so the resumed run keeps its own value, as the offload path did before; restoring the saved one would make the first step after resuming an expert-parallel checkpoint without expert parallelism raise a KeyError. The live groups are updated in place, so the full-offload per-chunk references to them stay valid. The non-offload path already restores the groups through Optimizer.load_state_dict and is unchanged. This changes what a resume with offload does when the config differs from the checkpoint: like offload = "none", it now keeps the checkpoint's lr, betas and weight decay, and ckpt.skip_scheduler does not change that. Only ckpt.skip_optimizer, which skips the whole optimizer state, keeps the config values. Add a GPU test that resumes a CPUOffloadOptimizer run in the middle of a warmup through AppState and DCP, and checks that the lr and the next steps match the uninterrupted run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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
With CPU optimizer offload (
model.offload = "optimizer", the default, or"full"), a resume never restored the optimizer's param groups. Those hold the lr, betas, weight decay and, for Muon, the step counter. SFT and RL both resume throughCheckpointManager.load→AppState.load_state_dict, so both were affected.Problem. In the offload branch,
AppState.load_state_dictapplies the model weights and writes back the packed optimizer state, but never touches the param groups. The optimizer state is loaded in place: the template thatstate_dict()hands todcp_loadshares its state tensors with the live optimizer. The param groups are the exception.Optimizer.state_dict()builds new group dicts, andFullCPUOffloadOptimizer.checkpoint_optimizer()copies its groups too. Sodcp_loadwrote the saved values into those copies, and nothing copied them back. The non-offload branch goes throughset_state_dict→Optimizer.load_state_dict, which does restore the groups.What this broke:
LinearLRandCosineAnnealingLRcompute the next lr from the currentgroup["lr"], andLRScheduler.load_state_dictdoes not touch the groups. So a resumed run kept the lr its scheduler set when it was built. With alinearorcosinewarmup andmin_lr = 0, that islr * 1e-8: 5e-13 instead of 5e-5 until the schedule's next phase starts. For a warmup-onlylinearschedule, or acosineschedule resumed after its warmup, that is the rest of the run. Resuming during a decay without warmup restarted the decay near the peak lr instead (cosine: 5e-5 instead of 1e-5). With the defaultconstantschedule the lr symptom does not show as long as the config's lr equals the checkpoint's.linearschedule, SFT'sckpt.skip_schedulerrebuilt its warmup on top of that lr, so it topped out at 5e-13.Muonkeeps its step counter in the param groups. It restarted at 1, so the bias correction of its AdamW sub-optimizer (embeddings, norms,lm_head, ...) made the first updates after a resume too large: 1.66x, 1.39x, 1.27x, ... of the uninterrupted run in a small run resumed at step 60. This happens with the constant schedule too.Fix. After
dcp_load, copy every loaded param group entry into the matching live group, except"params"and Muon's"distributed_mesh_name". Groups are matched by position, the same way DCP fills the template. The live groups come fromself.optimizers, not_get_checkpoint_optimizers(), because full offload's checkpoint optimizer only holds copies. The groups are updated in place, soFullCPUOffloadOptimizer's per-chunk references to them stay valid. The non-offload branch is unchanged.distributed_mesh_namenames the mesh Muon uses for the expert group:"dp_shard_mod_ep"when expert parallelism is on,Nonewhen it is off. It describes the current run's layout, not training progress, and withmodel.ep = "auto"it follows the GPU count. So the resumed run keeps its own value. Restoring the saved one would make the first step after resuming an EP checkpoint without EP raise aKeyError, where the offload path ran before.offload = "none"restores whole groups throughOptimizer.load_state_dict, so it still fails on such a resume, as it does onmain.Behaviour change. With offload, a resume now keeps the checkpoint's lr, betas, weight decay and other group hyperparameters, as
offload = "none"already does. Before, the config's values applied because of this bug. For example, resuming or forking a run (--resume.dir) with a loweroptim.lrused the new lr onmainwith the default offload, and now uses the checkpoint's.ckpt.skip_schedulerdoes not change that, because the rebuilt schedule starts from the restoredlrandinitial_lr. Onlyckpt.skip_optimizer, which skips the whole optimizer state, keeps the config's values. Whether the config should be able to override these on resume is a separate question that affects both paths.Checkpoints written after a resume on an affected version already hold the wrong lr in their param groups (5e-13 after a warmup). This change restores whatever the checkpoint holds.
Added
test_checkpoint_resume_restores_param_groupstotests/unit/train/test_state_offload.py, which is GPU-marked like the rest of that file. It trains aCPUOffloadOptimizerrun 3 steps into a 4-stepLinearLRwarmup, then saves and loads it throughAppStateand DCP. It checks that the lr and the next 2 steps match the uninterrupted run. Full offload is not covered by the test; it is covered by the resume script below and still needs a GPU check.Validation
Rebased on
main(7a630865c, includes #4053); the results below are on the rebased tree, compared withmainat the same commit.No GPU was available. Everything below ran on CPU with torch 2.14.1 (prime-rl pins 2.13.0).
dionandtorchdatawere not installed: dion was imported from its source at the pinned commit (6f9242d), with its Triton module stubbed out (Muon runs withuse_triton=False).torchdata.stateful_dataloader.StatefulDataLoaderwas replaced by a stub, whichckpt.pyonly uses in type annotations. Other packages that are not installed here (triton, fla, wandb, loguru, ...) were replaced by mock modules. The checkpoint and optimizer code checked below does not use any of them.New test, with CUDA emulated on CPU.
.cuda()was a no-op, andCPUOffloadOptimizer's H2D copy was aclone(). Its pinned buffers were unpinned, andtorch.cuda.synchronize()was a no-op. AdamW,LinearLR,AppStateand DCP were the real code.mainit fails withassert 1e-10 == 0.007500000025.Resume script. It used the real
AppState,setup_schedulerand DCP save/load, on a single-rank gloo model with DTensor parameters. It ran three wrappers:CPUOffloadOptimizerwith AdamW;CPUOffloadOptimizerwith dion Muon;FullCPUOffloadOptimizer. Its CUDA-only setup and H2D copy were skipped, and steps ran torch's AdamW on the CPU masters.Each run was saved at step 60 and resumed, and the lr for steps 61-65 was compared with the uninterrupted run. Three schedules were tested:
linearwarmup 50;linearwarmup 50 + decay 50; andcosinewarmup 50.main:offload = "none"matches. Both"optimizer"wrappers and"full"resume at 5e-13 instead of 5e-5 (cosine: instead of 4.95e-5). Muon's group step reads 6 instead of 65 five steps after the resume._get_checkpoint_optimizers()instead fixes"optimizer"but not"full".Longer runs (
CPUOffloadOptimizer, 200 steps). With linear warmup (with and without decay), cosine warmup resumed at step 30 and 60, and cosine or linear decay without warmup resumed at step 140, the lr on this branch matches the uninterrupted run up to step 199. Onmainthe warmup cases recover only where a next phase starts (step 51 for cosine resumed at 30, step 151 for linear warmup + decay) and never for the other two, and the decay-only schedules resume at 5e-5 instead of 1.03e-5 (cosine) and 2.98e-5 (linear).ckpt.skip_schedulerflow, with the schedule rebuilt after the load: onmainwith offload, a warmup-onlylinearschedule tops out at 5e-13; warmup + decay and cosine reach 5e-5 becauseSequentialLRresets the lr toinitial_lr. On this branch every case reaches 5e-5, with and without offload.Multi-rank. A 2-rank FSDP2 run (gloo) with
CPUOffloadOptimizerand a linear warmup, saved after 4 steps and resumed at world size 1, 2 and 4: on this branch the groups and lr (0.004) are restored, and the next 3 steps use the same lr as the uninterrupted run. The parameters after them are equal at world size 2 and within 1.5e-8 at 1 and 4, where the gradient reduction differs. Onmainthe lr after the load is 1e-10.Muon expert group across an EP change (single rank, mesh dims named as with EP on or off). With
CPUOffloadOptimizer, the resumed expert group keeps its owndistributed_mesh_namein both directions, its lr and step are restored, and the next step runs.offload = "none"raises theKeyErrorafter EP on → off, on bothmainand this branch.Changed config on resume (constant schedule; saved with lr 1e-2 and weight decay 0.01, resumed with 1e-3 and 0.1).
main:"none"keeps 1e-2 / 0.01, the default offload takes 1e-3 / 0.1, with or withoutckpt.skip_scheduler. This branch: both modes keep 1e-2 / 0.01, with or withoutckpt.skip_scheduler. Withckpt.skip_optimizer, both modes take 1e-3 / 0.1 on both trees.CPU CI.
test_state_offload.pyis GPU-marked, sopytest tests/unit -m "not gpu"only imports it. Its new top-levelfrom prime_rl.trainer.ckpt import AppStateneedstorchdata, which CI installs withuv sync --all-extras.Lint.
ruff checkandruff format --check(ruff 0.15.14, repo config) andpy_compileare clean on the changed files.Still needs a GPU:
offload = "full"and a warmup schedule. The native CPU AdamW kernel readsgroup["lr"]through the per-chunk group references. Check that the logged lr right after the resume continues from the run before it.offload = "optimizer"under FSDP, including an MoE model resumed with expert parallelism switched on or off.🤖 Generated with Claude Code