Skip to content

Muon runs no Newton-Schulz at ZeRO stage 0, the default: run it - #8442

Merged
delock merged 5 commits into
deepspeedai:masterfrom
alanhuangyoo:fix/muon-requires-zero-optimizer
Sep 8, 2026
Merged

Muon runs no Newton-Schulz at ZeRO stage 0, the default: run it#8442
delock merged 5 commits into
deepspeedai:masterfrom
alanhuangyoo:fix/muon-requires-zero-optimizer

Conversation

@alanhuangyoo

@alanhuangyoo alanhuangyoo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #8441.

The problem

MuonWithAuxAdam.step applies an update it assumes has been orthogonalized already:

# deepspeed/runtime/zero/muon/muon_optimizer.py
if group["use_muon"]:
    # we move the muon update part to the deepspeed's optimizer since the parameter here is a flat version
    # thus not suitable for muon update
    for p in group["params"]:
        p.mul_(1 - group["lr"] * group["weight_decay"])
        p.add_(p.grad.reshape(p.shape), alpha=-group["lr"])

That holds under ZeRO: get_flat_partition in stage_1_and_2.py and the sub-group loop in stage3.py call muon_update, so by then the gradient holds the orthogonalized update.

With no ZeRO optimizer nothing does, and p.add_(p.grad, alpha=-lr) on a raw gradient is SGD. zero_optimization.stage defaults to 0, so a config that just names Muon gets that. Counting the Newton-Schulz kernel calls on one step:

config wrapper Newton-Schulz calls max|w - SGD|
no zero_optimization block, fp32 MuonWithAuxAdam 0 1.49e-08
stage: 0, bf16 FP16_UnfusedOptimizer 0 4.88e-04
stage: 0, fp16 FP16_UnfusedOptimizer 0
stage: 1, fp32 DeepSpeedZeroOptimizer 2 9.77e-02

1.49e-08 is reduction ordering: those are the SGD weights, seven orders below what a real Muon step does to the same gradient. Training runs and the loss falls either way.

The change

The two cases are distinguishable by shape, which I initially thought they were not. ZeRO hands step() a flat 1-D partition. An unwrapped optimizer hands it the model's weight, and FP16_UnfusedOptimizer hands it a per-parameter fp32 clonep.clone().float().detach(), same shape — not a flat buffer. Measured:

stage 0 / fp32   MuonWithAuxAdam         ndims=[2]
stage 0 / bf16   FP16_UnfusedOptimizer   ndims=[2]
stage 0 / fp16   FP16_UnfusedOptimizer   ndims=[2]
stage 1 / fp32   DeepSpeedZeroOptimizer  ndims=[1]

So: orthogonalize when the parameter is a matrix, and keep applying the update as-is when it is a partition. After the change, stage 0 fp32 produces max|w - SGD| = 9.772e-02 — the same value stage 1 gives, i.e. the same update.

Newton-Schulz is scale-invariant and the momentum starts at zero, so initialize_optimizer_states' warm-up step on zero gradients stays a no-op.

num_heads is deliberately not threaded through here: it does not exist on muon_update on master. Once #8384 lands, this call site is where per-head would be added for the unwrapped path.

Tests

tests/unit/runtime/zero/test_muon_without_zero_optimizer.py, 7 cases: Newton-Schulz runs at stage 0 for fp32, bf16 and fp16 — all three wrappers; it runs for a config with no zero_optimization block, which is the plainest form; and it still runs on stages 1, 2 and 3.

On master, 4 fail and 3 pass. The four that fail are the stage-0 ones, with Newton-Schulz ran 0 times for two Muon matrices; the three that pass are the ZeRO stages, which is the control that says the test measures the right thing.

The counter is started after deepspeed.initialize, because FP16_UnfusedOptimizer steps once at construction to allocate state and that call would otherwise satisfy the assertion on its own. It is also patched inside the test body rather than in a fixture, since DistributedTest runs the body in a worker a parent-process fixture would not reach.

This is the assertion the existing Muon tests were missing: tests/unit/ops/muon/ parametrizes stages [1, 2, 3] and checks that the loss moves, which SGD also does — which is why stage 0 went unnoticed.

7 passed. yapf and flake8 clean.

@alanhuangyoo
alanhuangyoo force-pushed the fix/muon-requires-zero-optimizer branch from 15a798c to 9498533 Compare September 6, 2026 14:53
@alanhuangyoo alanhuangyoo changed the title Muon runs no Newton-Schulz at ZeRO stage 0, the default: refuse the configuration Muon runs no Newton-Schulz at ZeRO stage 0, the default: run it Sep 6, 2026
@alanhuangyoo

Copy link
Copy Markdown
Contributor Author

Checked the obvious follow-up question given #8443, which reports ZeRO-3 running Newton-Schulz once per micro-batch: this path does not have that problem. Two optimizer steps, two Muon matrices, so 4 orthogonalizations is correct:

no zero_optimization  gas=1   newton_schulz calls=4   expected 4
no zero_optimization  gas=4   newton_schulz calls=4   expected 4
stage 0               gas=1   newton_schulz calls=4   expected 4
stage 0               gas=4   newton_schulz calls=4   expected 4

MuonWithAuxAdam.step is reached once per optimizer step here, since engine.step() no-ops until the accumulation boundary, so the momentum advances once per step as configured. ZeRO-3's problem is that its update sits in the IPG reduce path, which is a different call site with no boundary guard.

@alanhuangyoo

Copy link
Copy Markdown
Contributor Author

Ran the full Muon suite with this PR merged onto master alongside the other two open Muon fixes (#8438, #8440), since all three touch optimizer setup: 190 passed in 28:51 — tests/unit/ops/muon/, tests/unit/v1/ops/muon/, and the three PRs' own test files. The three merge cleanly onto master and onto each other.

MuonWithAuxAdam.step applied an update it assumed had been orthogonalized
already. That holds under ZeRO, where the parameters it sees are flat
partitions and get_flat_partition or the ZeRO-3 sub-group loop did the
work. With no ZeRO optimizer nothing did, and p.add_(p.grad, alpha=-lr) on
a raw gradient is SGD.

zero_optimization.stage defaults to 0, so a config that just names Muon
got that. Counting the Newton-Schulz calls on one step, before:

  no zero_optimization, fp32   MuonWithAuxAdam         0   max|w-SGD| 1.5e-08
  stage 0, bf16                FP16_UnfusedOptimizer   0   max|w-SGD| 4.9e-04
  stage 0, fp16                FP16_UnfusedOptimizer   0
  stage 1, fp32                DeepSpeedZeroOptimizer  2   max|w-SGD| 9.8e-02

Training ran and the loss fell either way.

The two cases are distinguishable by shape: ZeRO hands step() a flat 1-D
partition, while an unwrapped optimizer and FP16_UnfusedOptimizer - which
keeps per-parameter fp32 clones rather than a flat buffer - hand it the
2-D weight. So orthogonalize when the parameter is a matrix and keep
applying the update as-is when it is a partition. After, stage 0 fp32
gives the same max|w-SGD| as stage 1, 9.772e-02.

The existing Muon tests parametrize stages 1, 2 and 3 and assert that
training progresses, which SGD also does. The new tests count the
orthogonalizations, and count them around the training step only, since
FP16_UnfusedOptimizer also steps once at construction to allocate state.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
MuonWithAuxAdam.step tells the two cases apart by shape: a matrix is the
weight itself and is orthogonalized there, a 1-D tensor is a ZeRO
partition whose update the ZeRO optimizer already applied. BF16_Optimizer
breaks that reading - it replaces the param groups with flat fp32
partitions and knows nothing about use_muon, so the update is never
applied and the shape test reads its partitions as already done.

Enumerating every wrapper a Muon config can select, before this branch:

  s0-fp32        MuonWithAuxAdam            ndims=[2]  NS=0
  s0-bf16        FP16_UnfusedOptimizer      ndims=[2]  NS=0
  s0-fp16        FP16_UnfusedOptimizer      ndims=[2]  NS=0
  s1-bf16        DeepSpeedZeroOptimizer     ndims=[1]  NS=2
  s1-bf16-ga32   BF16_Optimizer             ndims=[1]  NS=0
  s1-fp16        DeepSpeedZeroOptimizer     ndims=[1]  NS=2
  s2-bf16        DeepSpeedZeroOptimizer     ndims=[1]  NS=2
  s3-bf16        DeepSpeedZeroOptimizer_S3  ndims=[]   NS=2

s1-bf16-ga32 is bf16 with grad_accum_dtype fp32 at stage 1, and it was
broken before this branch in the same silent way: max|w - SGD| of 4.9e-04,
bf16 rounding away from plain SGD, against 6.8e-02 for the same config one
flag apart.

The original shapes are not recoverable from a flat partition, so this
refuses the combination at initialize rather than fixing it; implementing
Muon inside BF16_Optimizer is a separate change.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
@alanhuangyoo

Copy link
Copy Markdown
Contributor Author

Before you spend time on this one — I found a hole in it myself and want to flag it rather than have you find it.

I claimed the two cases were distinguishable by shape: a matrix is the weight and gets orthogonalized here, a 1-D tensor is a ZeRO partition whose update the ZeRO optimizer already applied. That is true for every wrapper except one. BF16_Optimizer also replaces the param groups with flat partitions —

# deepspeed/runtime/bf16_optimizer.py
param_group['params'] = [self.fp32_groups_flat_partition[i]]

— and knows nothing about use_muon, so it never applies the update. My shape test reads its partitions as "already done" and the step stays SGD. Enumerating every wrapper a Muon config can select, on master:

s0-fp32        MuonWithAuxAdam              ndims=[2]  NS=0
s0-bf16        FP16_UnfusedOptimizer        ndims=[2]  NS=0
s0-fp16        FP16_UnfusedOptimizer        ndims=[2]  NS=0
s1-bf16        DeepSpeedZeroOptimizer       ndims=[1]  NS=2
s1-bf16-ga32   BF16_Optimizer               ndims=[1]  NS=0     <- this one
s1-fp16        DeepSpeedZeroOptimizer       ndims=[1]  NS=2
s2-bf16        DeepSpeedZeroOptimizer       ndims=[1]  NS=2
s3-bf16        DeepSpeedZeroOptimizer_S3    ndims=[]   NS=2

s1-bf16-ga32 is bf16 with grad_accum_dtype: fp32 at stage 1 — a normal enough recipe, and it was already broken before this PR, in the same silent way. Its weights come out at max|w - SGD| = 4.9e-04, which is bf16 rounding away from plain SGD, against 6.8e-02 for the same config one flag apart.

I have pushed a commit that refuses that combination at deepspeed.initialize rather than leaving it silently wrong. It cannot be fixed in step — the original shapes are not recoverable from a flat partition — so implementing Muon inside BF16_Optimizer would be its own change, and I would rather not fold it in here.

So the PR now reads: orthogonalize where the parameters are the real weights, refuse the one place where they are not and nobody else does the work. Two tests added for it, including the neighbouring config without grad_accum_dtype, so the refusal is shown to be narrow.

Sorry for the churn on the one I asked you to look at first.

@alanhuangyoo
alanhuangyoo force-pushed the fix/muon-requires-zero-optimizer branch from 9498533 to 9adc537 Compare September 7, 2026 04:58
@delock
delock self-requested a review September 7, 2026 13:56
@alanhuangyoo

Copy link
Copy Markdown
Contributor Author

@delock — following the order you agreed to, this is #1 and it is ready.

Recap in one paragraph: {"optimizer": {"type": "Muon"}} with no zero_optimization block defaults to stage 0, and at stage 0 no ZeRO optimizer wraps the parameters, so nothing ever calls Newton-Schulz — the plainest possible Muon config silently trains with a plain SGD-with-momentum step. The fix runs the update in MuonWithAuxAdam.step itself for that case, and refuses the one combination where a wrapper hands flat partitions but never applies Muon (bf16 + grad_accum_dtype: fp32 + stage 1), which I found by re-reading my own claim rather than by testing and posted here before anyone asked.

The red modal-torch-latest / DeepSpeedAI CI is a GPU reservation timeout, not a test result — SandboxStartTimeout: Sandbox did not start within 1800s, so no test ran. This is a capacity problem rather than a test failure. collect tests and DCO are green on the same commit, and the branch is merged with current master.

@delock

delock commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Hi @alanhuangyoo , thanks for your PR. It looks good to me. I would suggest to open an issue for BFloat16 optimizer to keep tracking if you havn't, thanks!

@delock
delock enabled auto-merge September 8, 2026 11:06
cpu-torch-latest fails test_newton_schulz_runs_at_stage_zero[fp16] with

    ValueError: Type fp16 is not supported on your device.

which _do_sanity_check raises on not get_accelerator().is_fp16_supported().
That is a different predicate from supported_dtypes(): the runner reports fp16
in the latter and False from the former, so guarding on the usual one would
still fail there. _skip_if_unsupported mirrors the engine's own check, and the
two bf16 cases in TestMuonRefusesBF16Optimizer get the same guard.

Verified on 1xH20 (9 passed) and on the CPU accelerator, where both predicates
return True so the skip does not fire -- I could not reproduce the runner's
False branch locally, so the guard is matched to the raising condition rather
than to an observed skip.

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
auto-merge was automatically disabled September 8, 2026 12:22

Head branch was pushed to by a user without write access

@delock
delock enabled auto-merge September 8, 2026 14:03
@delock
delock disabled auto-merge September 8, 2026 14:22
@delock
delock added this pull request to the merge queue Sep 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 8, 2026
@alanhuangyoo

Copy link
Copy Markdown
Contributor Author

Thanks @delock — both done.

The red is cleared. cpu-torch-latest / unit tests was failing on my own test: test_newton_schulz_runs_at_stage_zero[fp16] hit ValueError: Type fp16 is not supported on your device. on that runner. Fixed in c8f3417.

Worth noting which predicate, because the obvious one is wrong here. _do_sanity_check raises on not get_accelerator().is_fp16_supported(), while the usual guard in this repo is torch.half not in get_accelerator().supported_dtypes() — and those disagree on that runner, which reports fp16 in supported_dtypes() and False from is_fp16_supported(). So the skip mirrors the engine's own check instead. The two bf16 cases in TestMuonRefusesBF16Optimizer got the same guard.

I could not reproduce the runner's False branch locally (the CPU accelerator on my box returns True from both), so the guard is matched to the raising condition rather than to an observed skip. On 1×H20 the file is 9 passed.

Now APPROVED / CLEAN, 12 green and modal skipped per #8412.

The BF16 tracker is #8461. It records why Muon cannot run under BF16_Optimizer — it hands step flat partitions like the ZeRO optimizers do, but unlike them never applies Muon anywhere, so step's "ZeRO already did the update" branch is taken on a premise that is false — and the two ways to lift the refusal, with a note that applying Muon where BF16_Optimizer fills the partition looks right to me but I have not written it.

@delock
delock added this pull request to the merge queue Sep 8, 2026
Merged via the queue into deepspeedai:master with commit cbd303e Sep 8, 2026
13 checks passed
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.

Muon runs no Newton-Schulz at ZeRO stage 0, which is the default: the plainest Muon config trains with SGD

2 participants