Muon runs no Newton-Schulz at ZeRO stage 0, the default: run it - #8442
Muon runs no Newton-Schulz at ZeRO stage 0, the default: run it#8442alanhuangyoo wants to merge 2 commits into
Conversation
15a798c to
9498533
Compare
|
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:
|
|
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 — |
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>
|
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. # deepspeed/runtime/bf16_optimizer.py
param_group['params'] = [self.fp32_groups_flat_partition[i]]— and knows nothing about
I have pushed a commit that refuses that combination at 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 Sorry for the churn on the one I asked you to look at first. |
9498533 to
9adc537
Compare
Closes #8441.
The problem
MuonWithAuxAdam.stepapplies an update it assumes has been orthogonalized already:That holds under ZeRO:
get_flat_partitioninstage_1_and_2.pyand the sub-group loop instage3.pycallmuon_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.stagedefaults to0, so a config that just names Muon gets that. Counting the Newton-Schulz kernel calls on one step:zero_optimizationblock, fp32MuonWithAuxAdamstage: 0, bf16FP16_UnfusedOptimizerstage: 0, fp16FP16_UnfusedOptimizerstage: 1, fp32DeepSpeedZeroOptimizer1.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, andFP16_UnfusedOptimizerhands it a per-parameter fp32 clone —p.clone().float().detach(), same shape — not a flat buffer. Measured: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_headsis deliberately not threaded through here: it does not exist onmuon_updateon 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 nozero_optimizationblock, 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, becauseFP16_UnfusedOptimizersteps 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, sinceDistributedTestruns 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.