Skip to content

fix(flux): make MLPerf validation measure what it claims to - #1055

Draft
jasainio wants to merge 15 commits into
mainfrom
fix/flux-eval-correctness
Draft

fix(flux): make MLPerf validation measure what it claims to#1055
jasainio wants to merge 15 commits into
mainfrom
fix/flux-eval-correctness

Conversation

@jasainio

Copy link
Copy Markdown
Contributor

Summary

The MLPerf Flux validation path reported a number that did not correspond to the
evaluation it ran. This branch makes the reported loss, the sample count, and the
timesteps all mean what they say, and clears two things that stopped the
evaluation-only path from running at all.

  • The reported loss was an average of per-rank ratios, not a ratio of globally
    summed numerator and denominator, so ranks with differently sized batches were
    weighted equally. It is now a single packed fp64 all-reduce producing a value
    identical on every rank, which the target-loss early stop depends on: if ranks
    disagree near the threshold, one can leave train() alone while the others keep
    training and desync collectives into an NCCL hang.

  • The sample count was the count the configuration intended, added per
    iteration regardless of how wide the batches really were, which is
    indistinguishable from a correct run. It is now the count the reduction actually
    observed, and a shortfall raises rather than logs.

  • eval_iters could silently resolve to 0. The patch deriving it from
    eval_samples runs during build_args, before Megatron initialises process
    groups, so reading args.data_parallel_size raised AttributeError; the patch
    runner logged it and carried on, and the job evaluated nothing and exited 0. The
    width is now derived from world_size and the parallel sizes, and the Energon
    provider refuses to build validation dataloaders when eval_samples is set but
    eval_iters is 0.

  • Ingest reported HuggingFace metadata as conversion failures. MLCommons
    manifests list dataset_info.json and state.json beside the Arrow data;
    parse_md5_manifest already accepted a suffix_filter but fetch_manifest
    never passed one. They also consumed max_files slots, and one sorting ahead of
    a data file would have shifted every shard index after it.

  • Diffusion recipes detoured into bookcorpus tokenisation. prepare.py read an
    absent train_data_path as proof that a run wants a tokenised corpus. Every
    diffusion recipe sets dataloader_type: external and brings its own Energon
    pipeline, so that built a dataset the run never opens and demanded HF_TOKEN for
    a tokenizer it never loads.

Also adds the val-only ingest config the MLPerf validation split is built from, and
routes evaluation reporting through one helper that drops to debug under MLPerf
mode, where the submission log reports the loss itself and Megatron's own reporting
is already suppressed.

Test plan

  • 166 unit tests across the eval budget, reduction, sample-count accounting,
    eval RNG isolation, timestep source, MLPerf patches, and ingest pipeline
  • Full 129-file validation ingest through the pipeline: 29,696 samples,
    3,712 per timestep across the 8 equidistant timesteps
  • Evaluation-only run on 8x MI355X against the re-ingested split, covering all
    29,696 samples exactly (58 iterations x 512) and bit-reproducible across repeats
  • Same run under mlperf_mode: true, confirming the mllog eval_accuracy event
    matches and the duplicate reporting is suppressed
  • pre-commit run --all-files and tools/ci/check_version_consistency.py
  • Reviewer check: no checkpoint is loaded in the evaluation-only runs above, so
    the loss reflects randomly initialised weights. The runs validate the
    machinery, not the number.

Note

feat/mxfp6-fused-mlp is stacked on this branch and carries its own fix for the
Flux Q/K dtype mismatch, which this branch deliberately does not duplicate.

Flux evaluation reported a val_loss over 29,696 samples at the dataset's
own timesteps. It was doing neither, and both defects were invisible in the
logs because every number printed was the configured one.

Coverage. Energon shards validation data across dp_size * num_workers but
splits the batch quota across num_workers alone, so the two divisions agree
only when each worker's slice is a whole number of microbatches. They did
not agree in any shipped recipe: the tail of every short worker's slice went
unread, giving 27,776 samples at num_workers 16 and 28,928 at 8, with the
same samples missing on every eval. This adds val_num_workers (defaulting
to 0), builds validation its own WorkerConfig, and asserts
eval_samples % (dp_size * max(1, val_num_workers) * micro_batch_size) == 0
so an unreachable shape fails at startup with the list of counts that work.

The budget is now expressed as eval_samples rather than eval_iters, which
stays correct across batch-size changes, and full_validation reads the true
split size from the dataset index instead of being a no-op.

Timesteps. The MLCommons val Arrow files carry a per-sample int32 timestep;
ingest read only the four tensor columns and dropped it, so every eval ever
run against these shards silently fell back to injecting arange(B) % 8.
That is not a near miss: the published val set pairs each image with one
fixed timestep and orders them so a contiguous batch spans two sigmas, not
eight. Ingest now carries the column into the sidecar, collate validates it
on every sample rather than samples[0], and eval_timestep_source makes the
choice explicit so a missing field is an error for MLPerf instead of a
silent substitution.

is_validation now derives from eval mode alone rather than from the batch
carrying a timestep. The old signal was data-driven, so a training batch
that happened to carry the field silently suppressed CFG dropout.

Noise. per_step_rng_reseed froze the training step counter during eval and
reseeded every microbatch identically, replaying one draw of VAE epsilon and
flow noise across the whole validation set. Both references redraw per
batch. Eval now advances its own counter in a seed range disjoint from
training, derived from (iteration, microbatch) so it survives resume without
checkpointing a counter, and leaves the training stream untouched.

Reporting and cost. consumed_valid_samples counted a flat global batch per
iteration regardless of the real width; it now uses the observed loss
denominator. Three all-reduces and two host round-trips collapse to one
packed fp64 reduction, the missing MLPerf evaluation_frequency event is
logged, and the unconditional per-eval gc.collect()/empty_cache() moves
behind eval_purge_memory, off by default.

Verified against /mnt/dcgpuval/mlperf_flux1: a harness rebuilding the
provider's exact dataloader reproduces 28,928, 27,776 and 5,120 on the
unfixed shapes and 29,696 with 3,712 per timestep after the fix. A full
re-ingest of the val split yields exactly 3,712 per timestep, and driving
both splits through the reader shows the same batch of 64 images carrying
sigmas {0, 0.5} from the real column against all eight from the fallback.

The reduction is split out of primus_evaluate as reduce_eval_losses so the
step that produces the reported number is testable without a process group.
Its tests pin that the loss is a ratio of summed numerator and denominator
rather than a mean of per-rank ratios, that the denominator survives as a
sample count, and that a short or doubled read raises instead of being
reported as the count the configuration intended.

Deployment note: the recipes set eval_timestep_source: dataset, so
evaluation fails loudly until a val split ingested with the timestep column
is in place.
…ngest

MLCommons manifests list dataset_info.json and state.json beside the Arrow
data. The ingest pipeline fetched the manifest unfiltered, so conversion
failed on both and reported every run as having failed files. They also
consumed max_files slots, and one sorting ahead of a data file would have
shifted every shard index after it. parse_md5_manifest already accepted a
suffix_filter; fetch_manifest just never passed one through.

Separately, prepare_dataset_if_needed read an absent train_data_path as
proof that a run wants a tokenised bookcorpus corpus. Every diffusion
recipe sets dataloader_type: external and brings its own Energon pipeline,
so that built a dataset the run never opens and demanded HF_TOKEN for a
tokenizer it never loads.

Adds the val-only ingest config the MLPerf validation split is built from.
The eval_samples patch derives eval_iters during build_args, before
Megatron initialises process groups, so args.data_parallel_size did not
exist yet. The patch runner logged the AttributeError and carried on,
leaving eval_iters at 0: the job evaluated nothing and exited 0. Derive the
width from world_size and the parallel sizes instead, and refuse to build
validation dataloaders when eval_samples is set but eval_iters is 0, so a
swallowed patch failure cannot pass for a clean run.

Under skip_train the provider no longer builds a train dataset, which an
evaluation-only dataset has no split for.

Evaluation reporting now goes through one helper that drops to debug under
MLPerf mode, where the submission log reports the loss itself and
Megatron's own reporting is already suppressed. Only confirmations are
quietened: a coverage shortfall still raises, and the mismatch that
context parallelism explains still reports at info.
Black's formatting of the line, which the pre-commit gate enforces and
which the preceding commits had not been run through.
Comment thread primus/backends/megatron/training/evaluator.py Fixed
# Microbatch index stride within one evaluation. Must exceed the number of
# microbatches any single evaluation runs so that consecutive evaluations
# cannot collide; asserted where the index is built.
EVAL_RNG_ITERATION_STRIDE = 1 << 20

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.

Not applied: this constant is used, just not in the module that defines it. diffusion_trainer.py imports it and uses it four times to build the eval RNG index (iteration * EVAL_RNG_ITERATION_STRIDE + index) and to assert that consecutive evaluations cannot collide. Deleting it would break that import and remove the guarantee that eval seeds stay disjoint from training seeds.

global _warned_uncovered_equidistant
if batch_size % NUM_VALIDATION_TIMESTEPS == 0 or _warned_uncovered_equidistant:
return
_warned_uncovered_equidistant = True

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.

Not applied: this one is used within the same function. It is the warn-once latch in _warn_if_equidistant_undercovers -- read on line 75 and set on line 77 under global, and patched by two tests. Removing it would turn a once-per-process warning into one per microbatch.

The equal case returns early just above, so the `!= expected` test could
never be false. Outdenting its body leaves behaviour identical and stops
the guard from reading as though a third case existed.
@jasainio
jasainio requested review from eshaw2 and olehtika August 28, 2026 04:50

@olehtika olehtika left a comment

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.

Reviewed against docs/mlperf/flux_eval_rules_rcp_audit.md (the v6.1 rules audit). A2 (eval RNG), A3 (per-sample timesteps) and A6 (eval budget) all land, and A2/A6 are better than what the audit prescribed — deriving the eval index from (iteration, microbatch) makes it resume-safe without checkpointing a counter, and rejecting eval_samples + eval_iters together closes the failure mode where a wrong value never errors.

Two things I'd flag, both in mlperf_logging_patches.py.

The evaluation_frequency event logs a key the checker doesn't look for

self._event(
    key=getattr(self._constants, "EVAL_FREQUENCY", "eval_frequency"),
    value=getattr(args, "eval_interval", 0) * self.gbs,
)

The comment says the constant name "differs across mlperf_logging releases", but there is no EVAL_FREQUENCY in any release — I checked the installed 4.1.42 and upstream master, and neither mllog/constants.py defines it. The string evaluation_frequency appears only inside the compliance_checker/training_*/closed_flux1.yaml files, never as a constant. So the getattr fallback always fires and the emitted key is eval_frequency, while closed_flux1.yaml requires:

- KEY:
    NAME:  evaluation_frequency
    REQ:   EXACTLY_ONE

There's already a correct precedent in-repo: the diffusion backend emits the literal string at primus/backends/diffusion/trainers/base.py:521.

The other two EXACTLY_ONE keys are still absent

opt_learning_rate_warmup_steps and opt_gradient_clip_norm are both EXACTLY_ONE in closed_flux1.yaml and neither is emitted on the Megatron path. Both constants exist (OPT_LR_WARMUP_STEPS, OPT_GRADIENT_CLIP_NORM), and the diffusion backend already emits both (base.py:514 and :520), so this is one line each.

Net effect: with this PR the compliance checker still fails on the same three keys it failed on before it, and every underlying value is already correct — lr_warmup_iters and clip_grad: 1.0 are both right in the recipe, just unlogged.

Two smaller ones, cosmetic only

train_samples is emitted as null rather than 1,099,776, because getattr(args, "train_samples", 1099776) returns None when the attribute exists and is None — the default never fires. This PR's own EVAL_SAMPLES change uses the idiom that handles it (getattr(...) or eval_iters * self.gbs).

gradient_accumulation_steps is max(self.gbs // self.mbs, 1) = 8, ignoring data parallelism; the true micro-batches per step is 1. closed_common.yaml only checks > 0 so it passes, but the diffusion backend uses the real grad_accum_steps at base.py:512. Mirroring base.py:502-521 would close all four at once.

Coverage finding

Separately: the coverage defect this PR fixes (27,776 / 28,928 against a configured 29,696) falsifies §3.2 of the audit, which graded coverage correct on the strength of the Evaluating on 29696 samples log line and the .idx shard count. The .idx count is the dataset size, not what the loop read — exactly the trap described here. I'm updating the audit accordingly. It also means the seven L5 convergence runs in §4.3, including the 0.585314 gate crossing, were measured on 94–97% of the validation set, so those numbers need regenerating once this lands regardless of the A2 re-run.

@olehtika olehtika left a comment

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.

One more suggestion, on making eval_timestep_source hard to get wrong rather than merely possible to get right.

The field-precedence ordering leaves exactly one unsafe combination

resolve_validation_timesteps checks for the field before consulting the source:

if "timestep" in batch:
    val_timesteps = batch["timestep"].float() / NUM_VALIDATION_TIMESTEPS
elif eval_timestep_source == DATASET_TIMESTEPS:
    raise ValueError(...)
else:
    val_idx = torch.arange(batch_size, device=device) % NUM_VALIDATION_TIMESTEPS

That ordering is the right call and it does most of the work — it means a re-ingested split is
evaluated correctly under either setting, so the two changes can land independently. Enumerated:

Shards eval_timestep_source Outcome
re-ingested dataset dataset timesteps — correct
re-ingested equidistant dataset timesteps anyway, the field wins — correct
pre-fix dataset hard ValueError naming the fix — safe
pre-fix equidistant positional injection, silent — the only unsafe cell

The recipes in this PR set eval_timestep_source: dataset, which closes that cell for them. But
trainer_base.yaml defaults to equidistant for backward compatibility, so the protection rests on
each MLPerf recipe remembering to override it. A recipe copied from a non-MLPerf diffusion config, or
a new one that inherits the default, silently lands back on the injected timesteps — and per this
PR's own description that is indistinguishable from a correct run in the logs.

Suggestion: under mlperf_mode: true, refuse equidistant rather than relying on the recipe.
MLPerf submissions have no legitimate use for injected timesteps, so the combination can be made
unreachable by construction.

Where to put it: beside the existing EvalCoverageError, not in the build_args patch

The obvious home looks like patch_eval_samples, but that would be the wrong place for the reason
this PR already documents in energon_dataset_provider.py:

The patch that turns eval_samples into eval_iters runs in build_args, where a failure is
logged and swallowed rather than raised.

An assertion added there would be swallowed exactly as the AttributeError was. The provider-side
EvalCoverageError guard is the pattern that works — a refusal at validation-dataloader-build time
that cannot be silently dropped — and mlperf_mode plus eval_timestep_source are both readable at
that point. Adding the check next to it costs a couple of lines and reuses a mechanism this PR
already established.

Why this is a live risk rather than a hypothetical

On our cluster there are now two complete copies of the MLPerf Flux val split on shared storage, one
carrying the timestep column and one not, with a third directory sharing the mlperf_flux1 name and
containing no shards at all. Until the pre-fix shards are gone everywhere, the bottom row of that
table is reachable by pointing a run at the wrong path — and it is the one combination that produces a
plausible-looking number instead of an error.

Incidentally, reading the annotated shards turned up a detail worth knowing for the test plan: the
per-shard timestep histograms are deliberately non-uniform, e.g. {0: 3712, 1: 1288, 4: 3712, 5: 1288}
for a 10,000-sample shard, because the split is ordered by timestep in blocks. Only the full-split
total is 3,712 each. A useful side effect is that with the real column a coverage under-read shows up
as a skewed histogram, whereas the positional fallback re-derives timesteps from batch position and so
looked uniform however much of the split went unread — which is to say the timestep defect was
concealing the coverage defect this PR fixes alongside it.

…requires

Run the Flux MLLOG output through the official training_6.1.0 compliance checker and it
fails before it reaches anything about the run itself. Several of these are v6.1 renames
that were still being emitted under their v5 spelling, so the keys were present but
invisible to the checker.

Precision disclosures. v6.1 requires EXACTLY_ONE of lowest_numerical_precision_in_linear,
_in_attn and _in_comm, and the logger emitted none. They are read from the environment
and startup fails if any is unset, rather than defaulting: the value is a submission
claim about what the run did, so inferring it from the config is how a stale claim gets
logged after a precision change. The checker validates them against its own enum, which
is what makes a wrong value cheap to catch and a missing one expensive.

Epoch metadata. BLOCK_START/BLOCK_STOP took first_epoch_num and EVAL_START took
epoch_num. v6.1 keys these on samples_count, and Flux is sample-based -- there is one
epoch, so the old metadata carried no information either.

Gradient accumulation. gbs // mbs counts the accumulation steps of the whole job rather
than of one rank, so it over-reported by the data-parallel size on every multi-GPU run.

Also emits evaluation_frequency under its v6.1 name rather than the EVAL_FREQUENCY
constant, which is absent from the pinned mlperf_logging and silently fell back to the v5
literal; adds the required cache_clear, parallelism, micro_batch_size, config_filename,
warmup-steps and clip-norm keys; and makes run_start idempotent, since an eval that
precedes the first training batch otherwise emits a second one and the checker rejects
the duplicate.
…1 ruleset

6.0.0-rc5 stops at training_5.0.0, so there was no way to check a v6 Flux log
against anything: no training_6.0.0 config directory, no closed_flux1.yaml, no
rcps_flux1.json. Work on the logging contract was therefore being done against
a guess at what the checker wanted.

6.0.0-rc6 adds training_6.0.0 with closed_flux1.yaml and the reference
convergence points, which is what makes the rest of this branch verifiable
rather than plausible.
run_start has to precede every read of the real dataset, and it did not. It
fired from the first training_log call, which is after the first train_step,
which is after build_train_valid_test_data_iterators has already constructed
the Energon iterators. Everything between those two points -- reading the
.nv-meta shard metadata, spawning loader workers, filling the prefetch queue --
happened inside the initialization window and was excluded from the measured
time. The warmup steps landed on the wrong side too: they ran on synthetic data
inside the first train_step, so compile and FP8 bring-up were being charged to
the run.

There is no lifecycle hook at the point where the boundary belongs. Primus'
phases (setup / build_args / before_train / after_train) all fire before
pretrain(), and pretrain() runs setup_model_and_optimizer, then the data
builder, then train() with nothing in between. Megatron-LM is a pinned
submodule, so the call site cannot be edited either without forking it.

mlperf_boundary creates the seam from outside by wrapping three entry points
during before_train: pretrain to capture forward_step_func, which is a
pretrain() argument and is reachable nowhere else beforehand;
setup_model_and_optimizer to capture the model, optimizer and scheduler that
warmup needs; and build_train_valid_test_data_iterators, whose first call is
the boundary. Firing there runs the pre-run hooks, synchronizes, emits the
transition on rank zero, and holds every rank on a second barrier so none of
them can start opening shards while rank zero is still writing run_start. The
once-guard covers virtual pipelining, which calls the builder per stage.

The warmup body is now shared between the boundary and the original train_step
hook rather than duplicated. Only MLPerf mode moves to the boundary:
development recipes have no clock to protect and keep the behaviour they were
tuned against, and the relocated path needs objects that only exist once the
capture wrappers have run, so it fails loudly rather than silently skipping
warmup if they have not.

The old first-training_log trigger stays as a backstop and is now a no-op once
the boundary has fired.
Four things in the log were being decided by defaults rather than by the run.

The result never reached a file. mllog was left on its default handler, so the
records went to stdout interleaved with every other rank's output and with
framework noise, and the artifact a checker reads would have been whatever
survived a later filtering step. Rank zero now writes MLLOG_OUTPUT_FILE
directly.

Submission identity defaulted. org fell back to "AMD", division to "closed",
platform to "MI355X" and config_filename to "unknown". Those four values decide
which division the log is judged in and which recipe a reviewer goes looking
for, and a default is exactly how a wrong one gets submitted without anyone
noticing. cache_clear defaulted to false in the same way, which let a run that
never dropped caches make a claim about the machine it ran on. All of them are
now required, and startup fails naming the variable.

A run that missed the target produced no run_stop at all -- only convergence
emitted one. run_stop is EXACTLY_ONE in the ruleset, so those logs did not
parse, and a non-converging run is still part of the campaign the RCP checker
compares. It is emitted from after_train now, idempotently, so a converged run
does not get a second contradictory record.

The block reopened only on the path where the validation loss could be read.
When it could not, block_stop had already fired on entry to eval and training
carried on outside any block.

Precision disclosures are passed through unchanged even when the checker will
reject them: training_6.0.0/common.yaml accepts a fixed vocabulary and mxfp6 is
not in it, so an MXFP6 run produces a log that has to wait for the format to be
approved upstream. Emitting fp8 instead would make it pass by describing the
run falsely. It warns instead.

The tests now drive the real mlperf_logging end to end -- real mllog writes the
file, the real compliance checker reads it back under training_6.0.0 -- so a
converged run is verified against the ruleset a submission is judged with
rather than against a mock's idea of it. They skip where the package is absent.
That is also how two assumptions turned out to be wrong: the checker requires
at least one eval_accuracy at or below the target, so no amount of well-formed
logging makes an exhausted run pass, and epoch_start needs no matching
epoch_stop.
…arget

train_iters was 5000, which at GBS 512 is 2560000 samples. The reference
convergence points for flux1 at that batch size span 7077888 to 7602176 samples
across 20 NVIDIA BF16 runs (rcp_checker training_6.0.0/rcps_flux1.json), so the
budget ran out around a third of the way to the earliest of them. Every run was
going to exhaust below target no matter how well it trained, and none of them
could ever have counted as a result.

16000 steps is 8192000 samples, clearing the slowest reference run with room
for a run that converges later than the reference spread. It is a cap and not a
target: the run ends when eval_accuracy reaches 0.586 and how many samples that
took is the result. lr_decay_iters moves with it -- inert while lr_decay_style
is constant, but 4000 next to a 16000-step budget reads as a decay schedule
that ends early.

The seed also comes from outside now. A campaign is ten runs with ten different
seeds, and the value in the log has to be the one the launcher chose rather
than the one the file was written with.
The logging patch now fails closed on everything it would otherwise guess, so
something has to supply the answers. This is that something, which makes it
part of the submission rather than a convenience wrapper: it sets the
submission identity, the precision disclosures, the result path and the recipe
name, and it drops the page cache and reports cache_clear=false when it lacks
the privileges to do so, instead of claiming a cold start it did not get.

Each run is checked against the ruleset as soon as it finishes. Checking at the
end of the campaign instead would mean finding out after ten runs and several
days of compute that none of the logs parse.

The campaign driver does ten runs with distinct seeds, then an RCP comparison
over the set. Ten is what rcp_checker requires for flux1, not a round number. A
run that ends without converging still produces a result file and still counts
as one of the ten -- it is part of the distribution being compared, and
dropping the runs that went badly is how the comparison stops meaning anything.
pretrain() runs a final validation pass whenever args.do_valid is set, and
the early stop only clears args.train_iters, so reaching the target loss
was followed by one more evaluation. That evaluation draws its own VAE
epsilon and flow-matching noise, so it can land above the target and
contradict the evaluation that just ended the run, leaving a result file
whose last eval_accuracy is worse than the one at run_stop.

Clear do_valid alongside train_iters so the pass never runs, and refuse to
emit any MLLOG record once run_stop is out, so an evaluation reached by
some other path cannot append to a closed log either. The second guard
keys on run_stopped rather than converged, which also covers aborted runs.

Verified on 8x MI355X: a run converging at step 2 of a 4-step budget now
ends at run_stop with a single eval_accuracy and nothing after it.
The per-microbatch evaluation RNG index was built from args.iteration and
a microbatch counter that only reset when that value changed. Neither is
what it was meant to be. args.iteration is the resume point: Megatron
assigns it at setup and at checkpoint load and never inside the training
loop, so an evaluation at step k drew one set of noise when reached
continuously and a different set when reached after a resume. And because
it never changes mid-run, the counter never reset, so two evaluations at
the same step continued one stream rather than reproducing each other.

Key on args.curr_iteration, the step the loop last completed, and reset
the counter per evaluation rather than per step. The evaluator signals the
boundary through a module-global counter in eval_session, which is
import-free so the per-microbatch path does not pull in the Megatron
evaluation stack.

Measured on 8x MI355X with two evaluations forced at the same step: they
reported 1.688877 and 1.690349 before, and 1.685579 twice after. A drift
of that size is enough to carry a follow-up evaluation across a 0.586
target.

Note this changes which noise every evaluation draws, so convergence
evidence collected before it does not carry over.
import pytest
import torch

import primus.backends.megatron.training.eval_session as eval_session
gphuang and others added 2 commits September 1, 2026 11:07
## Summary

Every Flux run with `mlperf_mode: true` and `warmup_train_steps > 0`
dies during the first real training step, before a single iteration
completes. This makes the warmup wait on the gradient reduce-scatter it
dispatches.

The MLPerf warmup runs at the pre-data boundary, which is before
Megatron's `train()` executes. `train()` is where
`config.finalize_model_grads_func` gets assigned, and that callback is
the only caller of `finish_grad_sync()`. So with `overlap_grad_reduce:
true` a warmup backward dispatches the data-parallel reduce-scatter and
nothing ever waits on it. The handle is still outstanding when the first
real step dispatches its own, and Megatron asserts:

```
File ".../megatron/core/distributed/param_and_grad_buffer.py", line 530, in start_grad_sync
    self.grad_reduce_handle is None
AssertionError: Should not have multiple communication calls outstanding at once
```

### The fix

Install `finalize_model_grads` for the duration of the warmup loop and
restore whatever was there before, so the boundary warmup does not
change what `train()` later sees. The `None` guard leaves the
in-`train_step` warmup path used by development recipes untouched, since
by then `train()` has already installed the callback — which is why only
MLPerf mode was affected.

### Why the escape hatch does not cover this

`start_grad_sync` no-ops when a handle is outstanding *and*
`is_first_batch` is still set. Warmup step 1 populates the ready counts
without dispatching; the `reset()` at the head of warmup step 2 records
the golden counts and clears `is_first_batch`; warmup step 2 then
dispatches for real. By the first training step the flag is long gone,
so the assertion fires.

## Test plan

- [x] Two unit tests in
`tests/unit_tests/backends/megatron/test_mlperf_patches.py`: the warmup
steps see a grad-finalize callback, and a callback the caller already
set is preserved rather than replaced. Both fail without the production
change.
- [x] Full `test_mlperf_patches.py` passes (46 tests) on an MI355X dev
container.
- [x] Bisected on 8x MI355X with the Flux 12B MXFP6 MLPerf recipe. The
FP8 MLPerf recipe fails identically, so this is not precision-specific;
`mlperf_mode: false` passes; `warmup_train_steps: 0` passes.
- [x] With this change the full MLPerf recipe at `warmup_train_steps: 2`
trains 20 iterations clean, and a full convergence run is in flight.

---------

Co-authored-by: guangphu <guangphu@amd.com>
resolve_validation_timesteps prefers the dataset's timestep column
whenever the batch carries one, so a re-ingested split is evaluated
correctly under either setting of eval_timestep_source. That leaves
exactly one unsafe combination: shards ingested before the column was
carried through, read under equidistant. There the positional fallback
injects t = index % 8, which reproduces neither the timesteps nor the
image-to-timestep pairing the published split defines, and it fails
silently -- the loss it reports is indistinguishable in the logs from a
correct one.

The MLPerf recipes all set dataset and close that cell for themselves,
but trainer_base.yaml defaults to equidistant for the diffusion recipes
that have no annotated split, so the protection rested on every future
submission recipe remembering to override it. A recipe copied from a
non-MLPerf diffusion config would land back on injected timesteps. There
are also two complete copies of the val split on shared storage, one
annotated and one not, so the combination is reachable today by pointing
a run at the wrong path.

The check goes beside the existing eval_iters guard in the Energon
provider rather than in the build_args patch that sizes the eval budget.
A failure raised there is logged and swallowed by the patch runner,
which is how eval_iters silently stayed at 0; eval_timestep_source is
also a Primus-only key, merged onto args only after build_args has run.
Placement within the provider matters for the same reason: validation
dataloader construction is wrapped in a bare except that disables
evaluation and carries on, so the guard has to precede it.

Adds the first tests to drive the provider's guards through
create_dataloaders, pinning that an MLPerf run on injected timesteps
raises before any dataset is built and leaves eval_iters alone, and that
a genuine construction failure is still swallowed as before.

Also corrects the comment on evaluation_frequency, which described a
getattr fallback the key no longer uses. No mlperf_logging release
defines a constant for that name; it appears only in the checker's own
rulesets, so the literal is the spelling rather than a fallback.
@jasainio

jasainio commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Two things I'd flag, both in mlperf_logging_patches.py

All four are fixed. Three of them landed in b7c5ba8, which went up a couple of hours after your review — same finding arrived at from the other end, by moving to mlperf-logging 6.0.0-rc6 (e01c0bc) and running the log through the real checker instead of reasoning about what it wanted. You were right about the mechanism in every case.

Values from an 8x MI355X run's MLLOG on the current head:

"key": "train_samples",                    "value": 1099776
"key": "evaluation_frequency",             "value": 64
"key": "gradient_accumulation_steps",      "value": 1
"key": "opt_learning_rate_warmup_steps",   "value": 1600
"key": "opt_gradient_clip_norm",           "value": 1.0

No eval_frequency key is emitted any more, train_samples is the number rather than null, and gradient_accumulation_steps is DP-aware (gbs // (mbs * dp_size)) rather than gbs // mbs.

On the comment specifically. You were right that it was the actual defect, so I have corrected it in fe20330 rather than just deleting the getattr. It claimed the constant name "differs across mlperf_logging releases", which is not true of any release — the name exists only in the checker's own rulesets. That sentence is what made a permanently-taken fallback read as deliberate compatibility handling, and it would have justified the same mistake for the next key. It now says the literal is the spelling and not a fallback.

Net effect is no longer the same three keys. tests/unit_tests/backends/megatron/test_mlperf_log_is_checker_valid.py drives real mllog writes through the real mlperf_logging.compliance_checker under training_6.0.0 / closed_flux1.yaml, and a converged run passes it end to end. Two assumptions did not survive contact with the checker, which is worth recording: it requires at least one eval_accuracy at or below target, so no amount of well-formed logging makes an exhausted run pass, and epoch_start needs no matching epoch_stop.

On the coverage finding. Agreed on all of it, including that §3.2 of the audit is falsified. The .idx count is the dataset size and the log line was the configured number, so the two pieces of evidence that agreed were the two that could not disagree. The seven L5 runs in §4.3 need regenerating, and there is now a second reason beyond the A2 re-run: ff91c97 changes which noise every evaluation draws (the eval RNG was keyed on args.iteration, the resume point, rather than on the step being evaluated), so pre-existing convergence evidence does not carry over regardless. Thanks for updating the audit.

@jasainio

jasainio commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Suggestion: under mlperf_mode: true, refuse equidistant rather than relying on the recipe.

Applied in fe20330, in the place you named. assert_mlperf_timestep_source sits next to EvalCoverageError in eval_budget.py and is called from the provider immediately after assert_val_worker_divisibility, at validation-dataloader-build time.

Your reasoning about patch_eval_samples turns out to be even more decisive than the swallow alone. eval_timestep_source is a Primus-only key, and MegatronArgBuilder drops anything Megatron's parser does not define; those keys are merged back onto args only after the build_args phase runs. So a check there would not have read equidistant and been swallowed — it would have read nothing at all, and then either passed vacuously or refused every MLPerf run depending on which way it failed. The two keys patch_eval_samples does need early (eval_samples, val_num_workers) are hydrated from the module config by hand for exactly this reason.

Placement within the provider matters for the same reason placement outside build_args does, which I had not appreciated until writing the test. Validation dataloader construction is wrapped in a bare except Exception that logs, sets eval_iters = 0 and carries on — deliberately, so a dataset with no validation split degrades rather than aborts, but it cannot tell that case apart from a misconfiguration. A guard inside it would turn a refusal into a run that evaluates nothing and exits 0, which is the build_args failure mode again in a second location. The guard therefore has to precede that block, and there are now tests pinning that it does: an MLPerf run on injected timesteps raises before get_val_datasets is called and leaves eval_iters at 58, while a genuine construction failure is still swallowed as before. These are the first tests to drive create_dataloaders at all, so the existing eval_iters == 0 guard is covered by them too.

Two decisions worth flagging:

It fails closed on an absent setting. getattr(args, "eval_timestep_source", None) not being "dataset" is a refusal, so an MLPerf recipe that somehow inherits nothing is rejected rather than read as opting in. All three mlperf_mode: true recipes on this branch and on feat/mxfp6-fused-mlp already set dataset, so the guard is a no-op for everything shipped — I checked before adding it, since a fail-closed guard that misfires would break every submission run.

The error names the way out. It gives the setting to change and points at mlperf_flux1_val.yaml, because under mlperf_mode the answer is never "switch to equidistant" — it is "re-ingest the split", and the two copies on shared storage make it easy to reach this by pointing at the wrong path rather than by misconfiguring anything.

trainer_base.yaml still defaults to equidistant; the non-MLPerf diffusion recipes have no annotated split and are untouched.

The histogram detail is genuinely useful, thanks — that the positional fallback re-derives timesteps from batch position and so looks uniform however much of the split went unread is a sharper statement of why these two defects hid each other than anything in the PR description. I will fold the per-shard skew check into the re-ingest verification.


monkeypatch.setattr(provider_module, "get_val_datasets", _get_val_datasets)

provider = EnergonDatasetProvider(task_encoder_factory=lambda: object())

import pytest

import primus.backends.megatron.data.energon_dataset_provider as provider_module
@olehtika

olehtika commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Heads-up on a second warmup failure on this branch, adjacent to the one #1069 fixed. Opened #1072
against this branch with the fix and evidence; summary here since it's the same area of code.

Symptom. With warmup_train_steps: 2, an MXFP6 Flux 12B recipe using gradient accumulation
dies before the first iteration completes:

AssertionError: Communication call has not been issued for this bucket (21/21 params have grad available)

Cause. Megatron's gradient buckets calibrate on their first batch —
_ParamAndGradBucketGroup.reset() records golden_per_param_grad_ready_counts, and from the
second batch on register_grad_ready() dispatches the reduce-scatter only once that count recurs.
The boundary warmup from 8e1c22a runs batches like any other, so it consumes the calibration, and
the golden counts end up describing a synthetic step. Under accumulation those counts never recur:
every parameter reports in and the collective is never issued. The 21/21 is the tell — the key
set is complete, so equality failed on the counts rather than on a missing parameter.

#1069 is necessary but not sufficient. Installing finalize_model_grads_func for the warmup
steps fixes the accumulation-1 case; it leaves the calibration untouched. Measured on 8xMI355X,
300-iteration single-node arms, overlap_grad_reduce + distributed optimizer, with #1069 applied
and confirmed active in the log:

micro 64, GBS 512, accum 1 micro 32, GBS 512, accum 2
300/300 steps dies at step 0, assertion above, all 26 ranks

Two things worth flagging from chasing this:

The two assertions look like separate bugs and are one. Before #1069 the accumulation-1 shape
failed with Should not have multiple communication calls outstanding at once and the
accumulation-2 shape with has not been issued — opposite messages, same root, which is what made
it read as a DDP problem rather than a warmup one.

The failure is invisible to a step-count health check in one specific case. Our m32_gbs512 arm
legitimately early-stops at step 200 on the 0.586 gate, so "did not reach 300" is not by itself a
failure signal for that shape.

Also useful as a regression canary: with the fix in place the log line reports

[MLPerf_WARMUP] Reset DDP grad-ready calibration on 43 bucket groups (0 outstanding collectives drained)

The drain count should stay at 0 while #1069's finalize install is on the path. Before #1069 it
reported 43 of 43, so a non-zero count later would mean something stopped awaiting warmup's grad
syncs again.

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.

3 participants