Skip to content

[muon] Reconcile the momentum dtype when a checkpoint is restored - #8433

Open
alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/muon-momentum-dtype-on-resume
Open

[muon] Reconcile the momentum dtype when a checkpoint is restored#8433
alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/muon-momentum-dtype-on-resume

Conversation

@alanhuangyoo

@alanhuangyoo alanhuangyoo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #7746.

Problem

Resuming a Muon run under ZeRO 1/2 raises as soon as the first gradient arrives:

RuntimeError: expected dtype torch.float32 for `end`, but got dtype torch.bfloat16

ZeRO 1/2 keep Muon's momentum in a hand-flattened tensor under optimizer.state[flatten_copy]["momentum_buffer"], allocated in the gradient accumulation dtype. A checkpoint stores optimizer state in fp32, so a restored buffer comes back in fp32 while the gradients it is combined with are not, and muon_update's momentum.lerp_(grad) requires the two to match.

get_flat_partition only asked whether a buffer existed:

if "momentum_buffer" not in self.optimizer.state[flatten_copy] and ...:
    self.optimizer.state[flatten_copy]["momentum_buffer"] = ...

so it left the restored one alone. Traced across a resume:

fresh run          momentum torch.bfloat16
after initialize   no buffer yet
after load         momentum torch.float32     <- crashes on the next step

Fix

The buffer is converted to the dtype it is about to be combined with. Discarding and reallocating it would also stop the crash, but the momentum a resume just restored is the reason the checkpoint carries it — starting from zero is a different run, and Muon's update is built on that momentum.

Verification

2 × H20, ZeRO 1/2/3, an uninterrupted run against one interrupted at the halfway point and resumed in a fresh engine:

stage master this branch
1 RuntimeError momentum restored (0.003303 → 0.003303), max param norm drift 0.0
2 RuntimeError momentum restored (0.003303 → 0.003303), max param norm drift 0.0
3 passes unchanged

Drift 0.0 is the part worth noting: the resumed run does not merely survive, it produces the same parameters as the run it resumed.

Stage 3 keeps its momentum through a different path (_create_momentum_buffer) and was never affected.

Tests

tests/unit/ops/muon/test_muon_checkpoint.py, ZeRO 1/2/3 × bf16/fp16. Four of the six fail on the parent commit:

FAILED test_resumes_with_its_momentum[bf16-1]
FAILED test_resumes_with_its_momentum[bf16-2]
FAILED test_resumes_with_its_momentum[fp16-1]
FAILED test_resumes_with_its_momentum[fp16-2]
4 failed, 2 passed

and all six pass here. It asserts both halves: that the restored momentum matches what was saved, and that continuing from the checkpoint lands on the same parameters as never having stopped.

It is a new file rather than a case in test_muon.py because that module skips itself entirely where fp16 is unsupported, which would take the bf16 coverage with it.

Why the suite could not have caught this

tests/unit/ops/muon/test_muon.py never saves or loads a checkpoint. It also runs fp16 at the default loss scale, where — separately from this PR, filed as #8432 — every step overflows, so no momentum buffer is ever allocated in a dtype that could disagree with anything.

The fp16 cases here set initial_scale_power: 4 for that reason: this test is about what a checkpoint carries, not about the scaler.

Resuming a Muon run under ZeRO 1/2 raises as soon as the first gradient arrives:

    RuntimeError: expected dtype torch.float32 for `end`,
    but got dtype torch.bfloat16

ZeRO 1/2 keep Muon's momentum in a hand-flattened tensor under
optimizer.state[flatten_copy]["momentum_buffer"], allocated in the gradient
accumulation dtype. A checkpoint stores optimizer state in fp32, so a restored
buffer comes back in fp32 while the gradients it is combined with are not, and
muon_update's momentum.lerp_(grad) requires the two to match. get_flat_partition
only asked whether a buffer existed, so it left the restored one alone.

Traced across a resume:

    fresh run          momentum torch.bfloat16
    after initialize   no buffer yet
    after load         momentum torch.float32     <- crashes on the next step

The buffer is converted rather than reallocated. Discarding it would also stop
the crash, but the momentum a resume just restored is the reason the checkpoint
carries it, and starting from zero is a different run.

Measured on 2 x H20, ZeRO 1/2/3, comparing an uninterrupted run against one
interrupted at the halfway point:

    stage 1   master: RuntimeError    fixed: momentum restored, param drift 0.0
    stage 2   master: RuntimeError    fixed: momentum restored, param drift 0.0
    stage 3   master: passes          fixed: unchanged

Stage 3 has its own momentum path and was never affected.

The suite could not have caught this: tests/unit/ops/muon/test_muon.py never
saves or loads a checkpoint, and it runs fp16 with the default loss scale, where
every step overflows and no momentum buffer is ever allocated in a dtype that
could disagree. The new file covers ZeRO 1/2/3 across bf16 and fp16; four of the
six cases fail on the parent commit.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
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.

[BUG] Dtype mismatch (bf16 vs fp32) when resuming Muon optimizer from checkpoint

1 participant