[muon] Reconcile the momentum dtype when a checkpoint is restored - #8433
Open
alanhuangyoo wants to merge 1 commit into
Open
[muon] Reconcile the momentum dtype when a checkpoint is restored#8433alanhuangyoo wants to merge 1 commit into
alanhuangyoo wants to merge 1 commit into
Conversation
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>
alanhuangyoo
requested review from
loadams,
tjruwase and
tohtana
as code owners
September 6, 2026 10:01
delock
self-requested a review
September 7, 2026 13:56
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.
Closes #7746.
Problem
Resuming a Muon run under ZeRO 1/2 raises as soon as the first gradient arrives:
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, andmuon_update'smomentum.lerp_(grad)requires the two to match.get_flat_partitiononly asked whether a buffer existed:so it left the restored one alone. Traced across a resume:
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:
RuntimeErrorRuntimeErrorDrift 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: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.pybecause 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.pynever 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: 4for that reason: this test is about what a checkpoint carries, not about the scaler.