-
Notifications
You must be signed in to change notification settings - Fork 5k
[muon] Keep the momentum out of steps the loss scaler discards #8435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alanhuangyoo
wants to merge
2
commits into
deepspeedai:master
Choose a base branch
from
alanhuangyoo:fix/muon-momentum-survives-overflow
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| # DeepSpeed Team | ||
| """A tensor whose own gradient overflowed must not absorb it into its momentum. | ||
|
|
||
| Muon folds the gradient into its momentum while the partition is filled, which happens | ||
| before the overflow check decides whether to keep the step. With nesterov the blend is | ||
| also written back into the gradient in place. One overflow would therefore leave the | ||
| momentum non-finite for the rest of the run and make every later step overflow too, | ||
| until the scaler reaches its minimum and raises. | ||
|
|
||
| The guard is per tensor, and the loss scaler's decision is global: `has_overflow` reduces | ||
| `_has_inf_or_nan` over every partitioned gradient and `step()` discards the whole step on | ||
| that one flag. So a tensor whose own gradient was finite still advances its momentum on a | ||
| step discarded because some *other* tensor overflowed, and that update is thrown away. | ||
| `test_a_finite_tensor_still_absorbs_a_step_discarded_for_another` pins that, measured | ||
| rather than assumed; making it exact needs the momentum write deferred until the step is | ||
| known to survive, which costs a second buffer the size of the momentum. | ||
| """ | ||
|
|
||
| import pytest | ||
| import torch | ||
|
|
||
| import deepspeed | ||
| from deepspeed.accelerator import get_accelerator | ||
| from deepspeed.runtime.zero.muon.original_muon import muon_update | ||
| from unit.common import DistributedTest | ||
| from unit.simple_model import SimpleModel | ||
|
|
||
|
|
||
| def test_an_overflowed_gradient_does_not_enter_the_momentum(): | ||
| """The unit of the behaviour, without a training loop around it.""" | ||
| device = get_accelerator().device_name() | ||
| grad = torch.randn(16, 16, device=device) | ||
| momentum = torch.randn(16, 16, device=device) | ||
| before = momentum.clone() | ||
|
|
||
| overflowed = grad.clone() | ||
| overflowed[0, 0] = float("inf") | ||
| update = muon_update(overflowed, momentum) | ||
|
|
||
| assert torch.equal(momentum, before), "a tensor's own overflow must not move its momentum" | ||
| assert not torch.isfinite(update).all(), \ | ||
| "the update has to stay non-finite, or the overflow check will not skip the step" | ||
|
|
||
|
|
||
| def test_a_finite_gradient_still_moves_the_momentum(): | ||
| """The guard must not disable the optimizer.""" | ||
| device = get_accelerator().device_name() | ||
| grad = torch.randn(16, 16, device=device) | ||
| momentum = torch.zeros(16, 16, device=device) | ||
|
|
||
| update = muon_update(grad.clone(), momentum) | ||
|
|
||
| assert momentum.abs().sum() > 0 | ||
| assert torch.isfinite(update).all() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("zero_stage", [1, 2]) | ||
| class TestMuonSurvivesLossScaleBackoff(DistributedTest): | ||
| world_size = 2 | ||
|
|
||
| def test_training_recovers_from_the_initial_overflow(self, zero_stage): | ||
| """fp16 starts at a loss scale that overflows; backing off is the normal path. | ||
|
|
||
| On the parent commit this never recovers: the momentum is NaN from the first step, | ||
| the poisoned gradient keeps the overflow check firing, and DeepSpeed raises | ||
| "Current loss scale already at minimum - cannot decrease scale anymore". | ||
| """ | ||
| if torch.half not in get_accelerator().supported_dtypes(): | ||
| pytest.skip("fp16 not supported") | ||
|
|
||
| hidden_dim, batch_size = 128, 8 | ||
| torch.manual_seed(0) | ||
| model = SimpleModel(hidden_dim=hidden_dim, nlayers=5) | ||
| config = { | ||
| "train_batch_size": batch_size, | ||
| "optimizer": { | ||
| "type": "Muon", | ||
| "params": { | ||
| "lr": 0.05 | ||
| } | ||
| }, | ||
| "gradient_clipping": 1.0, | ||
| "fp16": { | ||
| "enabled": True | ||
| }, | ||
| "zero_optimization": { | ||
| "stage": zero_stage, | ||
| "reduce_scatter": False | ||
| }, | ||
| } | ||
| engine, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config) | ||
|
|
||
| # Captured after initialize: before it the parameters are still fp32, and comparing | ||
| # across the fp16 cast makes any assertion about change trivially true. | ||
| before = [p.clone().cpu() for p in model.parameters()] | ||
| for _ in range(30): | ||
| x = torch.randn(batch_size, hidden_dim, device=engine.device, dtype=torch.half) | ||
| y = torch.randint(0, hidden_dim, (batch_size, ), device=engine.device) | ||
| engine.backward(engine(x, y)) | ||
| engine.step() | ||
| after = [p.clone().cpu() for p in model.parameters()] | ||
|
|
||
| changed = sum(1 for b, a in zip(before, after) if not torch.equal(b, a)) | ||
| assert changed == len(before), f"only {changed}/{len(before)} parameters moved in 30 steps" | ||
|
|
||
| optimizer = getattr(engine.optimizer, "optimizer", engine.optimizer) | ||
| for state in optimizer.state.values(): | ||
| buffer = state.get("momentum_buffer") if isinstance(state, dict) else None | ||
| if buffer is not None: | ||
| assert torch.isfinite(buffer.float()).all(), "the momentum did not survive the backoff" | ||
|
|
||
|
|
||
| class TestMuonMixedOverflow(DistributedTest): | ||
| world_size = 1 | ||
|
|
||
| def test_a_finite_tensor_still_absorbs_a_step_discarded_for_another(self): | ||
| """The scope of the guard, recorded rather than implied. | ||
|
|
||
| Two 2-D parameters in one group; only `boom` is fed an input that overflows in | ||
| fp16. The step is discarded for the whole model, so neither parameter moves -- | ||
| but `calm`'s gradient was finite, so its momentum advances anyway, for an update | ||
| that is thrown away. Making that exact needs the momentum write deferred until | ||
| the step is known to survive; this pins today's behaviour so the gap is visible. | ||
| """ | ||
| if torch.half not in get_accelerator().supported_dtypes(): | ||
| pytest.skip("fp16 not supported") | ||
|
|
||
| hidden_dim = 32 | ||
| numel = hidden_dim * hidden_dim | ||
|
|
||
| class TwoMatrices(torch.nn.Module): | ||
|
|
||
| def __init__(self): | ||
| super().__init__() | ||
| self.calm = torch.nn.Linear(hidden_dim, hidden_dim, bias=False) | ||
| self.boom = torch.nn.Linear(hidden_dim, hidden_dim, bias=False) | ||
|
|
||
| def forward(self, calm_x, boom_x): | ||
| return self.calm(calm_x).sum() + self.boom(boom_x).sum() | ||
|
|
||
| torch.manual_seed(0) | ||
| model = TwoMatrices() | ||
| config = { | ||
| "train_batch_size": 1, | ||
| "optimizer": { | ||
| "type": "Muon", | ||
| "params": { | ||
| "lr": 0.01, | ||
| "momentum": 0.9, | ||
| "weight_decay": 0.0 | ||
| } | ||
| }, | ||
| # low enough that a normal step does not overflow, so the only overflow is | ||
| # the one the test injects | ||
| "fp16": { | ||
| "enabled": True, | ||
| "initial_scale_power": 4 | ||
| }, | ||
| "zero_optimization": { | ||
| "stage": 1 | ||
| }, | ||
| "zero_allow_untested_optimizer": True, | ||
| } | ||
| engine, _, _, _ = deepspeed.initialize(model=model, model_parameters=list(model.parameters()), config=config) | ||
|
|
||
| def momentum_halves(): | ||
| """(calm, boom) slices of the group's flat momentum buffer, in parameter order.""" | ||
| inner = getattr(engine.optimizer, "optimizer", engine.optimizer) | ||
| for state in inner.state.values(): | ||
| buffer = state.get("momentum_buffer") if isinstance(state, dict) else None | ||
| if buffer is not None and buffer.numel() >= 2 * numel: | ||
| flat = buffer.detach().float() | ||
| return flat[:numel].norm().item(), flat[numel:2 * numel].norm().item() | ||
| return None, None | ||
|
|
||
| device = engine.device | ||
| calm_x = torch.randn(1, hidden_dim, device=device, dtype=torch.half) | ||
| finite_x = torch.randn(1, hidden_dim, device=device, dtype=torch.half) | ||
| overflowing_x = torch.full((1, hidden_dim), 6e4, device=device, dtype=torch.half) | ||
|
|
||
| # Step 0 establishes a momentum for both; step 1 overflows only through `boom`. | ||
| for step in range(2): | ||
| # muon_update runs while the partition is filled, i.e. inside backward, so the | ||
| # momentum has to be read on either side of that rather than around step(). | ||
| calm_before, boom_before = momentum_halves() | ||
| params_before = [p.detach().float().norm().item() for p in model.parameters()] | ||
| engine.backward(engine(calm_x, overflowing_x if step == 1 else finite_x)) | ||
| calm_after, boom_after = momentum_halves() | ||
| engine.step() | ||
| params_after = [p.detach().float().norm().item() for p in model.parameters()] | ||
|
|
||
| if step == 1: | ||
| assert engine.optimizer.overflow, "step 1 was meant to overflow" | ||
| assert params_before == params_after, "an overflowed step must not move parameters" | ||
| assert boom_before == boom_after, \ | ||
| "the tensor whose own gradient overflowed must not absorb it" | ||
| assert calm_before != calm_after, ( | ||
| "a tensor whose gradient was finite does advance its momentum on a step " | ||
| "discarded for another tensor -- if this starts failing, the guard has " | ||
| "become global and the module docstring should say so") |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
grad_is_finiteis per tensor, but the decision it protects the momentum from is global.has_overflow(stage_1_and_2.py:2482) sums_has_inf_or_nanover every partitioned gradient in every group and all-reduces MAX across the DP and model-parallel groups, andstep()discards the whole step on that one flag at:2307. The ordering is not in doubt: the flag is computed fromaveraged_gradients, which is whatget_flat_partitionreturns, and that is wheremuon_updateis 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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_overflowreduces over every partitioned gradient andstep()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_anotherpins 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 wheremuon_updatewrites it (insideget_flat_partition, during backward) rather than aroundengine.step():calmmoves 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 theboomcolumn:inf -> nan -> nanforever 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.