Skip to content

fix(lora): persist distributed optimizer parameter state - #2516

Open
Arist12 wants to merge 4 commits into
radixark:mainfrom
Arist12:fix/lora-checkpoint-optimizer-parameter-state
Open

fix(lora): persist distributed optimizer parameter state#2516
Arist12 wants to merge 4 commits into
radixark:mainfrom
Arist12:fix/lora-checkpoint-optimizer-parameter-state

Conversation

@Arist12

@Arist12 Arist12 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Part of #2705.

Problem

DistributedOptimizer shards the FP32 master weights and the Adam moments across the data-parallel group and deliberately keeps them out of state_dict(); they are saved separately through save_parameter_state / load_parameter_state_from_dp_zero. The LoRA checkpoint path only wrote optimizer.state_dict(), so a resumed run restored the param-group metadata but rebuilt the master weights from the base model — the loaded adapter is then wiped by the first optimizer step, and the moments restart at zero.

Change

save_lora_checkpoint also writes optimizer_param_state_rank{rank}_optimizer{i}.pt for every non-stub DistributedOptimizer child, and load_lora_adapter restores it. Both Megatron calls are collective (a gather / a scatter over the DP group), so the surrounding logic keeps every rank on the same branch:

  • the training-state torch.save result is agreed on before the parameter-state gather, so a rank that fails to write cannot leave its peers in the gather;
  • the "are all shards present?" verdict is a gloo consensus, and the read failures are agreed on before the first scatter;
  • on the DP root, the checkpoint's numel_unpadded is compared against the grad buffers before any scatter. load_parameter_state_from_dp_zero asserts the same thing between two scatters, where a DP-root failure hangs the rest of the group instead of raising.

Checkpoints written before this change have no parameter-state files at all: they log a warning and reload_model_params(), which at least seeds the masters from the adapter that was just loaded. A partially present set is an error rather than a silent warm start.

The parameter state is read with weights_only=True; the training state still needs full unpickling for its param-group metadata.

Validation

  • 66 focused fast tests (test_lora_optimizer_checkpoint.py, test_lora_utils.py); full tests/fast/backends/megatron_utils green apart from two TestUpdateWeightsZeroChunks failures that reproduce on main.
  • 4x MI350X two-stage resume: adapter weights stay finite and the optimizer continues stepping instead of restarting.

Notes for reviewers

  • Children are selected with isinstance(child, DistributedOptimizer) rather than args.use_distributed_optimizer, which keeps load_lora_adapter's signature unchanged. Only DistributedOptimizer (and ChainedOptimizer, which is never a child) defines save_parameter_state.
  • Stubs are skipped, matching DistributedOptimizer.load_parameter_state, which early-returns on is_stub_optimizer.
  • Follow-ups deliberately left out to keep this PR to one problem: --no-save-optim / --no-load-optim / --finetune are not honoured by the LoRA checkpoint path at all (pre-existing, and fixing it changes the behaviour of training_state_rank*.pt); optimizer.state_dict() still raises when a chain has stub children; and _all_ranks_true / _raise_if_any_rank_failed duplicate helpers added by fix(lora): deduplicate native adapter checkpoints #2669 in lora_checkpoint.py, so whichever lands second should reuse that module.

@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch 6 times, most recently from 6997c74 to de6fb63 Compare August 17, 2026 04:34
@Arist12
Arist12 marked this pull request as ready for review August 17, 2026 04:35

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@Arist12
Arist12 marked this pull request as draft August 17, 2026 06:47
@Arist12
Arist12 marked this pull request as ready for review August 19, 2026 01:19

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@Arist12
Arist12 marked this pull request as draft August 19, 2026 01:44
@Arist12 Arist12 changed the title fix(lora): persist the distributed optimizer's parameter state in LoRA checkpoints fix(lora): persist distributed optimizer parameter state Aug 19, 2026
@Arist12
Arist12 marked this pull request as ready for review August 20, 2026 15:42

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch from ce1b28d to d651c3c Compare August 21, 2026 18:27
@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch from d651c3c to 12f7112 Compare August 21, 2026 18:58
Registering a new 900s 4-GPU E2E is a CI-budget decision that belongs with
the reviewer, not with an optimizer-state fix. The save/load helpers are
already covered by the fast tests in this PR, and exact resume was validated
by hand on 8x MI350X.

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the current head (55dc933d6c9070940f854a88f9111f018d98853c) and exercised the implementation with a real two-rank Megatron distributed Adam optimizer on AMD Instinct MI355X GPUs.

The happy path restored model-side adapter tensors, fp32 master weights, first/second moments, optimizer metadata, and iteration exactly. The following failure paths still need work.

Blocker — parameter-state structure is not validated before DP scatter.

_load_optimizer_param_state coordinates only torch.load. With a valid pickle containing an invalid state structure, DP rank 0 raised KeyError while checking numel_unpadded, while rank 1 had already entered scatter and failed after the peer disconnected. This was reproduced with the real Megatron DistributedOptimizer, not a mock.

DP roots need a non-mutating structural/numel preflight, followed by global consensus, before any rank calls load_parameter_state_from_dp_zero.

The final rebase must preserve #2733 before any mutation.

#2516 conflicts exactly at the native-load site and its side contains the permissive if name in state_dict: copy_ loop. The combined flow must be:

  1. every rank reads its selected native shard;
  2. strict key/shape/dtype/finiteness validation runs locally;
  3. ranks reach global error consensus;
  4. only then copy model parameters and reload optimizer masters;
  5. preflight and restore optimizer/scheduler state.

Taking #2516's conflict side would silently revert #2733. Its new positional args parameter also requires updating #2733/#2669 tests and callers.

Distinguish legacy warm start from a torn current checkpoint.

_optimizer_param_state_is_available maps both “no new-format files exist” and “only a subset exists” to the same weight-only warm start. The former is valid legacy compatibility; the latter is a damaged current checkpoint and should fail rather than silently discard optimizer, scheduler, and step state.

Enforce topology compatibility.

Adapter files become topology-keyed after #2669, while training/optimizer files remain keyed by global rank. Persist world/TP/PP/EP/DP topology metadata and refuse exact restore when it differs; otherwise the same world size with a different layout can pair correct adapter shards with another coordinate's optimizer state.

Coordinate each remaining mutation boundary.

Scheduler loading is currently unguarded after optimizer mutation. Likewise, optimizer child save failures should be checked after each child so all ranks stop in lockstep, rather than waiting until every child has run. The parameter-state payload also round-trips with weights_only=True, map_location="cpu"; use that safer load mode where possible.

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review on the unchanged head (55dc933d6c9070940f854a88f9111f018d98853c). These findings are new relative to the first review.

Blocker — rank-local checkpoint generations and iterations are never compared.

Each rank independently loads training_state_rank{rank}.pt, and the only consensus covers whether torch.load raised. There is no equality check for checkpoint generation, iteration, or scheduler position. I reproduced this with two valid files: rank 0 contained iteration 10 and rank 1 iteration 11; _load_training_state returned [10, 11] without an error.

This can arise from an interrupted overwrite because native, training-state, and optimizer-state files are published independently with no generation manifest. Persist one generation ID plus the expected shard/file set, write a completion marker only after all required state succeeds, and require all ranks to agree on generation and iteration before any model/optimizer mutation.

--no-load-optim can leave the LR/WD scheduler at step zero.

The no-load branch returns the saved nonzero iteration without restoring opt_param_scheduler. Later, model.py skips opt_param_scheduler.step(iteration * global_batch_size) whenever use_checkpoint_opt_param_scheduler is true and iteration is nonzero, assuming the checkpoint restored scheduler state. In this path that assumption is false, so training resumes at the old iteration with a fresh scheduler.

Return whether scheduler state was actually restored, and reposition it from iteration whenever it was not.

Security — no-load paths still perform unrestricted pickle deserialization.

training_state_rank*.pt is loaded with weights_only=False before checking optimizer is None or args.no_load_optim. A user-supplied adapter directory can therefore execute pickle payloads even when optimizer loading was explicitly disabled. The real distributed-Adam training-state payload produced by this PR successfully round-tripped with weights_only=True, map_location="cpu" in local validation.

Use safe deserialization for the supported state schema, or split iteration/scheduler metadata from any payload that genuinely requires trusted pickle loading.

Validate supported process-group and optimizer-child layouts up front.

The legacy distributed-optimizer parameter-state API requires Megatron DP Gloo subgroups. Miles passes args.enable_gloo_process_groups into optimizer construction, so --disable-gloo-process-groups currently fails only at the first LoRA save interval. Reject this incompatible combination during argument validation rather than failing after training has begun.

The active-child compatibility guard is also one-directional: checkpoints written with optimizer_children are checked, but a checkpoint written while all children were active uses the optimizer key and bypasses the guard when the current run has a stub child. Record active child indices unconditionally and compare them in both directions before calling load_state_dict.

…cope

Drop the optimizer-metadata, resume-flag and adapter-read changes, which fix
different bugs, and select DistributedOptimizer children by type so the load
path keeps its signature. Read the parameter state with weights_only and check
it against the grad buffers on the DP root, before the scatter that would
otherwise hang the group on a mismatch.
@Arist12

Arist12 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this review led to a substantial rewrite. The PR is now narrowed to one problem: DistributedOptimizer keeps master weights and Adam moments out of state_dict(), so a LoRA checkpoint that saves only state_dict() silently resumes from base-model masters.

Adopted — the DP-root preflight. Confirmed against Megatron: load_parameter_state_from_dp_zero asserts numel_unpadded between two scatters (distrib_optimizer.py:2369-2375), so a mismatch raising on the DP root hangs the rest of the group. The shape check now runs before any collective, and its failure is agreed on across ranks.

Adopted — weights_only=True for the parameter state. Verified it round-trips the (dtype, dtype)-keyed payload; the weights_only=False load is now confined to the training state, which genuinely holds non-tensor objects, with a comment saying so.

Adopted — distinguish "legacy checkpoint" from "torn checkpoint". All shards present → restore. None present → warn and reload_model_params() (warm-start from the adapter). Anything in between → raise, rather than silently warm-starting from a partially written checkpoint.

Adopted — collectives must not sit below a per-rank early return. Re-reviewing, _load_training_state returned early on a per-rank state_path.exists() above the newly-added collectives, and the torch.load / load_state_dict were uncoordinated. Both now go through the consensus helper.

Not adopted — guarding the scheduler load. Nothing collective runs after it, so a rank that skips it cannot strand anyone. Adding a consensus there is cost with no failure mode behind it.

Not adopted — per-child consensus around the parameter-state save. Stub-ness is rank-dependent, so a global all-reduce per child would itself deadlock whenever the ranks disagree about how many non-stub children they have. The save loop records the first error and reaches one consensus at the end instead.

Not adopted — data_parallel_group_gloo. Megatron's own load_parameter_state uses data_parallel_group (distrib_optimizer.py:2624); matching it is the conservative choice.

Deferred (listed in the PR body). --no-save-optim / --no-load-optim / --finetune handling, topology metadata in the checkpoint, and cross-rank agreement on iteration/generation. All are separate behaviours; folding them in was what made the earlier revision hard to review. Also stripped: the checkpoint.py change and the args plumbing — isinstance(child, DistributedOptimizer) answers the same question locally, so the load path no longer needs args at all.

Note on stacking: the failure-consensus helpers here duplicate lora_checkpoint.py from #2669. Whichever lands second should delete its copy.

Validation: 4 focused tests exercising round-trip through the DP root, the legacy warm-start, the torn-shard rejection, and the pre-scatter shape rejection; tests/fast/backends/megatron_utils — 263 passed, plus the two TestUpdateWeightsZeroChunks failures that reproduce on main. pre-commit clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants