Skip to content

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

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

Arist12 wants to merge 14 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

LoRA checkpoints save and restore only optimizer.state_dict(). Megatron's DistributedOptimizer keeps the FP32 master weights and Adam moments in its parameter state instead, so a resume trains from uninitialized moments, and loading the adapter leaves the masters at their pre-load values. With expert-only LoRA, the stub child in the ChainedOptimizer makes the save itself raise.

Fix

  • Save each non-stub distributed child's parameter state and restore it together with the state_dict. A checkpoint without it (saved by main or before the first step) resumes with a fresh optimizer; a partial one is rejected.
  • Refresh the master weights after loading adapter weights.
  • Hide stub children from Megatron's chained state_dict / load_state_dict.

Tests

  • tests/fast/backends/megatron_utils: 292 passed (286 on main).
  • 2×MI355X, tiny MoE GPT with and without a stub child: masters and moments resume bit-exactly. On main the save raises with a stub, and without one 18546/58884 params are NaN after one resumed step.
  • 4×MI355X, Qwen2.5-0.5B LoRA resumed from iter_0000002/adapter: trains to the end. On main the second resumed step has a NaN grad norm.

Overlaps #2661 in load_lora_adapter; the master refresh must stay ahead of its weight-only return.

@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 2 times, most recently from d651c3c to 12f7112 Compare August 21, 2026 18:58

@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.

@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.

@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch 2 times, most recently from ae2a445 to 8e1587c Compare September 1, 2026 21:10
@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch 4 times, most recently from ad58f15 to f4ac9dc Compare September 11, 2026 20:42
DistributedOptimizer keeps its fp32 master weights and Adam moments out of
state_dict() -- Megatron saves them separately via save_parameter_state -- so a
LoRA checkpoint that stored only state_dict() resumed with neither.

Save them per data-parallel root and restore them through the dp-zero scatter,
coordinating rank-local I/O failures so no rank enters a collective its peers
skipped, and checking the buffer sizes before the scatter rather than inside it.
Checkpoints written before this warm-start instead of failing.

Also refresh the masters in load_lora_adapter whenever adapter weights are
written. They are snapshotted at optimizer construction, which happens before
the load, so without this the first step() copies the construction-time values
back over the restored adapter -- the same reason the Inkling path already
calls reload_model_params.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch from f4ac9dc to f2af2cb Compare September 11, 2026 21:27
@yushengsu-thu yushengsu-thu self-assigned this Sep 14, 2026
@Arist12
Arist12 force-pushed the fix/lora-checkpoint-optimizer-parameter-state branch 3 times, most recently from f972fa3 to 3706ef5 Compare September 22, 2026 00:04
Arist12 and others added 11 commits September 22, 2026 00:05
# Conflicts:
#	miles/backends/megatron_utils/lora/utils.py
…nt_dir requires

A rank whose adapter-shard write failed left save_parameter_state's
data-parallel gather unmatched, so the save hung instead of raising.
…ep; gather first

get_parameter_state_dp_zero raises KeyError('exp_avg') on an optimizer that has
never stepped, so a save before the first step failed. With no parameter state,
resume keeps a fresh optimizer, which is what an unstepped one is.
@XinyuJiangCMU

Copy link
Copy Markdown
Contributor

Verified that LoRA checkpoints now save and restore the previously missing FP32 master weights and Adam moments, with stub optimizers correctly skipped.

This branch has not been deployed

No deployments
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.

4 participants