Summary
Muon under ZeRO 1/2 with fp16 does not train. The first loss-scale overflow folds an overflowed gradient into Muon's momentum buffer, the buffer stays NaN for the rest of the run, and every subsequent step therefore overflows too. The loss scaler backs off until it gives up:
Exception: Current loss scale already at minimum - cannot decrease scale anymore. Exiting run.
No parameter is ever updated. This is with the configuration tests/unit/ops/muon/test_muon.py itself uses, only run for longer.
Isolation
Same configuration, only the initial loss scale differs. 30 steps, 2 GPUs, ZeRO-2, SimpleModel(hidden_dim=128, nlayers=5), lr=0.05:
initial_scale_power |
parameters changed |
momentum finite |
outcome |
| 16 (the default, 65536) |
0 / 10 |
False |
dies at minimum loss scale |
| 0 (scale 1, no overflow) |
10 / 10 |
True |
trains normally |
So the failure is entirely the overflow interaction, not Muon itself.
Mechanism
An overflowed step is supposed to be discarded. Muon consumes the gradient before that happens.
get_flat_partition (stage_1_and_2.py) calls muon_update on the accumulated gradient while filling the partition, which runs in independent_gradient_partition_epilogue, i.e. before the overflow check that decides whether to skip the step. muon_update then does:
momentum.lerp_(grad, 1 - beta) # inf/nan enters the momentum
update = grad.lerp_(momentum, beta) if nesterov # ...and is written back into grad
Both halves matter. The first poisons the momentum permanently, since nothing ever resets it. The second writes NaN back into the gradient in place, so the overflow check fires again on the next step regardless of what the loss scale has been reduced to — the failure sustains itself.
Step trace, default scale:
step 1: loss_scale=65536 overflow=True changed=0/10 momentum_finite=False
step 2: loss_scale=32768 overflow=True changed=0/10 momentum_finite=False
step 10: loss_scale=128 overflow=True changed=0/10 momentum_finite=False
... loss scale reaches the minimum and the run raises
Why the suite is green
Two things hide it.
The run is too short. TestMuonConfigs takes 5 steps. The scaler needs about sixteen halvings to reach the minimum, so the exception never fires inside the test.
The parameter-change assertion cannot fail. initial_params is captured before deepspeed.initialize, which casts the model to fp16, so the comparison is fp32 against fp16 and torch.equal is False whatever happened in between:
initial_params = [p.clone().cpu() for p in model.parameters()] # fp32, pre-initialize
...
assert not torch.equal(initial.cpu(), final.cpu()) # final is fp16
Measured:
initial dtype (pre-init) torch.float32
after-training dtype torch.float16
repo assertion (pre-init vs after) 10/10 "changed"
same-dtype comparison 0/10 actually changed
cast alone, no training at all 10/10 "changed" by the same assertion
The last line is the point: casting a fresh model to fp16 and training it zero steps satisfies the assertion.
Expected behaviour
A discarded step should leave the optimizer state as it was. Muon's momentum should not absorb a gradient whose step is about to be skipped, and a skipped step should not make the next one fail.
Environment
- DeepSpeed at
32110a9 (current master)
- torch 2.9.1+cu128, 2 × NVIDIA H20-3e, NCCL
- ZeRO stages 1 and 2 both reproduce; stage 3 uses a different momentum path and is not affected
Related
The same code path has a second, independent defect I am fixing separately: a restored checkpoint returns the momentum buffer in fp32 while the gradients are in the gradient accumulation dtype, so resuming a bf16 Muon run raises on momentum.lerp_(grad). Both are invisible to the current suite for the same reason — it never checkpoints and only runs fp16.
I am happy to take this one.
Summary
Muon under ZeRO 1/2 with fp16 does not train. The first loss-scale overflow folds an overflowed gradient into Muon's momentum buffer, the buffer stays
NaNfor the rest of the run, and every subsequent step therefore overflows too. The loss scaler backs off until it gives up:No parameter is ever updated. This is with the configuration
tests/unit/ops/muon/test_muon.pyitself uses, only run for longer.Isolation
Same configuration, only the initial loss scale differs. 30 steps, 2 GPUs, ZeRO-2,
SimpleModel(hidden_dim=128, nlayers=5),lr=0.05:initial_scale_powerSo the failure is entirely the overflow interaction, not Muon itself.
Mechanism
An overflowed step is supposed to be discarded. Muon consumes the gradient before that happens.
get_flat_partition(stage_1_and_2.py) callsmuon_updateon the accumulated gradient while filling the partition, which runs inindependent_gradient_partition_epilogue, i.e. before the overflow check that decides whether to skip the step.muon_updatethen does:Both halves matter. The first poisons the momentum permanently, since nothing ever resets it. The second writes
NaNback into the gradient in place, so the overflow check fires again on the next step regardless of what the loss scale has been reduced to — the failure sustains itself.Step trace, default scale:
Why the suite is green
Two things hide it.
The run is too short.
TestMuonConfigstakes 5 steps. The scaler needs about sixteen halvings to reach the minimum, so the exception never fires inside the test.The parameter-change assertion cannot fail.
initial_paramsis captured beforedeepspeed.initialize, which casts the model to fp16, so the comparison is fp32 against fp16 andtorch.equalisFalsewhatever happened in between:Measured:
The last line is the point: casting a fresh model to fp16 and training it zero steps satisfies the assertion.
Expected behaviour
A discarded step should leave the optimizer state as it was. Muon's momentum should not absorb a gradient whose step is about to be skipped, and a skipped step should not make the next one fail.
Environment
32110a9(current master)Related
The same code path has a second, independent defect I am fixing separately: a restored checkpoint returns the momentum buffer in fp32 while the gradients are in the gradient accumulation dtype, so resuming a bf16 Muon run raises on
momentum.lerp_(grad). Both are invisible to the current suite for the same reason — it never checkpoints and only runs fp16.I am happy to take this one.