Skip to content

[main] Muon fp8 param gather for decoupled layout - #5479

Open
Wohox wants to merge 15 commits into
NVIDIA:mainfrom
Wohox:muon-decouple-fp8-param-gather-main
Open

[main] Muon fp8 param gather for decoupled layout#5479
Wohox wants to merge 15 commits into
NVIDIA:mainfrom
Wohox:muon-decouple-fp8-param-gather-main

Conversation

@Wohox

@Wohox Wohox commented Jun 24, 2026

Copy link
Copy Markdown
Contributor
  • I, the PR author, have personally reviewed every line of this PR.

What does this PR do ?

Adds FP8 parameter gather to the Muon optimizer on the compact decoupled LayerWise DDP layout
(--fp8-param-gather), so Muon persists and syncs its matrix parameters as FP8 instead of BF16 —
bringing its memory footprint down to Adam's level while staying bit-for-bit equal to the
fp8-param-gather-OFF run.

Main-branch counterpart of the dev stack #5388 + #5470 (both merged).
Compact-layout checkpoint support is intentionally out of scope for this PR.

Self-contained: it supersedes #5391, which carried the layout half on main and is now
closed into this one. See Parity with the dev stack below.

Builds on the decoupled compact LayerWise layout (dev #5388 / main #5391), included here. Previously the non-DistOpt Muon path could
only hold and all-gather its parameters in BF16. With this change Muon's matrix params are persisted
as FP8 (mxfp8 on Blackwell, blockwise on Hopper), removing the standing BF16 parameter copy; combined
with the no-padding layout from #5388 this closes the Muon-vs-Adam memory gap.

Behaviour change: the compact decoupled layout is now the default for the LayerWise (Muon)
optimizer. use_layer_wise_param_layout defaults to False on both
DistributedDataParallelConfig and OptimizerConfig, and the CLI knob is the opt-in
--use-layer-wise-param-layout (the old opt-out --no-use-layer-wise-param-layout is gone). Pass
the flag to restore the padded shard-aligned layout, e.g. for bit-for-bit comparison against older
runs. This matches what merged on dev.

What's new

1. An FP8-aware whole-parameter all-gather for the non-DistOpt Muon path. Muon's matrix buffers
own whole params via ping-pong assignment and sync through allgather_params (not DistOpt's
byte-shard reduce-scatter/all-gather). FP8 tensors can't be all-gathered directly — mxfp8's row/col
block scales can't be derived from one another — so the transport rides BF16 and requantizes on
landing: stage each owned param from its fp32 master → bf16 (_stage_param_to_bf16, a
high-precision source, not a lossy fp8 dequant) → uneven all-gather-v of the bf16 whole-params →
requantize the gathered bf16 into every rank's fp8 param.data
(copy_back_gathered_bf16_into_fp8_param) so all ranks hold Q(bf16(master)) (== OFF / Adam) →
post_all_gather_processing rebuilds the fp8 columnwise/transpose.

Both copy-back sites — the overlap path (start_param_sync / finish_param_sync) and the
non-overlap path (allgather_params) — share one helper,
_layerwise_copy_back_gathered_params(bucket, local_rank, fp8_staged=...). The staging decision is
persisted on the bucket as bucket.layerwise_fp8_staged at start_param_sync time and read back by
the (possibly deferred) copy-back, so transport dtype and copy-back can never disagree. Plain bf16
(fp8pg OFF) collapses back to the original code.

Params are dispatched by transport dtype: fp8 and bf16 ride the bf16-staged helper, while native
fp32 params (weights marked keep_in_fp32, e.g. the DeepSeek-V4 CSA ape) are gathered in fp32 —
routing them through the bf16-staged path would silently downcast them, and mixing fp32 with bf16 in
one flatten is invalid. For a pure-bf16 model the native group is empty and this collapses to the
original single-helper dispatch.

2. use_distributed_optimizer becomes a per-buffer property. On the decoupled layout,
LayerWise-managed (Muon 2D-matrix) buffers locally disable DistOpt — compact no-padding layout,
all-reduce gradients, whole-param ping-pong ownership — while sibling buffers (embeddings, biases,
layernorm) keep the standard byte-level DistributedOptimizer path.

The decision has a single source of truth: it is baked into each buffer's own ddp_config via
dataclasses.replace(...), so bucket groups inherit it through buffer.ddp_config rather than
through a separately threaded attribute. A bucket group runs a single collective type
(reduce-scatter for DistOpt buffers, all-reduce otherwise), so the merging paths in
partition_buckets (the fp8-merge branch, force_single_bucket_group) assert that the buckets they
combine agree, and the no-fp8 branch keeps one bucket per group so they never mix. When every bucket
agrees — every non-decoupled configuration — this collapses to exactly the previous grouping.

3. Rank-independent ping-pong ownership. allgather_params requires every DP rank to agree on
each param's single owner. numel alone isn't a total order (a stable sort tie-breaks equal-numel
params by insertion order → different owners per rank), so ownership is now keyed by a canonical
identity (chunk_idx, buffer_idx, global_start_index) from model construction, with an assert that
every Muon param has one.

4. iter-0 master parity, and no double-write of gathered fp8 params. The fp32 master is seeded
from each param's high-precision pre-quantization init (get_high_precision_init_val), not the lossy
fp8 dequant, so fp8pg ON and OFF hold an identical master at iter 0 (matching DistOpt).

The master→model copy routes through bf16 (Q(bf16(master))), gated by
_layer_wise_non_distopt_child — a tag LayerWiseDistributedOptimizer sets on the
Float16OptimizerWithFloat16Params children it wraps. Gathered fp8 params are tagged
_layer_wise_fp8_gathered and skipped there, because the all-gather's requantize already writes
Q(bf16(master)) into their param.data; non-gathered fp8 params (e.g. MoE experts at
expt_dp == 1, which the all-gather skips) are untagged and still get their copy here.

Routing those Float16 children away from the DDP param buffer in step_with_ready_grads is handled
by the existing hasattr(self, "_copy_main_params_to_param_buffer") probe — only
DistributedOptimizer owns that byte-shard buffer — so this PR deliberately does not define a
raising stub of that method on Float16OptimizerWithFloat16Params, which would make the probe always
succeed.

5. Memory: drop the non-owned high-precision init copies. shard_params() removes non-owned
params from the local optimizer groups, so the Float16 wrapping only clears TE's high-precision init
copy (a full-size CPU tensor per fp8 param) for locally owned params. Without an explicit sweep
every DP rank would retain ~(dp-1)/dp of the LayerWise matrix params' bf16 CPU copies for the whole
run. The sweep is scoped to LayerWise-managed params — sibling DistOpt params must keep their init
value until their own optimizer's master creation consumes it.

6. force_sync finalizes a pending LayerWise gather. A pending LayerWise gather normally lands
in finish_param_sync (the forward pre-hook), which force_sync bypasses. start_param_sync now
finalizes it explicitly — otherwise every rank keeps stale param.data and the gather payload
pollutes grad_data (which the LayerWise path reuses as the all-gather receive buffer).

7. Validation guards (arguments.py): FP8 param gather is supported only on the decoupled layout
and is rejected on the padded LayerWise layout. On decouple it requires fp8_recipe ∈ {mxfp8, blockwise}; fp4_param_gather is rejected unconditionally (the LayerWise gather routes buckets by
is_float8tensor, so an NVFP4 param would silently take the raw flatten path). mxfp8 additionally
requires --reuse-grad-buf-for-mxfp8-param-ag, and the layout requires
num_distributed_optimizer_instances == 1, since the non-DistOpt Muon buffers all-reduce within a
single instance and partial DistOpt would under-reduce Muon gradients across the DP domain.

8. Single all-reduce buffer (a correctness fix this combination exposed). With fp8pg ON,
LayerWise FP8 grads were keyed by torch.uint8 in group_params_for_buffers, splitting Muon's
gradients into two all-reduce buffers (uint8 + bf16) where OFF has one. Both reduce in fp32, but
NCCL's fp32 accumulation order is buffer-size sensitive, so the two-buffer order diverged ~1 ULP from
OFF on the DP-reduced dense FP8 2D weights — amplified by Muon's Newton–Schulz into a visible loss
divergence. Fix: a new merge_layerwise_fp8_grads flag (decouple-only) keys FP8 Muon grads by their
bf16 logical dtype so FP8 + bf16 grads share one fp32 all-reduce buffer, matching OFF. Two
all-reduces collapse to one (no extra communication); NVFP4's packed param buffer is untouched.

9. Mixed fp8/bf16 buckets (a direct consequence of 8). Once fp8 Muon grads key to their bf16
logical dtype, one buffer — and therefore one bucket — can hold both fp8 and bf16 params (e.g. an
fp8 matrix next to a non-quantized MoE router / DSA indexer / mHC weight). The bf16-staged transport
handles both dtypes, so the staging decision is any(is_float8tensor(p) for p in bucket.params_list)
rather than a peek at params_list[0]: a bf16-first mixed bucket would otherwise be routed into the
raw _flatten_dense_tensors() path and crash on the MXFP8 .view(-1).

Correctness — bitwise ON == OFF

Deterministic, clip_grad=0. Compared per-iter lm loss, forward output, per-param reduced
main_grad, fp32 master, and bf16 model param.

setup result
H100, DP8, blockwise, deepseek_v3_proxy, 20 it bitwise identical, all 20 iters
GB200, DP4, mxfp8, deepseek_v3_proxy, 20 it bitwise identical, all 20 iters

(Before the single-buffer fix, loss diverged at it12 on H100 / it3 on GB200; per-param pre-reduce
grads were already identical, isolating the divergence to the DP all-reduce.) With clip_grad=0,
Adam (DistOpt byte-shard, not split) was already bitwise. clip_grad>0 adds a separate
layout-dependent global grad-norm fp32 sum term, so the clip_grad=1.0 benchmarking recipes are not
bitwise — bit-exactness is established at clip_grad=0.

Memory & throughput

GB200 DP64 (mxfp8, deepseek_v4_pro_proxy 8L, TP1/PP1/EP64, SL4K):

arm peak alloc
Muon decouple + fp8pg 105.8 GB
Muon decouple, no fp8pg 116.6 GB
Muon padded (legacy), no fp8pg 142.9 GB
Adam (DistOpt) + fp8pg 106.1 GB
Adam (DistOpt), no fp8pg 116.9 GB

FP8 param persistence −10.8 GB (identical on Muon and Adam); compact-layout padding removal −26.2 GB;
decouple+fp8pg vs padded Muon −37 GB (~26%), and ~11 GB below Adam-no-fp8pg. H100 dev proxy (DP8,
blockwise) reproduces the mechanism: fp8pg −1.1 GB (35,460 vs 36,594 MB); padding 0.0% vs 0.6%.
Refactor integrity: Muon padded / fp8pg OFF vs the pre-refactor DSv4+padded-Muon baseline is
142,858 vs 142,857 MB (1 MB) — the padded path is left equivalent.

Throughput (GB200 wall-clock, comparable within branch): Adam < Muon decouple < Muon padded. Muon
is ~5–6% slower than Adam (Newton–Schulz + uneven all-gather-v); fp8 param-gather itself adds ~1%;
the compact layout is ~4.8% faster than padded.

Parity with the dev stack

The FP8/layout source and test commits from the dev side are carried here:

dev content
#5388 the decoupled compact LayerWise DDP layout (main counterpart #5391, closed into this PR)
#5470 FP8 parameter gather, plus its post-review work: compact-layout default flip + single-source-of-truth per-buffer distopt, gathered-fp8 copy-back skip, force_sync finalize / non-owned init cleanup / fp4 guard, native-fp32 gather dispatch, added MoE + force_sync coverage

The FP8/layout feature symbols were checked symbol-by-symbol against the dev stack.

Main-only adaptations, all intentional:

Golden values regenerated. The dist_dist_muon ckpt-resume goldens are refreshed on this
branch, using CI's own harness (tests/functional_tests/shell_test_utils/run_ci_test.sh) on the
common_pile CI dataset and matching hardware, each running the full ckpt-resume flow (both phases
to iteration 100):

test case platform shape
..._dist_dist_muon dev_dgx_h100 8×H100, EP8
..._dist_dist_muon dev_dgx_gb200 2× GB200 nodes, EP8
..._dist_dist_muon_1node dev_dgx_gb200 1 GB200 node, EP4

Every run reported Exact comparison: FAILED / APPROXIMATE test: PASSED before the refresh — a
reordering, not a regression; peak |rel Δ| on lm loss is ~1.6e-4. (dev's refreshed goldens are
not transferable: main and dev have independently drifted baselines for these cases, ~1e-3
across 94–97 of 100 iters, the same order as the change itself.) They were re-verified in compare
mode at the final tip: all three pass with zero exact-comparison failures.

The two *_param_layout cases now pass --use-layer-wise-param-layout explicitly, since the
compact layout became the default and they exist to cover the padded one; their goldens are
unchanged.

⚠️ For major changes (either in lines of code or in its impact), please make sure to first share a design doc with the team. If you're unsure what's the best way to do so, contact @NVIDIA/mcore-oncall.

Issue tracking

For PRs from open-source community contributors:

  • New features: a linked issue is required. Please open a feature request and reference it here before submitting the PR.
  • Small updates (bug fixes, minor improvements): a linked issue is recommended and will accelerate the PR review process.

Linked issue: Supersedes #5391 (the main-branch prerequisite, closed into this PR)

Contribution process

Pre-checks

  • I have added relevant unit tests
  • I have added relevant functional tests
  • I have added proper typing to my code Typing guidelines
  • I have added relevant documentation
  • I have run the autoformatter.sh on my PR

Unit test: tests/unit_tests/test_muon_decouple_fp8_param_gather.py — bitwise ON-vs-OFF checks on the
decoupled layout (muon + use_distributed_optimizerLayerWiseDistributedOptimizer), asserting
per-step loss / forward output / main_grad / fp32 master / bf16 param are bitwise-identical under
deterministic_mode. Three cases, parametrized over fp8_recipe ∈ {blockwise, mxfp8}:
test_on_vs_off_bitwise_identical (× overlap {OFF, ON}), test_moe_on_vs_off_bitwise_identical
(× overlap × EP), and test_force_sync_finalizes_pending_layerwise_gather. blockwise runs on Hopper
only and mxfp8 on Blackwell and newer, so each platform runs 7 and skips 7. Both halves verified
against the CI Dockerfile.ci.dev image:

suite platform result
test_muon_decouple_fp8_param_gather.py H100 ×8 7 passed, 7 skipped (blockwise; mxfp8 skipped)
test_muon_decouple_fp8_param_gather.py GB200 ×4 7 passed, 7 skipped (mxfp8; blockwise skipped)

Code review

Feel free to message or comment @NVIDIA/mcore-oncall to help accelerate your merge into main. The less complex your PR is, the faster it will be approved and merged!

All PRs start as draft. If you open a non-draft PR, it will be automatically converted to draft.

Step 1: Mark PR as "Ready for Review"

  1. When your PR is ready, click Ready for Review.
  2. An oncall reviewer is auto-assigned and expert reviewers are notified based on your changes.
    • Some PRs may jump straight to step 2. This is determined by .github/CODEOWNERS.

⚠️ Only mark as ready once merge-conflicts are resolved and the CI is passing.
Final Review might get declined if these requirements are not fulfilled.

Step 2: Final Review

For PRs that change megatron/core, once all expert reviewers have approved, the Final Review label is applied automatically and final reviewers are assigned.

For PRs outside megatron/core, this step is skipped.

Step 3: Approved

Once all required reviewers have approved, the Approved label is applied automatically.

Merge

Any member of mcore-engineers will be able to merge your PR.


Dev-branch counterpart: #5470 (merged)

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@Wohox
Wohox force-pushed the muon-decouple-fp8-param-gather-main branch from ae3d338 to 1fc496b Compare June 24, 2026 13:23
@Wohox

Wohox commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 1fc496b

@Wohox
Wohox marked this pull request as ready for review June 24, 2026 14:02
@Wohox
Wohox requested review from a team as code owners June 24, 2026 14:02
@Wohox Wohox changed the title Muon fp8 param gather for decoupled layout [Main] Muon fp8 param gather for decoupled layout Jun 24, 2026
@Wohox Wohox changed the title [Main] Muon fp8 param gather for decoupled layout [main] Muon fp8 param gather for decoupled layout Jun 24, 2026
Wohox and others added 2 commits July 30, 2026 09:40
…int fix

Makes `use_distributed_optimizer` a per-buffer property so the LayerWise (Muon) optimizer
can drop the persistent `dp_size * max(shard_load)` padding from its long-lived param and
grad buffers. LayerWise-managed (Muon 2D-matrix) buffers get a compact no-padding DDP
layout and locally disable DistributedOptimizer semantics (all-reduce gradients,
whole-param ping-pong ownership, `allgather_params` param sync), while sibling buffers
(embeddings, biases, layernorm) keep the standard byte-level DistributedOptimizer path.
The decision is baked into each buffer's own `ddp_config` via `dataclasses.replace`, so
bucket groups inherit it; a bucket group runs one collective type, so `partition_buckets`
asserts that any buckets it merges agree.

This layout is now the **default**: `use_layer_wise_param_layout` defaults to `False` on
both `DistributedDataParallelConfig` and `OptimizerConfig`, and `--use-layer-wise-param-layout`
opts back **into** the padded shard-aligned layout (e.g. for bit-for-bit comparison against
older runs).

Checkpoint fix, which belongs with this layout but was missed when it first landed:

* Grad-buffer range maps are filtered to the params the optimizer instance actually owns.
  On this layout a DistOpt's buffers also carry buckets owned by the LayerWise child;
  unfiltered, the DistOpt builds ranges and state for params it does not own, duplicating
  what LayerWise already saves.
* `sharded_param_state_dp_reshardable` save/load skip buckets this optimizer owns no param
  of. Membership is checked per param rather than via `params[0]`, so buckets mixing owned
  and LayerWise-managed params are handled.
* A DP rank can own a shard that is entirely padding yet still overlaps
  `[0, numel_unpadded)`; such a shard now synthesizes a padding ShardedTensor from a
  captured fp32 template so global coverage holds, and writes it back into `state` (the
  branch rebinds `bucket_state`, so without the write-back the shard was dropped).
* The original strict `empty bucket encountered` assert is kept for pure DistOpt runs;
  empty shards are only expected when LayerWise-managed params co-exist.
* `fully_sharded_model_space` is asserted unsupported on this layout rather than failing
  obscurely later.

The `*_param_layout` functional cases now pass `--use-layer-wise-param-layout` explicitly,
since they exist to cover the padded layout and the default moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
Lets Muon persist and sync its matrix parameters as FP8 (mxfp8 on Blackwell, blockwise on
Hopper) instead of BF16, removing the standing BF16 parameter copy. Combined with the
compact layout this closes the Muon-vs-Adam memory gap while staying bit-for-bit equal to
the fp8-param-gather-OFF run.

FP8 tensors cannot be all-gathered directly (mxfp8's row/col block scales cannot be derived
from one another), so the whole-param transport rides BF16 and requantizes on landing:
stage each owned param from its fp32 master to bf16 (`_stage_param_to_bf16` -- a
high-precision source, not a lossy fp8 dequant), uneven all-gather-v, then requantize the
gathered bf16 into *every* rank's fp8 `param.data` so all ranks hold `Q(bf16(master))`;
`post_all_gather_processing` rebuilds the fp8 columnwise/transpose. The overlap
(`start_param_sync`/`finish_param_sync`) and non-overlap (`allgather_params`) paths share
one copy-back helper, and the staging decision is persisted per bucket so transport dtype
and copy-back cannot disagree. Params are dispatched by transport dtype: fp8 and bf16 ride
the bf16-staged helper, while native fp32 params (`keep_in_fp32`, e.g. the DeepSeek-V4 CSA
`ape`) are gathered in fp32 -- the bf16 path would silently downcast them.

Also in this commit:

* Rank-independent ping-pong ownership. `allgather_params` needs every DP rank to agree on
  each param's single owner; `numel` alone is not a total order, so ownership is keyed by a
  canonical `(chunk_idx, buffer_idx, global_start_index)` identity.
* iter-0 master parity: the fp32 master is seeded from the high-precision pre-quantization
  init, and the master->model copy routes through bf16. Gathered fp8 params are tagged and
  skipped there, since the all-gather's requantize already wrote `Q(bf16(master))`;
  non-gathered fp8 params (MoE experts at `expt_dp == 1`) still get their copy.
* Non-owned params drop TE's high-precision init copy, which every DP rank would otherwise
  retain for ~(dp-1)/dp of the LayerWise matrix params for the whole run.
* `force_sync` finalizes a pending LayerWise gather it would otherwise bypass, which would
  leave stale `param.data` and pollute the reused `grad_data`.
* Single all-reduce buffer: keying fp8 Muon grads by `torch.uint8` split their gradients
  into two all-reduce buffers where OFF has one; NCCL's fp32 accumulation order is
  buffer-size sensitive, so the split diverged ~1 ULP from OFF and Newton-Schulz amplified
  it into visible loss drift. fp8 Muon grads now key to their bf16 logical dtype and share
  one buffer. A consequence is that a bucket can hold both fp8 and bf16 params, so the
  staging decision scans the whole bucket rather than `params_list[0]`.
* Validation: fp8 param gather is accepted only on this layout, requires
  `fp8_recipe in {mxfp8, blockwise}` (fp4 rejected outright), mxfp8 additionally requires
  `--reuse-grad-buf-for-mxfp8-param-ag`, and the layout requires
  `num_distributed_optimizer_instances == 1`.

Golden values for the `dist_dist_muon` ckpt-resume cases are refreshed at this tip
(8xH100 EP8, 2x GB200 EP8, 1x GB200 EP4), regenerated with CI's own harness on the
common_pile dataset. Each reported 'Exact: FAILED / APPROXIMATE: PASSED' beforehand -- a
~1 ULP reordering from the rank-independent ownership sort, not a regression; peak
relative delta on lm loss is ~1.6e-4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
Wohox added 4 commits July 31, 2026 16:37
sharded_param_state_dp_reshardable synthesizes optimizer-state padding for
shards that lie entirely in a bucket's inter-param padding. That synthesis
needs the state's key set and dtypes, which a rank owning no param in the
bucket cannot observe locally.

Derive the template per (gbuf_idx, dtype, bucket_idx) and reconcile it across
the DP group with one all_gather_object. Per bucket is the axis that matters:
dist_checkpointing enforces one dtype per KEY, and each bucket is its own key,
so a template sampled from one bucket is not authoritative for another. The
agreement assert is likewise per bucket, so buckets no rank sampled stay absent
instead of aborting the save.

The template cannot be derived from config: the save path runs the state
through get_unscaled_state (which upcasts bf16/fp16/fp8 state to fp32 and
returns int16 for store_param_remainders), and the key set comes from whatever
optimizer.state holds. `step` is excluded -- it becomes a LocalNonpersistentObject
and never a ShardedTensor.

Device stays out of the gathered template and out of the compare. It is
rank-local: under --optimizer-cpu-offload HybridDeviceOptimizer walks each
rank's own shard list, so the CPU/GPU cutoff lands on a different param on
every rank and a cross-rank compare would abort a legitimate save.
dist_checkpointing validates dtype and shape but never device.

Also reject fully_sharded_model_space up front for the decoupled compact
layout. That format is independently non-functional for every
DistributedOptimizer here -- it sets flattened_range on every non-factory
param and ShardedTensor rejects that unconditionally -- so this assert does
not fix the format; it makes the compact case fail early with an actionable
message instead of dying per-param inside mapping.py. Narrow on purpose.

Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
_allgather_helper read params_list[0][0] to pick the device and dtype for the
collective. The per-rank lists come from the ping-pong assignment and the dtype
split in _dispatch, either of which can leave rank 0's slot empty while other
ranks own params, so this raised IndexError. `any(owned ...)` in the caller does
not prevent it. Take the first non-empty list instead, mirroring the fp8 twin.

The same shape appears in the expert-parallel guards, which decide "there are no
expert params anywhere" from expt_dp_params_list[0] alone and then disable the
expert all-gather entirely. Both sharding paths happen to place the first expert
param in slot 0 (ping-pong starts at expt_dp_idx 0; the layout path gives the
first param of every expert bucket shard_id 0), so the two forms are equivalent
for every reachable configuration today and this fixes no observable bug. It is
a defensive change: it states the intended invariant, and unlike the helper it
would have failed silently rather than loudly.

Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
…sampling

Three of this test's comparisons were passing for the wrong reason.

_is_quantized probed for a `dequantize` attribute, but torch.Tensor.dequantize
is defined on every tensor (it returns self.to(float32) for dense ones), so the
predicate was always True, _snapshot_params always returned {}, and the param
comparison was asserting {} == {}. Key off is_float8tensor and assert the
snapshot is non-empty.

_run_steps then snapshotted with include_quantized=True and fed that to the
ON-vs-OFF comparison, where OFF holds plain bf16 and ON holds Q(bf16(master)):
a dequantized fp32 view of the latter differs from the former by the
quantization step by construction. Drop it. ON legitimately skips its fp8
params while OFF keeps them as bf16, so ON's key set is a subset; pin the
dropped set at step 0 and require it to be unchanged and non-empty, rather than
accepting any shrinkage.

Under overlap_param_gather the param all-gather is deferred to the next forward
pre-hook, so right after step() the buffer still holds pre-update values and
there is no well-defined instant at which ON and OFF can be compared. Finalize
before sampling -- restaging the masters first, since the LayerWise buckets
alias the grad buffer that backward has just filled. Then re-arm: the forced
sync leaves param_gather_dispatched=True, which would turn every subsequent
forward pre-hook into a no-op and silently drop the deferred-gather coverage
this parametrization exists for. Assert the pre-hook is armed so that regression
cannot recur silently.

Finally, the force_sync test compared its two grad_data snapshots for equality,
but both paths end in grad_data.zero_(), so on the happy path that is zeros
against zeros; state the post-condition directly. Its param snapshots were also
asymmetric (one side included quantized storage, the other did not), which made
the key-set assert pass only because both were empty.

Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
setup_model_and_optimizer never set use_layer_wise_param_layout, so the field
was only ever whatever argparse defaulted to and no test could select the
layout. Thread it through both helpers, and guard it against ddp_use_layer_wise
-- the flag DDP actually receives -- rather than against the optimizer-name
derived use_layer_wise. The old guard admitted optimizer='dist_muon' with
dist_opt=False and use_layer_wise_param_layout=True, which builds DDP without
a layout while the optimizer expects the shard-aligned one.

test_layer_wise_optimizer_save_load then gains a LayerWise arm that actually
reaches the layout: dist_opt=True and use_param_layout=True (the DDP-level
routing switch), with use_layer_wise_param_layout=False selecting the compact
side. It is the only fully_reshardable coverage of that layout above tp=pp=1
and the only one with grad_reduce_in_fp32 left at its default. The padded
shard-aligned layout is deliberately out of scope.

Both arms must request a sharding type now that both build a real
DistributedOptimizer. The default fully_sharded_model_space is unusable:
the compact layout is rejected up front, and an ordinary DistOpt dies deeper
in replace() with "ShardedTensor.flattened_range is not supported".
dp_reshardable synthesizes padding with torch.empty, so two saves of identical
state differ in the uninitialized bytes and the terminal check_equal would
flake. fully_reshardable has neither problem.

The plain arm is kept as an in-file control -- test_optimizer.py already covers
a plain bf16 DistOpt under fully_reshardable, and harder resharding cases
besides -- and is labelled as such.

Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
@Wohox
Wohox force-pushed the muon-decouple-fp8-param-gather-main branch from 746ebd8 to 0604c63 Compare July 31, 2026 11:00
Wohox added 2 commits August 1, 2026 09:22
…am-gather-main-merged

# Conflicts:
#	megatron/core/distributed/param_and_grad_buffer.py
…am-gather-main-merged

# Conflicts:
#	megatron/training/arguments.py
@Phlip79

Phlip79 commented Aug 11, 2026

Copy link
Copy Markdown
Member

/claude strict-review

help='If set, initialize with fake distributed process group and all distributed communication operations will be skipped. \
This is quite useful for profiling memory usage of distributed training with just one GPU. \
Setting WORLD_SIZE and RANK to the specific values for target distribtued scale.')
group.add_argument(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add this? isn't the config struct automatically populated into the args?

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this more carefully: this flag is not auto-generated today. Neither OptimizerConfig nor DistributedDataParallelConfig is registered with ArgumentGroupFactory; get_megatron_optimizer_config() and get_megatron_ddp_config() only copy attributes that already exist on the argparse namespace.

Since this PR removes the old --no-use-layer-wise-param-layout flag and introduces the opt-in --use-layer-wise-param-layout form, this explicit parser entry is still required. I’ll keep it.

per_chunk_layouts = [None] * n
if DP is DDP:
if use_layer_wise_distributed_optimizer and use_layer_wise_param_layout:
if use_layer_wise_distributed_optimizer:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to change this?

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change makes ddp_config.use_layer_wise_param_layout the single source of truth for selecting the layout.

When the LayerWise optimizer is enabled, LayerWiseDistributedOptimizer.compute_full_param_layout() needs to run for both values: True builds the shard-aligned padded layout for LayerWise-managed parameters, while False builds the compact no-padding layout for those parameters and retains the standard DistOpt layout for sibling parameters such as embeddings, biases, and layer norms.

Therefore, skipping layout computation when the flag is False would also lose the required per-buffer routing and layout information.

Comment thread megatron/core/fp8_utils.py Outdated
mxfp8 columnwise can't be derived from rowwise, so force columnwise before copy_ (TE rebuilds
both directions from the bf16); blockwise/Float8 columnwise is a lossless transpose.
"""
if is_mxfp8tensor(model_p):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't account for single weight, so we should either support it or ban it properly in the transformer config level

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This copy-back path currently handles plain Float8/MXFP8 parameters, but it does not handle the Transformer Engine single-grouped-weight GroupedTensor representation correctly.

I’ll explicitly reject moe_single_grouped_weight with decoupled LayerWise FP8 parameter gather in config validation, unless we add a GroupedTensor-aware copy-back path and corresponding test coverage.

Comment thread megatron/core/fp8_utils.py Outdated
main_param = getattr(p, "main_param", None)
if main_param is not None:
return main_param.detach().to(torch.bfloat16)
if is_float8tensor(p):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_float8tensor is actually very vague, it could mean many fp8 recipes, so I suggest being a bit more explicit here ( if it's fp8 blockwise recipe, then fp8 blockwise, or mxfp8 tensor, etc.)

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_float8tensor is intentionally a broad abstraction over Transformer Engine quantized tensor implementations, but I agree that it is too broad as the capability check in this path.

The LayerWise parameter-gather path currently supports only MXFP8 and blockwise FP8, so I’ll make that restriction explicit in the predicate/config validation rather than relying on the generic classification.

Comment thread megatron/core/fp8_utils.py Outdated
if main_param is not None:
return main_param.detach().to(torch.bfloat16)
if is_float8tensor(p):
return dequantize_fp8_tensor(p).detach().to(torch.bfloat16)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a numerical lossy operation, when will _stage_param_to_bf16 be used?

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this Muon path, every locally owned source parameter must have an FP32 main_param, so the intended operation is FP32 master → BF16. This is intentionally lossy relative to FP32, but it matches the FP8-param-gather-OFF baseline, which produces Q(bf16(master)).

The FP8-dequant fallback is not expected to execute in this path. I’ll narrow the helper or assert the main_param invariant so that it cannot silently take the lossier fallback.

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_stage_param_to_bf16 is used by the two alternative LayerWise parameter-sync implementations: LayerWiseDistributedOptimizer.allgather_params() for the non-overlap path and _ParamAndGradBucketGroup.start_param_sync() for the overlap path. Only one of these paths is used for a given sync configuration.

arguments layer sets this flag and resets ``use_distributed_optimizer`` to False so
that the standard distributed-optimizer path is not triggered."""

use_layer_wise_param_layout: bool = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you elaborate what does layout mean here?

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, layout means the mapping of each parameter into the contiguous DDP buffer: its start/end indices, bucket assignment, and the padding and shard boundaries used by that buffer.

For LayerWise-managed parameters, the padded layout places every whole parameter inside one DP shard, aligns parameter starts, and pads the shards to the same size. The compact layout instead uses the normal no-padding DDP ordering and keeps whole-parameter ownership in the LayerWise optimizer’s ping-pong assignment.

The layout always determines the grad-buffer placement. It also determines the param-buffer placement when that buffer uses DistributedOptimizer; compact non-DistOpt LayerWise buffers do not allocate a DDP param buffer.

@@ -1110,12 +1131,46 @@ def _copy_model_grads_to_main_grads(self):
model_param.grad = model_param.main_grad

def _copy_main_params_to_model_params(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name is too similar with _copy_main_params_to_param_buffer and why do we need this in the first place?

@Wohox Wohox Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_copy_main_params_to_model_params() is the existing Float16OptimizerWithFloat16Params hook; the method name predates this change and pairs with _copy_model_params_to_main_params().

_copy_main_params_to_param_buffer() has a different role: it is the DistributedOptimizer/MXFP8 staging helper that writes FP32 master shards into the DDP parameter buffer before parameter all-gather.

A compact non-DistOpt LayerWise child does not own that byte-sharded parameter buffer, so after the optimizer step it must use _copy_main_params_to_model_params() instead. The new branch also ensures FP8 parameters are written as Q(bf16(master)) and skips parameters that will already be written by the LayerWise gather.

Wohox and others added 4 commits August 17, 2026 13:54
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
return data

@classmethod
def _filter_gbuf_range_map(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Wohox added 3 commits August 18, 2026 14:03
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
Signed-off-by: Pingtian Li <pingtianl@nvidia.com>
@Wohox
Wohox requested a review from a team as a code owner August 18, 2026 10:00

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't follow the changes in this file.

Could you move these changes to a separate PR and code-comment the why better?

https://google.github.io/eng-practices/review/developer/small-cls.html

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple places that call the high_precision_init_value method, this is to reduce the duplicated code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still confused. The change in this file made the code strictly harder to read -- what does this dedup buy us here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants