Skip to content

Run Muon once per optimizer step under ZeRO-3, not once per micro-batch - #8600

Open
alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/zero3-muon-once-per-step
Open

alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/zero3-muon-once-per-step

Conversation

@alanhuangyoo

Copy link
Copy Markdown
Contributor

Fixes #8443.

The problem

ZeRO-3 applies Muon inside the gradient reduce (_apply_distributed_muon_update, called from __avg_scatter_contiguous_grads), and that runs every micro-batch. With gradient_accumulation_steps: n, the momentum advances n times per optimizer step, and Newton-Schulz orthogonalizes each micro-batch's partial gradient instead of the accumulated one. ZeRO-1/2 apply Muon at the accumulation boundary and are correct.

On 2 GPUs, fp32, with the same 8 samples per step either way (one micro-batch of 8 at gas=1, four of 2 at gas=4), three steps, relative difference in the weights:

master this PR
ZeRO-2, gas=1 vs gas=4 3.6e-4 3.6e-4
ZeRO-3, gas=1 vs gas=4 1.3e-1 3.6e-4
Newton-Schulz calls, ZeRO-3, 2 matrices, 2 steps, gas=4 16 4

ZeRO-3 now lands on exactly ZeRO-2's figure.

The change

This is option 1 from the discussion in #8443. It is scoped to ZeRO-3 without optimizer offload.

  • The reduce path no longer runs Muon when optimizer offload is off. The partitions accumulate the raw averaged gradient, as they do for every other optimizer.
  • step() calls _apply_muon_to_accumulated_grads() after the overflow check and before the gradient norm. For each Muon sub-group, it:
    • all-gathers each parameter's accumulated gradient partitions, in chunks bounded by reduce_bucket_size as the reduce buckets were;
    • runs the existing round-robin Muon update once;
    • writes each rank's slice back into its partition.
  • The per-sub-group body of _apply_distributed_muon_update is moved into _muon_update_sub_group. It takes the full-shape gradients explicitly, because at step time the parameters are partitioned and param.grad can't hold them. The reduce path calls it with param.grad as before.
  • The gradient norm is still taken over the Muon update, as before. Clipping semantics are unchanged (Default gradient_clipping divides every Muon update by its own norm, shrinking the step by a model-sized factor #8439 / Fix Muon optimizer conflict with gradient clipping in ZeRO 1/2 #7776 are separate).
  • Because the update now runs after the overflow check, a step the loss scaler discards no longer touches the momentum. That is the ZeRO-3 counterpart of [muon] Keep the momentum out of steps the loss scaler discards #8435.
  • Collectives: each Muon parameter's gradient and momentum are gathered once per step instead of once per micro-batch. At gas=n that is n times fewer.

The optimizer-offload path is unchanged; #8464 is working on it. jinyouzhi added a pointer to this shape in #8464, and the overlap is limited to _apply_distributed_muon_update.

Testing

On 2×H20:

  • New file tests/unit/v1/ops/muon/test_muon_zero3_grad_accum.py. Both tests fail on master and pass here.
    • test_newton_schulz_runs_once_per_matrix_per_step: Newton-Schulz calls summed over ranks come to 2 × steps, not 2 × steps × gas.
    • test_gradient_accumulation_matches_one_large_micro_batch[2, 3]: gas=1 and gas=4 agree to within half-precision Newton-Schulz noise at both stages.
  • tests/unit/v1/ops/muon/ plus tests/unit/runtime/zero/test_per_head_muon.py: 260 passed.

ZeRO-3 applied Muon inside the gradient reduce, which runs every micro-batch.
With gradient_accumulation_steps n, the momentum advanced n times per step and
Newton-Schulz orthogonalized partial gradients, so training with accumulation
diverged from the same batch taken in one micro-batch. ZeRO-1/2 were correct.

Without optimizer offload, the reduce path now leaves Muon out and the
partitions accumulate the raw gradient. At the step, after the overflow check
and before the norm, each Muon sub-group's accumulated gradients are gathered in
bounded chunks, orthogonalized once through the same round-robin update, and
written back to the partitions. The per-subgroup update is factored out of
_apply_distributed_muon_update so both paths share it. The offload path is
unchanged.

Fixes deepspeedai#8443

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
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.

ZeRO-3 applies Muon's Newton-Schulz once per micro-batch, so gradient accumulation changes the optimizer

1 participant