Skip to content

Carry Muon's optimizer state through universal checkpoints - #8609

Open
alanhuangyoo wants to merge 4 commits into
deepspeedai:masterfrom
alanhuangyoo:fix/ucp-optimizer-state-keys
Open

alanhuangyoo wants to merge 4 commits into
deepspeedai:masterfrom
alanhuangyoo:fix/ucp-optimizer-state-keys

Conversation

@alanhuangyoo

@alanhuangyoo alanhuangyoo commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8608.

Universal checkpoints could not carry Muon's optimizer state at any ZeRO stage. With this they can at all three, including a resume on a different number of ranks.

#8633 is in now, so this is only its own four commits.

What was wrong

  1. The converter and the ZeRO-3 loader read Adam's exp_avg / exp_avg_sq by name, so a Muon checkpoint failed with KeyError: 'exp_avg'.
  2. ZeRO-1/2 keep Muon's momentum whole per parameter, for every parameter a partition touches, because Newton-Schulz needs the whole matrix. That buffer is not shaped like the partition, and a matrix that straddles two partitions is in both ranks' buffers.
  3. ZeRO-3's universal load copied the first param group's hyperparameters onto every group. With Muon that runs the Adam half as a Muon group, so every state came back exactly and the resume still diverged (3.2e-1 on the norm's bias after two steps).

The change

  • The converter and both loaders take the state names from what the optimizer saved, per param group for ZeRO-1/2 and per sub-group for ZeRO-3.
  • ZeRO-1/2 record which states hold each parameter whole (whole_param_optimizer_states in the optimizer checkpoint). The converter writes those per parameter from every rank that holds it and checks the copies agree. The loader rebuilds each rank's buffer in the order of its own partition, so a different dp size works.
  • ZeRO-3 gives each param group back its own hyperparameters.
  • A ZeRO-1/2 Muon checkpoint saved before the layout was recorded is refused. By size alone its buffer can look partition-shaped, and reading it that way writes the wrong rows without failing.
  • momentum_buffer takes scale power -1 for affine-mapped (TP) pieces, like exp_avg.

Testing

2 and 4 H20s, fp32, the same batch on every rank, clipping off. Train 3 steps, save, convert, resume, take 2 more steps, and compare with the run that never stopped. Bit-identical in every case:

  • ZeRO-1, 2 and 3 on the same ranks
  • dp 2→1, 1→2, 2→4 and 4→2 for ZeRO-1/2; 2→4 and 4→1 for ZeRO-3
  • ZeRO-1 ↔ ZeRO-2
  • bf16: ZeRO-2 2→2 and 2→4, ZeRO-1 on 4 → ZeRO-2 on 2

Also checked offload now that #8464 is in. With offload, ZeRO-1/2 keep momentum_buffer per partition like Adam's moments, so it converts the same way. All optimizer states come back bit-identical after loading, for ZeRO-1/2/3 with dp 2→4, 4→2, 2→1 and 1→2, and also with offload switched on or off between save and resume. test_universal_optimizer_states.py covers offload now too.

The new tests/unit/checkpoint/test_universal_optimizer_states.py covers stages 1 to 3 at 2→2 and 2→4, plus the refusal. Undoing each fix makes its test fail: the ZeRO-3 group copy, the ZeRO-1/2 rebuild, and the staging fix from #8633, where the conversion reports the disagreeing copies.

Existing checkpoint tests (test_universal_checkpoint.py in fp32, test_zero_optimizer.py, test_muon_checkpoint.py, test_autotp_uc_checkpoint.py): 169 passed. The one failure, TestRealCheckpointUniversalConversionTPxPP, fails the same way on master here (a Gloo process-group attribute missing in this torch build). These need pytest < 8.4 as pinned in requirements-dev.txt; DistributedFixture does not resolve under pytest 9.

Not covered

  • Converting between ZeRO-1/2 and ZeRO-3. The engine looks for stage-specific model-state files, so this fails for Adam on master too.
  • AutoEP's converter still hard-codes Adam's state names.
  • A universal checkpoint saved without round_robin_gradients and loaded with it diverges, for Adam on master as well. Fixed separately in Link ZeRO-1/2 fragments at their round-robin offsets #8637.

@alanhuangyoo
alanhuangyoo force-pushed the fix/ucp-optimizer-state-keys branch from 75d9b71 to cfd14b5 Compare September 21, 2026 17:22
@alanhuangyoo
alanhuangyoo force-pushed the fix/ucp-optimizer-state-keys branch from cfd14b5 to a9aead5 Compare September 23, 2026 04:41
@alanhuangyoo alanhuangyoo changed the title Follow the optimizer's own state names in universal checkpoints Carry Muon's optimizer state through universal checkpoints Sep 23, 2026
@alanhuangyoo
alanhuangyoo force-pushed the fix/ucp-optimizer-state-keys branch from a9aead5 to 27ea17e Compare September 23, 2026 18:51
Conversion read Adam's `exp_avg` and `exp_avg_sq` by name, so a checkpoint
written by any other optimizer could not be converted: Muon keeps
`momentum_buffer` and nothing else, and `ds_to_universal` raised
`KeyError: 'exp_avg'` before writing anything.

Extraction now takes the names from the param group's own state, the merge
takes them from the fragment files, and the ZeRO-3 loader takes them per
sub-group - per group rather than per checkpoint, because DeepSpeed splits a
Muon run into two param groups and one checkpoint therefore holds
`momentum_buffer` for the matrices and Adam's pair for the rest. The ZeRO-3
loader also takes the step from whichever group saved one, since the Muon
half has none.

A state that is not shaped like the ZeRO partition now stops the conversion
with a message naming it. ZeRO-1/2 give Muon a momentum buffer the size of
the whole group, replicated on every rank, because Newton-Schulz needs the
whole matrix; slicing that by partition offsets would write fragments that
are the right size and hold the wrong rows.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
ZeRO-1/2 keep Muon's momentum whole, per parameter, for every parameter a
partition touches, because Newton-Schulz needs the whole matrix. A matrix
that straddles two partitions is in both ranks' buffers, and the buffer is
not shaped like the partition, so conversion refused it.

The optimizer now records which of its states are laid out that way. The
converter writes each parameter's whole state from every rank that holds
it and checks the copies agree instead of concatenating them. The loader
hands those states to the parameter whole, and ZeRO-1/2 rebuild each
rank's buffer in the order of its own partition, which also makes a
different data-parallel size work.

ZeRO-3's universal load copied the first param group's hyperparameters
onto every group. With Muon that ran the Adam half as a Muon group, so a
resume diverged even though every state came back exactly. Each group now
gets its own.

A ZeRO-1/2 Muon checkpoint saved before the layout was recorded is refused
rather than read by partition offsets.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
@alanhuangyoo
alanhuangyoo force-pushed the fix/ucp-optimizer-state-keys branch from 27ea17e to 9b15e2a Compare September 25, 2026 00:51

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.

Universal checkpoint conversion only knows Adam's state keys, so a Muon checkpoint cannot be converted

1 participant