Skip to content

feat(mxfp4): switch MXFP4 linears to FP8 at a preset iteration - #1010

Draft
jasainio wants to merge 5 commits into
mainfrom
feat/mxfp4-to-fp8-switch
Draft

feat(mxfp4): switch MXFP4 linears to FP8 at a preset iteration#1010
jasainio wants to merge 5 commits into
mainfrom
feat/mxfp4-to-fp8-switch

Conversation

@jasainio

Copy link
Copy Markdown
Contributor

Summary

Flips every MXFP4 linear to dynamic tensorwise FP8 at a configured iteration, with no checkpoint, no weight conversion and no optimizer state remapping.

Neither precision stores a quantized weight — both inherit a plain BF16 nn.Parameter from Megatron and quantize inside forward — so the switch is purely a change of which autograd Function _forward_impl dispatches to. There is nothing for DDP's bucket groups or the optimizer's identity-keyed state to notice, which is what keeps it within the MLPerf rules.

Two independent bug fixes come first and are worth reviewing on their own. Both repair the baseline MXFP4 Flux path on Primus-Turbo 0.4.1, so they matter to anyone who does not care about the switch:

commit change
cb360974 fix(flux) — cast q/k back to value's dtype after QK norm
7f1b047d fix(mxfp4) — accept BackendChoice from get_gemm_backend
3db3604e fix(runner) — stop discarding non-zero rank output; let caller pin devices
f00e4bc8 feat(mxfp4) — the switch itself

The runner commit is a standalone bug fix: torchrun was always launched with --local-ranks-filter 0, so a crash on any rank other than zero produced no error message at all. That cost seven runs of debugging during this work, because the failing rank had been printing illegal memory access the whole time and it was being thrown away.

Design notes for reviewers

  • The train_step wrapper derives its state solely from the iteration number, so every rank reaches the same decision without communicating. Determinism, idempotence and order-independence are covered by tests.
  • The pre-warm is hosted inside mlperf_warmup because that is the only place that runs a real grad-enabled step at the production micro_batch_size. With automatic_dynamic_shapes on, pre-warming at any other shape would mark dims dynamic and change the compiled graph for the MXFP4 path too.
  • The pre-warm doubles as the only runtime proof the switch is not a silent no-op: it asserts Dynamo traced a new graph, and an empty layer plan raises rather than letting a BF16 fallback masquerade as a successful switch.
  • _switch_-prefixed attributes are deliberately distinct from the existing _fp8_bwd_dtype/_fp8_gran_value/_fp8_backend_value triple, which is passed on every call including the pure-MXFP4 path; reusing it would change traced constants on a path this switch must leave bit-identical.

Test plan

  • 30 new unit tests plus 15 existing MXFP4/FP8 tests pass on MI355X (Turbo 0.4.1), no regressions
  • Baseline MXFP4 trains stably after the QK-norm fix (222 iterations, zero NaN)
  • End-to-end switch at MBS 16 and MBS 32 on 8 GPUs, global batch 512: 57/57 layers flip, loss continues without a spike, zero NaN and zero skipped iterations
  • End-to-end switch at production MBS 64, global batch 512
  • Graph count: the whole 57-layer model needs 3 additional Dynamo graphs (unique_graphs 8 -> 11)
  • Saved activations grow 0.531 -> 1.005 bytes/element, matching the projection
  • Full-length convergence run at the production config

Known gaps

No full-length convergence run has completed. Every 8-GPU attempt at MBS 64 was killed by a hardware fault on one GPU of the test node (hipErrorIllegalAddress, SQC (inst) fetch at address zero, following physical GPU 2 across rank permutations and disappearing when that GPU is excluded). The MBS 64 validation above was therefore run with that GPU excluded. This is unrelated to the change set, but it means production-config convergence and post-switch steady-state performance numbers are still outstanding.

The Q/K RMSNorm can return a wider dtype than it was given, leaving q/k in
fp32 while v stays bf16. Every dense flash-attention backend rejects a
mixed-precision (q, k, v) triple rather than casting for us, so the MXFP4
Flux config could not train at all on a Primus-Turbo new enough to expose
the merged FlashAttnFunc.

Reference Flux performs this same cast inside QKNorm; the Megatron port had
dropped it. Applied to both streams of JointSelfAttention and to
FluxSingleAttention.
Primus-Turbo PR #447 changed get_gemm_backend() from returning a bare
BackendType to returning a BackendChoice carrying its own auto_tune flag.
Comparing a BackendChoice against a BackendType silently evaluates False,
which turned every MXFP4 run into a preshuffle-contract failure on Turbo
0.4.1.

Read through either shape so the same code works on both the newer Turbo the
Flux local spec requires and the older pinned ones still in use elsewhere.
…ices

torchrun was always launched with --local-ranks-filter 0, so only local rank
0 reached the console and a crash on any other rank produced no error message
at all. Diagnosing a GPU fault on rank 2 took seven runs because its
"illegal memory access" was being thrown away every time. PRIMUS_LOCAL_RANKS_FILTER
now overrides the default, with "all" keeping every rank.

HIP_VISIBLE_DEVICES was also overwritten unconditionally, silently clobbering
a value passed in from outside. A caller-supplied value now wins, which is what
allows a specific device set or ordering to be pinned -- needed to tell a
rank-specific software fault apart from a failing GPU.

Both default to the previous behaviour when the variables are unset.
Flips every MXFP4 linear to dynamic tensorwise FP8 at a configured iteration,
with no checkpoint, no weight conversion and no optimizer state remapping.

Neither precision stores a quantized weight -- both inherit a plain BF16
nn.Parameter from Megatron and quantize inside forward -- so the switch is
purely a change of which autograd Function _forward_impl dispatches to. There
is nothing for DDP's bucket groups or the optimizer's identity-keyed state to
notice, which is what keeps it within the MLPerf rules.

The train_step wrapper derives its state solely from the iteration number, so
every rank reaches the same decision without communicating; determinism,
idempotence and order-independence are covered by tests.

The pre-warm is hosted inside mlperf_warmup because that is the only place
that runs a real grad-enabled step at the production micro_batch_size. With
automatic_dynamic_shapes on, pre-warming at any other shape would mark dims
dynamic and change the compiled graph for the MXFP4 path too. It doubles as
the only runtime proof the switch is not a silent no-op: it asserts Dynamo
traced a new graph, and an empty layer plan raises rather than letting a BF16
fallback masquerade as a successful switch.

Validated on MI355X: 57/57 layers flip at the boundary, the whole model needs
3 additional Dynamo graphs, and loss continues without a spike. Saved
activations grow 0.531 -> 1.005 bytes/element, matching the projection.
Formatting only, no behaviour change. Satisfies the code-lint gate, which
runs black with --line-length=110.
@gphuang

gphuang commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Note: FlashAttnFunc import fails on the v26.5 image, silently disabling MXFP4 and FP8

Running this branch (0d72e064) on the rocm/primus v26.5 image (sha256:3040bf42…), the MXFP4 local spec provider never loads and the model builds plain Megatron linears. Both MXFP4 and FP8 quietly degrade to BF16, with nothing in the log to say so.

Cause. primus_turbo_local_spec.py line 32 imports the merged FlashAttnFunc:

ImportError: cannot import name 'FlashAttnFunc' from
'primus_turbo.pytorch.ops.attention.flash_attn_interface'

That image ships primus_turbo 0.3.2.dev48, which still exposes the split AiterFlashAttnFunc / TritonFlashAttnFunc. Stock Primus at the image's own HEAD (b511d1b6) imports AiterFlashAttnFunc on that same line. A comment in this branch's primus_turbo_mxfp4_local.py notes a Turbo new enough for the merged symbol is required, but nothing asserts it at import or launch.

Why it is silent. flux/layer_spec.py binds the provider inside a bare except ImportError: PrimusTurboMXFP4LocalSpecProvider = None, with no log line, and the same failure clears HAVE_PRIMUS_TURBO_LOCAL. Backend selection then falls past both the MXFP4 and the FP8 branch to LocalSpecProvider().

With the switch enabled the guard does eventually fire, but it misdiagnoses:

mxfp4_to_fp8_switch_iter > 0 but no MXFP4 linear was found in the model ... Check that fp4 is set and that transformer_impl selects the MXFP4 local spec.

In that run fp4: mxfp4 and transformer_impl: local were both correct and Using local transformer implementation was logged, so the message sends you to the recipe rather than the import. With the switch disabled there is no guard at all — plain MXFP4 training on this branch and image runs BF16 while loss and throughput both look plausible.

Workaround. Rewriting the symbol to AiterFlashAttnFunc is sufficient; the import and one torch._dynamo.allow_in_graph() hint are its only two uses. The provider then loads, 57 MXFP4 layers are planned, and 57/57 flip to FP8 at the boundary with loss finite across the switch.

Two fixes worth considering independently of the version pin: emit a warning in that except ImportError instead of binding None, and assert the required primus_turbo version so the mismatch fails loudly at startup rather than as a precision silently downgraded mid-run.

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.

2 participants