[muon] Keep the momentum out of steps the loss scaler discards - #8435
[muon] Keep the momentum out of steps the loss scaler discards#8435alanhuangyoo wants to merge 2 commits into
Conversation
Muon under ZeRO 1/2 with fp16 does not train. The first loss-scale overflow is
folded into the momentum buffer before the overflow check decides to discard the
step, the buffer stays non-finite for the rest of the run, and every later step
overflows too until the scaler gives up:
Exception: Current loss scale already at minimum - cannot decrease scale
anymore. Exiting run.
No parameter is ever updated. This is the configuration test_muon.py itself uses,
only run for longer than five steps.
The failure sustains itself because both halves of muon_update touch the
gradient:
momentum.lerp_(grad, 1 - beta) # inf/nan enters the momentum
update = grad.lerp_(momentum, beta) if nesterov # ...and is written back to grad
so the next step's gradient is already non-finite whatever the loss scale has
been reduced to.
The momentum now stays out of a step whose gradient is not finite, and the
non-finite gradient is still returned so the overflow is seen and the step is
skipped. Both are needed: on the non-nesterov path `update` is the momentum, so
protecting the momentum alone would hand an overflowed step a finite update and
the step would be applied instead of skipped. Evaluated on device, so this costs
no synchronization.
30 steps, 2 x H20, ZeRO 1/2, SimpleModel(hidden_dim=128, nlayers=5), lr 0.05:
stage scale master this commit
1 65536 0/10 moved, non-finite, dies 10/10 moved, finite
2 65536 0/10 moved, non-finite, dies 10/10 moved, finite
1 1 10/10 moved, finite 10/10 moved, finite
2 1 10/10 moved, finite 10/10 moved, finite
The scale-1 rows are the control: with no overflow there was never a problem, so
the failure is entirely the overflow interaction.
Reported as deepspeedai#8432, which also records why the suite is green today: the run is
too short to reach the exception, and the parameter-change assertion compares
parameters captured before deepspeed.initialize -- fp32 -- against fp16 ones
after training, so torch.equal is False whatever happened in between. Casting a
model to fp16 and training it zero steps satisfies that assertion.
Tests: 3 of the 4 new cases fail on the parent commit, including the unit-level
one that pins the momentum directly.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
| # scaler backs off until it raises "Current loss scale already at minimum". Keep the | ||
| # momentum out of it, and let the non-finite gradient through so the overflow is still | ||
| # seen and the step still skipped. Evaluated on device so this costs no synchronization. | ||
| grad_is_finite = torch.isfinite(grad).all() |
There was a problem hiding this comment.
grad_is_finite is per tensor, but the decision it protects the momentum from is global. has_overflow (stage_1_and_2.py:2482) sums _has_inf_or_nan over every partitioned gradient in every group and all-reduces MAX across the DP and model-parallel groups, and step() discards the whole step on that one flag at :2307. The ordering is not in doubt: the flag is computed from averaged_gradients, which is what get_flat_partition returns, and that is where muon_update is applied per tensor at :2167.
So on a step discarded because some other matrix overflowed, this matrix's gradient was finite, its momentum has already moved, and the parameter update it moved for is thrown away. The momentum then carries a step that never happened. That is narrower than the invariant your test module states ("A step the loss scaler discards must leave Muon's momentum as it was"), and it is the case the tests do not reach: both tensor-level tests use a single tensor, and the training test only asserts the momentum is finite, which the mixed case satisfies.
Is the per-tensor scope deliberate? One absorbed gradient is cheap next to the poisoned-forever behaviour you are fixing, so this is not a blocker either way. I read this rather than ran it, and I have no multi-GPU box here, so I have not seen the mixed case happen.
There was a problem hiding this comment.
Yes, deliberate — but you are right that the module claimed more than it delivers, and that is fixed in 4ce8e62. Apologies for not answering here; I pushed it and never replied in the thread.
Two changes, both from your reading:
The docstring no longer states the invariant you quoted. It is now "A tensor whose own gradient overflowed must not absorb it into its momentum", and it says the scope out loud — that the guard is per tensor, that has_overflow reduces over every partitioned gradient and step() discards on that one flag, and that a finite tensor therefore still advances its momentum on a step discarded for another.
And TestMuonMixedOverflow::test_a_finite_tensor_still_absorbs_a_step_discarded_for_another pins that case rather than leaving it to reading. You said you have no multi-GPU box, so here is the measurement — one finite matrix (calm) and one whose gradient overflows (boom), momentum read where muon_update writes it (inside get_flat_partition, during backward) rather than around engine.step():
this branch: step 1 overflow=True calm 55.159618 -> 104.801094 boom 42.780441 -> 42.780441 discarded
master: step 1 overflow=True calm 55.159618 -> 104.801094 boom 42.780441 -> inf
step 2 overflow=True calm 104.801094 -> 149.487137 boom inf -> nan
step 3 overflow=True calm 149.487137 -> 162.125839 boom nan -> nan
calm moves on a discarded step on both — that is the residue you describe, and it is the same on master, so this PR does not make it worse. What changes is the boom column: inf -> nan -> nan forever on master versus held at its pre-overflow value here.
On whether to close the residue too: it needs the momentum write deferred until the step is known to survive, which costs a second buffer the size of the momentum for every Muon parameter. That did not seem worth trading for one absorbed gradient per overflow event, so the docstring records the choice instead of hiding it. Happy to be argued out of that if you or a maintainer would rather pay the memory.
|
You are right, and it was not deliberate — I put the guard where Tracing it the same way you did, the ordering is forced, not incidental:
So the global flag is computed from post- What a global guard would actually cost, since that is the part your comment leaves open:
(3) is the only one that delivers what my test module claims. Whether that memory is worth it for a case that costs one absorbed gradient is a call I would rather you and @delock make than assume. Two things I am doing either way:
Thanks for reading it this closely. This is the second time on this stack I have claimed something wider than I measured, and both times it was someone else who noticed. |
|
Numbers, as promised. Your case reproduces exactly. Two 2-D parameters in one group, On this branch: Step 1 is discarded and the parameters do not move, On master, same script: So the same run shows both things at once: the poisoning this PR is for ( What I am changing here: the invariant in the test module, which as written promises more than the code delivers, and a test that pins the mixed case at the behaviour above so it is recorded rather than discovered again. What I am not changing without a word from you and @delock: the scope. Of the three ways to make it global, only deferring the momentum write is exact, and it costs one buffer the size of the momentum. One absorbed gradient per discarded step against +1x optimizer memory on the Muon groups is a trade I would rather not make unilaterally inside a PR that is meant to stop a run from dying. Thanks — you found this by reading, without a box to run it on, and you were right on every detail including which tests could not reach it. |
|
Thanks for running it. Your step 1 row is the case exactly: On scope, since you asked. Deferring the momentum write is the only one of the three that delivers what the test module claims. I would avoid inverting the lerp for the reason you gave, that it turns an exact invariant into an approximate one, and a rollback bought with an extra collective on every step is paying on the common path for the rare one. Whether the buffer is worth one absorbed gradient per discarded step is a memory call I have no numbers for, so that part is yours and @delock's. Narrowing the invariant and pinning the mixed case is worth doing either way. |
The module claimed 'A step the loss scaler discards must leave Muon's momentum
as it was'. The guard is per tensor and the scaler's decision is global --
has_overflow reduces _has_inf_or_nan over every partitioned gradient and step()
discards on that one flag -- so a tensor whose own gradient was finite still
advances its momentum on a step discarded for another tensor.
Measured on 1xH20, two 2-D parameters in one group, only one fed an overflowing
input, fp16 + ZeRO-1, momentum read on either side of backward because that is
where muon_update writes:
step overflow calm momentum boom momentum params
0 False None -> 55.159618 None -> 42.780441 changed
1 True 55.159618 -> 104.801094 42.780441 -> 42.780441 discarded
On master the same run gives boom 42.780441 -> inf, then nan, with every later
step discarded and the loss scale halving to the minimum -- the failure this PR
is for. calm's 55.159618 -> 104.801094 is identical on both sides.
Reported by @ebarkhordar, who found it by reading and named which tests could
not reach it: both tensor-level cases use a single tensor and the training test
only asserts the momentum is finite.
Docstring now says what the guard covers, and
test_a_finite_tensor_still_absorbs_a_step_discarded_for_another records the gap
so a later change to global scope shows up as a failing test rather than a
silent improvement.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Done, in The module docstring now says what the guard covers (a tensor whose own gradient overflowed does not absorb it) instead of what it does not (a step the scaler discards leaving the momentum as it was), and spells out why the two differ.
So the gap is a failing test away from being noticed rather than a paragraph someone has to remember.
Leaving the scope decision where you put it — @delock, the question is whether one absorbed gradient per discarded step is worth a second buffer the size of the momentum on the Muon groups. I have no preference strong enough to spend your memory budget on it. |
Fixes #8432.
Problem
Muon under ZeRO 1/2 with fp16 does not train. Running the configuration
tests/unit/ops/muon/test_muon.pyitself uses, for longer than its five steps:No parameter is ever updated. The first loss-scale overflow is folded into Muon's momentum before the overflow check decides to discard the step, and the buffer stays non-finite for the rest of the run.
The failure sustains itself because both halves of
muon_updatetouch the gradient:so the next step's gradient is already non-finite whatever the loss scale has been reduced to. Backing off cannot help.
Fix
The momentum stays out of a step whose gradient is not finite, and the non-finite gradient is still returned so the overflow is seen and the step skipped.
Both halves are needed. Protecting the momentum alone is not enough: on the non-nesterov path
updateis the momentum, so a protected momentum would hand an overflowed step a finite update and the step would be applied rather than skipped. The non-finiteness has to keep propagating. Evaluated on device, so this costs no synchronization.Verification
30 steps, 2 × H20, ZeRO 1/2,
SimpleModel(hidden_dim=128, nlayers=5),lr=0.05:The scale-1 rows are the control: with no overflow there was never a problem, so the failure is entirely the overflow interaction rather than anything about Muon.
Tests
tests/unit/ops/muon/test_muon_overflow.py. Three of the four fail on the parent commit:The unit-level case pins both halves directly: the momentum must not move, and the returned update must stay non-finite.
test_a_finite_gradient_still_moves_the_momentumis the guard against the guard — it would catch a fix that simply disabled the optimizer.Existing suite on this branch, non-offload configurations:
Both failures are
op_builder.builder.CUDAMismatchExceptionintest_muon_reduce_scatter_with_optimizer_offload_raises, from this box's system CUDA not matching the one torch was built against, so CPUAdam will not build. They are unrelated to this change and reproduce on master.Note on the suite
Worth recording, since it is why this survived:
TestMuonConfigscapturesinitial_paramsbeforedeepspeed.initialize, which casts the model to fp16. The assertion is therefore fp32 against fp16 andtorch.equalisFalsewhatever happened in between:Casting a fresh model to fp16 and training it zero steps satisfies it. That is out of scope here — the new file captures after
initializeand says why in a comment — but the assertion is worth tightening separately.