Skip to content

feat(mxfp6): MXFP6 (E2M3) Flux linears, a fused MLP, and the MLPerf recipe - #1064

Draft
jasainio wants to merge 19 commits into
fix/flux-eval-correctnessfrom
feat/mxfp6-fused-mlp
Draft

feat(mxfp6): MXFP6 (E2M3) Flux linears, a fused MLP, and the MLPerf recipe#1064
jasainio wants to merge 19 commits into
fix/flux-eval-correctnessfrom
feat/mxfp6-fused-mlp

Conversation

@jasainio

Copy link
Copy Markdown
Contributor

Summary

Adds MXFP6 (E2M3 with E8M0 block-of-32 scales) as a precision for Flux on gfx950,
via Primus-Turbo and AITER's A6W6 kernels, plus the MLPerf recipe that runs a
submission campaign at it.

  • A new local spec provider rather than an option on the MXFP4 one. MXFP6 has
    one backend, a mandatory Hadamard rotation folded into the packer, and no
    preshuffle choice, so MXFP4's preshuffle contract and recipe flags have nothing
    to guard and are dropped rather than retargeted. fp6 is a separate config
    field for the same reason: overloading fp4: "mxfp6" would let a config select
    the MXFP4 provider while skipping its preshuffle check and produce a
    plausible-looking run in the wrong precision. Megatron never sees fp6, so
    BaseDiffusionConfig rejects it alongside fp4, fp8, and
    mxfp4_to_fp8_switch_iter.

  • The MLP is one autograd Function, so its activation never reaches HBM.
    Splitting it in two forces the activation to exist: fc2's forward needs a real
    tensor and fc1's backward needs one back. Owning fc1 through fc2 in a single
    Function lets the MXFP6 packer take the epilogue as a prologue in both
    directions, packing straight out of LDS and never assembling the pre-activation
    gradient at all. Worth 74.1 to 78.0 images/s/GPU on Flux 12B at micro_batch_size
    64. It engages through an mlp_module() hook the Flux specs look for and fall
    back from, so no other backend is affected, and anything it cannot reproduce
    exactly falls back per module.

  • Q and K are realigned onto V's dtype after the QK-norm. On the v26.5 image
    the norm hands back its fp32 accumulation dtype under torch.compile, and only
    Q and K go through it, so Turbo's dense flash-attention backends see a dtype
    they reject. The dispatcher reports that as "No compatible backend found",
    listing shapes and never mentioning dtype. The casts stay inside the compiled
    region where they fuse into the norm's epilogue.

  • The fused-MLP tests pin the mode instead of reading it from the shell. The
    fallback tests assert that an unusable configuration warns and defers, which is
    auto behaviour; under on the same configuration raises. The submission
    container exports PRIMUS_MXFP6_FUSED_MLP=on so a fallback there cannot pass
    unnoticed, which meant these tests failed in the one environment where running
    them matters most.

  • The MLPerf recipe is the FP8 one with the precision block swapped, so
    comparing the two compares numerics rather than two independently drifted
    configurations. It cannot produce a submittable log yet and says so: the
    compliance checker accepts a fixed vocabulary for
    lowest_numerical_precision_in_linear and mxfp6 is not in it, so runs are
    valid runs whose logs are rejected on that one key until the format is accepted
    upstream.

Stacking

Based on fix/flux-eval-correctness (#1055) rather than main, so this diff is
the MXFP6 work alone. Needs retargeting to main once that merges.

Note the eval RNG re-keying in #1055 changes which noise every evaluation draws,
so the MXFP6 recipe added here inherits new eval numerics and its convergence
evidence needs regenerating alongside the FP8 recipes'.

Test plan

  • 30 MXFP6 unit tests and 6 Flux integration tests on MI355X: forward and both
    gradients at ~28 dB SNR against BF16, and the linear bit-identical to
    Primus-Turbo's FP6GemmMXFunction in both directions
  • 494 config tests, covering the fp6 rejections against fp4, fp8, and
    mxfp4_to_fp8_switch_iter
  • Fused-MLP A/B on Flux 12B, 8x MI355X: 75.3 ms/step of epilogue and reduction
    kernels leave, 35.5 ms/step of prologue cost joins the packer, GPU busy at
    96.8% so it lands as wall clock
  • Megatron backend suite after rebasing onto fix(flux): make MLPerf validation measure what it claims to #1055: 609 passing, with the 14
    pre-existing failures in synthetic-dataset and FSDP2-optimizer tests unchanged
  • pre-commit run over the branch
  • Reviewer check: gfx950 only. The A6W6 kernels are assembly with no fallback
    path, and an aiter lacking them fails at startup rather than quietly training
    in BF16
  • Convergence evidence for the new MLPerf recipe, pending the eval-numerics
    change noted above

Wires Primus-Turbo's gemm_fp6 into Flux through a new local spec provider,
mirroring the MXFP4 path but without its configuration surface: MXFP6 has one
backend (AITER A6W6), a mandatory Hadamard rotation folded into the packer, and
no preshuffle choice, so the preshuffle contract and recipe flags have nothing
to guard and are dropped rather than retargeted.

fp6 is a separate config field rather than an fp4: "mxfp6" overload. Overloading
fp4 would let an MXFP6 config select the MXFP4 provider while skipping its
preshuffle check, producing a plausible-looking run in the wrong precision.
Disjoint fields make that unrepresentable, at the cost that Megatron never sees
fp6 -- so BaseDiffusionConfig rejects fp6 with fp4, with fp8, and with
mxfp4_to_fp8_switch_iter, whose patch would otherwise build a plan over zero
layers and silently never switch.

Every MXFP6 GEMM needs M, N and K to be multiples of 256, K included because the
backward GEMMs use it as an output dimension. Flux 12B satisfies this at every
replaced linear.

Two test-quality issues the MXFP4 suite this was cloned from also has, hence the
different assertions here:

- Flux zero-inits proj_out and every adaLN_modulation tail, so a fresh model
  outputs exactly zero and gives all 12 MXFP6 linears an exactly zero gradient.
  Asserting "grad is not None" passes in that state whether or not the quantized
  GEMM ran. The integration test breaks the zero-init first and asserts non-zero
  gradients, keeping a test for the inert-at-init premise itself.
- A BF16 SGD loop cannot show convergence: the update is ~7e-6 relative to the
  weight while BF16 resolves ~8e-3, so steps round away and the loss is
  bit-identical regardless of gradient correctness. The loop steps an FP32 master
  copy, as Megatron's optimizer does.

Verified on MI355X: 30 unit + 6 Flux integration + 494 config tests pass.
Forward and both gradients land at ~28 dB SNR against BF16, and the linear is
bit-identical to Primus-Turbo's FP6GemmMXFunction in both directions.
Splitting the MLP into two autograd Functions forces the activation to exist:
fc2's forward needs a real tensor and fc1's backward needs one back. Owning
fc1 -> epilogue -> fc2 in a single Function lets the MXFP6 packer take that
epilogue as a prologue in both directions, so the activation is packed straight
out of LDS and the pre-activation gradient is never assembled at all. The bias
gradient comes back as a per-column sum from the packer, because it is a
reduction over exactly the tensor that no longer exists.

Worth 74.1 -> 78.0 images/s/GPU on Flux 12B, 8x MI355X at micro_batch_size 64:
75.3 ms/step of epilogue and reduction kernels leave, 35.5 ms/step of prologue
cost joins the packer, and the step is GPU bound at 96.8% busy so that lands as
wall clock. Peak allocated memory moves by the column-sum buffer alone.

Engages through a new mlp_module() hook that the Flux specs look for and fall
back from, so no other backend is affected. Anything the fused path cannot
reproduce exactly -- gated linear units, a non-tanh GELU, an FP8 backward --
falls back per module, and PRIMUS_MXFP6_FUSED_MLP forces either side for A/B
measurement.

Also ignores throwaway _*.yaml run recipes and ut_out/, which were showing up as
untracked in every status.
On the v26.5 image the QK-norm hands back its fp32 accumulation dtype
under torch.compile instead of the input dtype. Only Q and K go through a
norm, so V still carries the intended dtype while Q and K arrive as fp32,
and Turbo's dense flash-attention backends all require fp16/bf16. The
dispatcher reports that as "No compatible backend found for
FlashAttnDenseDispatcher", listing shapes and never mentioning dtype,
which sends you looking at the wrong thing.

Cast against V in both attention classes: the joint blocks project QKV
themselves, and the single blocks get an override since they reuse
Megatron's projection. Both casts stay inside the compiled region, where
they fuse into the norm's epilogue rather than costing a separate pass
over Q and K -- doing this at the attention call instead measured 14.5ms
per step, about 1.8% of a Flux 12B MXFP6 step.
…from the shell

The two fallback tests assert that an unusable configuration warns and defers to the
stock MLP, which is the behaviour of the default "auto" mode. Under "on" the same
configuration raises instead, and the submission container exports
PRIMUS_MXFP6_FUSED_MLP=on so that a fallback there cannot pass unnoticed -- so both
tests failed in the one environment where running them matters most.

Pin the mode per test rather than trusting the ambient value. The kill-switch test still
sets it explicitly and is unaffected.
… too

Same ambient-environment problem as the unit tests, reached by a different route.
FluxConfig's default activation is a hand-written tanh GELU that the fused prologue does
not reproduce, so every model these tests build takes the fallback path -- which is an
error rather than a fallback under the submission container's
PRIMUS_MXFP6_FUSED_MLP=on, failing all six at layer construction.
The MXFP6 work had a development recipe and the MLPerf work had an FP8 recipe,
and nothing in between. This is the FP8 MLPerf config with the precision block
swapped and nothing else moved, so that comparing the two is comparing numerics
rather than comparing two independently drifted configurations.

Three settings differ from the MXFP6 development recipe and are deliberate.
FP32 optimizer states instead of BF16, matching the FP8 MLPerf recipe: time to
train is what is being measured, and FP32 states cost nothing in throughput
here while removing a source of convergence difference from the comparison.
emulate_precision_casts off, since it models FP8 rounding inside compiled
regions and there is no FP8 cast on this path. nemo_aligned_lr_warmup written
out explicitly rather than left to the default, because the FP8 MLPerf recipe
and the MXFP6 development recipe disagree on it and the disagreement is a
different LR trajectory through warmup, not a stylistic difference -- it stays
on the setting the convergence evidence was collected under until there is a
reason to move it.

The recipe cannot produce a submittable log yet, and says so: the compliance
checker accepts a fixed vocabulary for lowest_numerical_precision_in_linear and
mxfp6 is not in it. Runs under this recipe are valid runs whose logs are
rejected on that one key until the format is accepted upstream.
@jasainio
jasainio force-pushed the feat/mxfp6-fused-mlp branch from ad7f482 to ccd4d18 Compare September 1, 2026 12:23
jasainio and others added 13 commits September 2, 2026 09:38
The empty T5/CLIP encodings are plain trainer attributes rather than registered
buffers, so nothing in the training stack ever moves them, and they are loaded from
.npy or torch.randn straight onto the host. The CFG dropout branch in forward_step
therefore ran empty_t5_encodings.to(device="cuda", ...) on every step, copying the same
never-changing tensors host-to-device for the life of the run.

That is wasted work in normal operation and fatal under CUDA graph capture, where an
unpinned host-to-device copy inside the captured region raises "Cannot copy between CPU
and CUDA tensors during CUDA graph capture" -- so full-iteration capture is unreachable
for any run with cfg_dropout_prob > 0 while the copy is there. Moving them once at
setup, in the run's compute dtype, turns the per-step .to(...) into an identity and
leaves the numerics untouched.

Both branches that populate the encodings do it: the discovered-encodings path and the
mock_data path. The third branch raises, so there is nothing to move.
The MXFP6 backward allocated each weight gradient and handed it to autograd as
param.grad, leaving Megatron's DDP hook to add it into main_grad and free it. With
gemm_fp6_out_impl the A6W6 asm writes main_grad itself and the hook has nothing left to
do. On the MBS=32 GBS=256 Flux 12B arm (8x MI355X) that is 974.2 -> 958.8 ms per 512
images, 15.4 ms or 1.6%, and it narrows the MBS=32 shortfall against MBS=64 from -15.3%
to -13.9% images/s/GPU. Loss is bit-identical at every iteration, which is the expected
outcome rather than a tolerance: the store computes the same GEMM into the same buffer
the add_ was filling from zeros. The trace confirms the mechanism exactly -- aten::add_
-304, gemm_fp6_impl -304, gemm_fp6_out_impl +304, 17.6 ms less elementwise GPU time, and
total CPU op count unchanged.

Gated on a new Primus-owned mxfp6_fused_wgrad_accum rather than Megatron's
gradient_accumulation_fusion, which was the first thing tried and is the wrong knob.
Megatron's flag is read by every plain linear, so switching it on also routes Flux's 76
AdaLN modulation projections through wgrad_gemm_accum_fp16, and at their M=32 shapes that
kernel costs more than the separate add it replaces: 487 -> 542 ms/step, +11%, with a
changed accumulation order that showed as a drifting loss. That is a finding beyond this
change -- Megatron's fused wgrad is a pessimisation at Flux's AdaLN shapes on this stack.
The MXFP6 linears therefore still reject the Megatron flag outright, now with a message
naming the field to use instead.

grad_added_to_main_grad is set from the forward, in _claim_main_grad, which reads oddly
and is load-bearing. Setting it next to the store, in the Function's backward, makes
dynamo refuse the mutation ("Mutating a variable from outside the scope of this HOP is
not supported") -- and rather than failing it breaks the graph around every MXFP6 linear:
240 breaks in a 14-iteration run, all 152 non-MLP linears eager, 3338 extra elementwise
kernels and +42.7 ms per 512 images against the ~16 ms the fusion saves. From the forward
the same assignment is ordinary traced code that dynamo records and replays. Setting it
before the store is safe in this direction only, because the flag is read by the DDP
backward hook, which cannot run before the backward has stored, and zero_grad_buffer
clears it every step; Megatron relies on the same asymmetry under TE CUDA graphs.

Three constraints are enforced per module rather than assumed, so a misconfiguration
cannot read as a performance result: one microbatch per optimizer step, since the beta=0
store overwrites instead of accumulating; a bf16 main_grad, since the asm writes nothing
else; and the pure-MXFP6 backward, since the FP8 path forms its wgrad with gemm_fp8_impl,
which has no out variant. The backward still returns a placeholder gradient because the
DDP hook asserts param.grad is not None whenever overlap_grad_reduce is on, matching what
Megatron's own fused path does; it is never read, and it costs 159.5 -> 165.1 GB peak.

Ships off. The MBS=64 arm is equally eligible -- one microbatch, bf16 main grads -- but
flipping a shipped default deserves its own measurement.

Six tests cover it: that the field and not Megatron's flag is what enables the fusion,
that the gradient lands in the real main_grad for both the plain linear and both MLP
matrices, that a missing or non-bf16 main_grad is refused, that FP8 backward is refused,
and that the compiled region stays whole. That last one only means something because it
traces with a main_grad attached and an input with requires_grad=True; without the input
there is no autograd graph, dynamo never reaches the backward, and the test reports zero
breaks whatever is in there -- which is how its first version passed while production was
breaking 240 times.
The MXFP6 linears are biased, and the caller was adding the bias after the
Function returned. That put the bias outside autograd's view of the op, so the
bias gradient became its own reduction over grad_output -- a two-stage Triton
reduction per linear, on a tensor the backward is already streaming end to end
for quantization.

Moving the bias into MXFP6LinearFunction lets the backward take it as a side
output of the pack instead. The packer gains an Identity prologue and
want_col_sum, and grad_bias is the finishing .sum(0) over its partials.
Identity rather than a real prologue because there is no activation to undo
here, unlike the MLP's fc1, which already did exactly this.

Measured -4.4 ms per 512 images at MBS=32 over three repeats with
non-overlapping groups, from -7.5 ms of kernel time: -7.2 ms of Triton
elementwise and 152 fewer launches, against +0.6 ms in pack/quantize. Four
kernels go outright, the two-stage qkv bias-gradient reduction on both streams.

The forward is unchanged arithmetic -- the add simply happens inside the
Function now -- and bias grad, input grad, weight grad and forward are all
bit-identical to the old arrangement at the real Flux shapes.

The fp8-backward path runs no packer, so it keeps paying for its own
grad_2d.sum(0); it is not the configuration this is for.

Note this does not close the MBS=64 gap. The traffic is token-proportional and
MBS=64 gains more from the same change (-7.7 ms), so the ratio moves the wrong
way. It is worth having for absolute MBS=32 throughput.
With 2c the bias is an input to MXFP6LinearFunction, but the forward still paid
a separate elementwise pass to add it: read the whole [M, N] result, add N
values, write it back. aiter's A6W6 GEMM can now do that in its store epilogue,
where it is free -- the epilogue is bound by its scatter store rather than by
VALU, so the add hides in issue slots the stores were already stalling through.

Measured -11.3 ms per 512 images at MBS=32, three repeats per arm interleaved,
groups non-overlapping. The trace confirms the mechanism rather than inferring
it: triton_poi_fused_add_view_1 (10.6 ms, 150 launches) and _2 are gone
entirely, and total kernel time falls 12.5 ms against the 10.8 ms directly
deleted.

Nothing is gated here and this layer knows no aiter version. Whether the bias
can actually be folded is a property of the installed aiter, so Primus-Turbo
probes gemm_a6w6's signature for the parameter and adds the separate pass
itself when it is absent. Passing the bias unconditionally is therefore always
correct, and there is no environment variable.

Kernel side: ROCm/aiter#5236.

The result is also slightly more accurate than what it replaces, which is why
the new test compares against a double-rounding bound rather than bitwise. The
separate add rounded the GEMM result to bf16 and then added, rounding twice;
the epilogue adds into the fp32 accumulator and rounds once.

Like 2c this is token-proportional, so MBS=64 gains comparably and it does not
close the gap between the two operating points.
…nels

Left to discover the fabric itself, RCCL plans 2 channels between nodes and
inter-node bandwidth measures 46 GB/s. The Crusoe topology XML declares which
rail is local to which GPU; with it the plan widens to 8 and the same benchmark
measures 118 GB/s, a 2.56x that shows up directly in two-node step time.

Resolved by GPU PCI device id, never by filename. The mi350x and mi355x files
differ only there (0x75a0 against 0x75a3) and a mismatched file still parses, so
RCCL applies it and silently loses the affinity the file exists to declare -- a
wrong filename is worse than no file, because it looks configured. Read from
sysfs rather than lspci, which is a package and not present in every image.
/etc/crusoe is not mounted in every container, so a staged copy at
/opt/rccl_topo_node.xml is accepted as a fallback.

NCCL_NET_GDR_LEVEL=LOC is set in the same branch and must stay coupled to it.
GPU-Direct RDMA registration fails on this fabric -- every ibv_reg_mr_iova2
returns EINVAL -- and the topology file is the first configuration that makes
RCCL attempt it, so setting one without the other kills every rank during
connection setup. The 2.56x comes from the wider plan and does not depend on GDR.

PRIMUS_RCCL_TOPO_DISABLE=1 restores the previous behaviour exactly, so the A/B
stays measurable from outside the launcher, and as an escape hatch if a future
node ships a file that plans worse than RCCL's own discovery.

NCCL_DMABUF_ENABLE becomes overridable rather than forced to 0, so the dmabuf
registration path can be tried without editing the launcher.

Refs: docs/mlperf/rccl_gdr_escalation.md
Co-authored-by: Cursor <cursoragent@cursor.com>
…sume

Megatron's gradient buffers learn, on their first batch, how many times each
parameter registers a ready gradient; from the second batch on they issue a
bucket's reduce-scatter only when that golden count is reached again. The MLPerf
warmup steps are batches like any other, so they consume the calibration: the
golden counts end up describing a synthetic step rather than the first real one.
When the real steps then register a different number of times -- a different
microbatch count is enough -- the bucket either fires early, and the next
registration finds a collective already in flight, or never reaches the golden
count and the reduce-scatter is never issued at all.

Reset every bucket group back to is_first_batch with empty counts, so the first
real step performs the calibration it would have performed without a warmup.

Any outstanding collective is drained first. #1069 already waits on the warmup's
own reduce-scatter, so in practice the drain count is zero on this stack, but the
handle belongs to a synthetic step whose gradients are about to be discarded and
leaving it in flight would hand the first real step a bucket that is busy for
reasons it cannot see. Draining here also keeps the reset correct if it is ever
applied on top of code without #1069.

Two-node MXFP6 runs reset 43 bucket groups at MBS 32 and GBS 512.

Co-authored-by: Cursor <cursoragent@cursor.com>
Megatron's forward_step takes the first element of what a loss function
returns as the tensor to backpropagate and rescales it IN PLACE --
`output_tensor *= cp_group_size`, then `output_tensor /= num_microbatches` --
and only then stores the reported dict. The diffusion validation path put
`loss_sum.detach()` in that dict, and a detached tensor shares storage with
the one it came from, so the reported loss was rescaled along with it.

What reached the caller was therefore the true validation loss divided by the
number of microbatches. At one microbatch per rank per step the divisor is 1
and nothing shows, which is why every shape measured so far looked right. At
two it halves, and under mlperf_mode the halved value is what the convergence
gate is compared against -- so a run reports convergence at roughly half the
samples it actually needed, and reports it as a pass. Measured on Flux 12B at
matched global batch 512: micro batch 32 reported 0.630950 at step 100 where
micro batch 64 reported 1.266634, a ratio of 2.007. With this fix the two
agree to 0.7%, and to 0.01% by step 200. Context parallelism has the mirror
problem, inflating the reported loss by cp_group_size.

The training path a few lines below already clones for this reason.

Carries the regression test with it: the test rescales the returned tensor
exactly as Megatron's forward_step does and asserts the reported pair does
not move, so a future detach-only return fails rather than silently halving
the number the convergence gate reads.

Co-authored-by: Cursor <cursoragent@cursor.com>
Nine seeds converged under this configuration on 2x8 MI355X -- 20131, 21139,
22147, 23155, 24163, 25171, 26179, 27187, 28195 -- every one reaching val_loss
<= 0.586, at a mean of 7,718,684 samples and about 2.4 h per seed. Until now it
existed only as a generated file in a scratch directory that .gitignore excludes
(examples/megatron/configs/**/_*.yaml), so reproducing a two-node run meant
obtaining that file out of band and the recipe the results belong to was not in
the repository.

Differs from the single-node MLPerf recipe in the batch shape and what the batch
shape forces: micro_batch_size 32 at global_batch_size 512 is one microbatch per
rank at 16 ranks, and lr 2.0e-4 with lr_warmup_iters 1600 are the flux_ref_512
reference values rather than the GBS 1024 pair. Neither field is constrained by
the ruleset, so carrying the wrong pair passes every compliance check and
quietly forfeits the reference points the run is scored against.

The rest of the deltas are corrections that apply at any shape and are documented
inline: do_valid must be set explicitly because the flux path never calls
build_train_valid_test_data_loaders, eval_samples must be used instead of
eval_iters so the coverage assertion runs, and eval_timestep_source must be
dataset or evaluation silently falls back to equidistant timesteps.

Seed, experiment name, TensorBoard directory and data path are environment
parameters, so one file serves a whole campaign.

Co-authored-by: Cursor <cursoragent@cursor.com>
Megatron's 256000000-element default is next to worst of the useful range
on Flux 12B. Sweeping seven sizes, 1024000000 is worth -17.4 ms per 512
images at the MBS=64/GBS=512 this recipe ships (78.8 -> 80.6 images/s/GPU)
and -27.9 ms at MBS=32/GBS=256, where twice as many optimizer steps make
twice as many collective calls. Peak memory is unchanged at 250.8 GB.

The mechanism is recovered overlap, not faster math: exposed collective
time falls from 17.8 to 4.0 ms per 512 images at MBS=64, so 96.5% of
collective time is hidden against 85.2%, while compute stays flat in every
arm.

Both ends of the sweep lose, which is why the comment in the config says
not to push this further in one direction. Small buckets pay per-call
overhead and get thin per-call bandwidth. Buckets spanning most of the
model cannot start their reduce-scatter until far into backward, so the
tail is re-exposed -- 2048000000 measures 13.9 ms exposed against
1024000000's 4.0. 1024000000 is where the two costs cross, and the optimum
is flat enough that 2048000000 is within run-to-run drift.

Measured with the winner and the default each run twice at both batch
sizes; replicate pairs differ by 0.5 to 1.8 ms against a 17-28 ms effect.
The MLPerf v6.0 reference moved the same knob in the same direction, 256M
to 512M, and 512M is also an improvement here -- just not the best one.

Only this recipe changes, and deliberately not the MLPerf one, even though
it is the same model at the same batch size. The sweep ran with
main_grads_dtype bf16, which is what this recipe uses; the MLPerf recipe
sets fp32, so an identical element count is twice the bytes per call. Bytes
are what set both per-call bandwidth and how early a bucket can flush, so
the optimum in elements should not be assumed to carry across that change
-- 1024000000 at fp32 sits closer in bytes to the 2048000000-at-bf16 arm,
which is already past the peak. That recipe needs its own sweep. The fp8,
mxfp4 and te_spec recipes are untouched for the same reason.
…gime

The previous commit deliberately left this recipe alone. ddp_bucket_size
counts elements, but what governs per-call bandwidth and how early a bucket
can flush is bytes, and this recipe sets main_grads_dtype fp32 where the
development recipe sets bf16 -- so 1024000000 here is twice the bytes it is
there, close to the 2048000000-at-bf16 arm that had already regressed.
Rather than assume either way, swept again in this regime.

The concern did not materialize. 1024000000 is the optimum in both, and the
fp32 plateau is merely wider:

  256000000   1110.2 ms / 512 img   267.0 GB   (the default, worst measured)
  512000000   1092.2                267.1
  1024000000  1086.8                266.0
  2048000000  1086.7                266.3

Worth -23.4 ms per 512 images with peak memory very slightly down, measured
with the two smaller sizes each run twice; replicate pairs differ by 0.9 and
3.6 ms. 512000000, the value the MLPerf v6.0 reference moved to, does help
but is not the best here.

Also corrects a claim in the optimizer block that this sweep happened to
measure. The comment asserted FP32 optimizer states "cost nothing in
throughput here", which was never measured and is not true: two arms
differing in nothing but those five lines give 1110.2 ms per 512 images for
FP32 against 812.4 for BF16, +36.7%, plus about 21 GB of peak memory. The
setting stays, because convergence comparability against FP8 is the whole
point of this recipe and time to train is not throughput -- but it is the
most expensive line in the file and the comment now says so.
The two commits before this one justified sweeping ddp_bucket_size
separately for this recipe on the grounds that it reduces fp32 gradients
where the development recipe reduces bf16, so an identical element count
would be twice the bytes per call. That reasoning is wrong, and the config
is what misled me: main_grads_dtype reads fp32 here, but it belongs to the
precision-aware optimizer and is inert when use_precision_aware_optimizer
is false, which it is.

The resolved runtime arguments say so plainly. Both recipes log
DistributedDataParallelConfig(grad_reduce_in_fp32=False, ...) and both
build 43 buckets with identical element counts at the same bucket size, so
the collective path -- the only thing ddp_bucket_size acts on -- is the
same in both. Nothing about the bucket optimum could have differed for the
reason I gave, and the two recipes agreeing on 1024000000 is the expected
outcome rather than the lucky one.

None of the measurements change. The sweep in this regime stands on its own
numbers, and re-running it was still the right call, just for a weaker
reason than the one recorded: this recipe's optimizer costs 36.7% of step
time, so the balance between exposed collective time and everything else
is different enough to be worth scoring rather than assuming.

The +36.7% figure needs its attribution fixed for the same reason. It is
the cost of FP32 m/v and the standard optimizer path, not of FP32
gradients, since the gradients DDP reduces are bf16 in both recipes.
The three numbers 8688146 and 4bc9bc6 put in this file are wrong, and
wrong for a dull reason: I averaged the per-iteration times with a throwaway
script instead of the harness that every other number in this campaign came
from. Each arm contains one profiled iteration of about 3700 ms against a
~800 ms step, the filter I thought I had written was a no-op, and the result
was ~290 ms of pure artifact added to all six arms uniformly.

Scored with summarize_mbs.py, which drops the profiled steps:

  256000000   819.3 ms / 512 img   267.0 GB   (the default, still worst)
  512000000   806.4                267.1
  1024000000  801.9                266.0
  2048000000  801.1                266.3

The decision does not change -- 1024000000 is still the optimum and still
ships -- but the size does: -17.4 ms per 512 images, not the -23.4 claimed.
Which is a better result than it looks, because -17.4 ms is exactly what the
development recipe measured, to the decimal. Two regimes that share a
collective path agreeing to 0.1 ms is the confirmation that the inflated
numbers were quietly denying.

The bigger correction is to the optimizer block above. 8688146 overturned a
comment saying FP32 states "cost nothing in throughput here", calling it
unmeasured and wrong at +36.7%. The +36.7% was the artifact, not the comment.
Measured properly the block costs +6.9 ms per 512 images, +0.85%, at both
bucket sizes -- so the original claim was right and I should not have touched
it. It is restored, with the measurement now attached, and with the cost that
is real called out instead: +16.2 GB of peak memory, which matters at 267 of
288 GB.

Worth recording how this was caught, because it was not caught by review. It
surfaced when someone asked where the fp32-gradient claim in 4bc9bc6 came
from. Chasing that question to the resolved runtime args turned up the inert
main_grads_dtype, and re-deriving the numbers through the standard harness
then turned up the parse bug underneath it. One question about a stray detail,
two errors.
Brings the two-node MLPerf work onto the branch the MXFP6 stack is pinned to, so
one branch carries both the single-node optimisation work and the multinode
recipe. No file is touched by both sides, so the merge is textually clean.

What arrives:

  ac21513  RCCL resolves the node topology XML by GPU PCI device id, widening
             the inter-node plan from 2 channels to 8 and inter-node bandwidth
             from 46 to 118 GB/s. NCCL_NET_GDR_LEVEL=LOC must stay coupled to it.
  4cdb4ac  resets Megatron's DDP grad-ready calibration after the MLPerf warmup
             steps consume it, so the first real step calibrates its own golden
             counts instead of inheriting a synthetic step's.
  6876783  clones the diffusion validation loss out of Megatron's in-place
             rescale path. This one is a correctness fix with teeth: the reported
             loss was divided by the microbatch count, so it only showed at more
             than one microbatch per rank, and under mlperf_mode the halved value
             is what the convergence gate reads.
  d7b0048  the two-node recipe itself, at MBS 32 / GBS 512 over 16 ranks, with
             nine converged seeds behind it.

Two interactions the merge does not resolve, deliberately:

The two-node recipe carries ddp_bucket_size 256000000, the old default, because
it predates the sweep that moved the single-node recipes to 1024000000. That
result does not transfer: the sweep measured 8 ranks inside one node, and the
two-node shape puts reduce-scatter on the inter-node fabric, where the overlap
that made 1024M win is a different quantity. It needs measuring on the target
cluster before the value is changed. See mbs32_e2e/README.md for the method.

It also sets use_precision_aware_optimizer true with main_grads_dtype bf16, where
the single-node MLPerf recipe sets the flag false and its main_grads_dtype is
therefore inert. The two recipes are in different optimizer regimes and should
not be assumed comparable field by field.

Co-authored-by: Cursor <cursoragent@cursor.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.

2 participants