Skip to content

[DO NOT MERGE] Integration: every tunix change of the 128-chip best run (#2873 + #2878 + #2877 + recipe switches) - #2887

Closed
parambole wants to merge 17 commits into
atwigg/mlperffrom
parambole/integration-128-best
Closed

parambole wants to merge 17 commits into
atwigg/mlperffrom
parambole/integration-128-best

Conversation

@parambole

@parambole parambole commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Do not merge; superseded. Every change here now ships in reviewable PRs: #2878 (tunix) and AI-Hypercomputer/maxtext#5718 (MaxText). Use those for end-to-end runs: this branch launches the fully dropless MoE layout by default and pins a MaxText without #5718.

Verification build, not for merging. This branch stacks every tunix change of the 128-chip best run on atwigg/mlperf, so one image can reproduce that run. Land the component PRs instead.

Companion: AI-Hypercomputer/maxtext#5723, the MaxText side of the same run, opened at the same time.

What is in it

Base: atwigg/mlperf at 4c7823f20. Merged with --no-ff, in this order:

PR Branch at sha Merge commit What
#2873 parambole/logps-sharded-head at a29991d3f 5d3f29b75 Opt-in sharded log-prob head (TUNIX_LOGPS_SHARDED_HEAD)
#2878 parambole/ours275-trainer-config at 59ad7c8db 998f587c6 Submission-recipe trainer flags: 7 MaxText keys, 1 libtpu flag, 2 precision keys
#2877 parambole/l3-converge at 42ada6cd0 8b7719e94 Opt-in pipelined and staged step boundary (PIPELINE_STEP_BOUNDARY, STAGE_NEXT_MICROBATCH)

One conflict, in rl_program.py: #2877 and #2633 (already on the base) both added keyword arguments to StandardRLProgram.__init__. I kept both. #2633 also adds a 4-line wait before the weight sync in _train_loop. It merged cleanly and only runs with over-generation, which this recipe leaves off.

Extra commit 69962eb7a (integration only). Turns the opt-in pieces on in mlperf_gbs1024_submission.sh only:

Measured result this reproduces

Vaibhav's RL trainer bench (AI-Hypercomputer/maxtext#5701): 128 v7x chips, fsdp32 x context4 x expert2, 64 rows x 65,536 tokens per micro-batch, 9 micro-batches per optimizer step.

Run s per optimizer step tok/s/chip
Team recipe 130.80 2,255
Final, all four changes 98.31 (replicate 98.27) 3,000
Same pod, without the staged boundary 105.97

Per change: recipe keys + libtpu flag -8.80 s, sharded log-prob head -14.41 s, 2 precision flags -1.35 s, staged step boundary -7.66 s.

Correctness: the head and the precision flags passed a real-gradient TPU A/B. Real-gradient checks for the recipe keys and the staged boundary are scheduled for today.

How to verify

  1. Build the bench client image from this branch plus the MaxText integration branch ([DO NOT MERGE] Integration: MaxText side of the 128-chip best run (#5718) AI-Hypercomputer/maxtext#5723).
  2. Run Vaibhav's bench with MICRO_STEPS=9. For the staged-boundary number, turn on the bench driver's opt-in STAGE_BEFORE_UPDATE switch.

Caveats

…opt-in)

Re-applies the pipelined step boundary (parambole/u10-L3-pipelined-boundary
at 0db0ac237) on #2719 merged with atwigg/mlperf. Two flags, both
off by default, and off is the base loop exactly.

--pipeline_step_boundary: while step k's last micro-batches train, take and
pack step k+1's groups. Send step k+1's first micro-batch once step k's
checkpoint save, weight-sync staging, commit and (deferred) metrics fetch are
done, and run step k's step metrics, logging, on_step_end and host-memory
release while that call is in flight. The trainer gets the same calls, with
the same inputs, in the same order.

--stage_next_microbatch: also ship that micro-batch to the trainer right after
step k's last train call, while the device still computes, so the call that
later runs it carries no data. #2719 runs the update inside that last call
and, once the trainer exposes last_update_token, returns only after it landed,
which would leave the transfer nothing to hide behind. So when a stage is
planned the call is sent with wait_for_update=False (it returns at the
update's dispatch) and the stage confirms the update instead (update_ready),
which keeps #2719's deferred get_metrics at a staged boundary. A planned stage
that finds nothing packed in time falls back to get_metrics ahead of the save.

Trainer side: TrainerWorker.fwd_bwd(wait_for_update=...); stage_fwd_bwd holds
one micro-batch and reports device_wait_s and update_ready; fwd_bwd_staged
runs it through fwd_bwd, so a staged micro-batch gets the fused update too.
DistributedRLEngine: stage_train_step, trainer_staging_support, and
train_step(staged_request_id=...) sharing #2719's fused-response handling.
BatchOrderedQueueManager passes a hole the serving cursor already read past.
Recipe, launcher and driver plumbing for PIPELINE_STEP_BOUNDARY and
STAGE_NEXT_MICROBATCH.
…start-up stagger

Runs rollout_dispatch_stage with #2845's first-batch stagger in
front of the train loop (max_staleness 1, so only the stagger holds batch 1):
batch 1 is still held for the first gap in every mode, the prepack waits on
the held batch without holding up step 0's boundary or the dispatcher, the
next boundary is pipelined (and staged), and the trainer calls are the base
loop's. Control: without the stagger batch 1 goes out at once.
On a box at load ~170 the first version failed in every mode, the base loop
included: it assumed step 0 ends well within the 1 s gap, and with a
background sync the dispatch window then rightly waits for step 0's round.
Use a blocking sync so only the stagger holds batch 1, measure from the
stagger's own reference point, keep the lower bound exact and allow for load
above it, check the boundary-0 claim whenever its precondition holds, and
stamp each delivered group with a fixed policy version so admission and
metrics do not depend on timing.
Three fixes from the review of the pipelined step boundary. All are on the
staged path, which stays dormant until MaxText has wait_for_device.

* rl_program: a stage that fails on the way to the trainer, or in the
  reference log-probs it needs, no longer ends the run before the step's
  get_metrics, checkpoint and on_step_end. The micro-batch goes back to the
  front of the next step's ready list and is sent whole in that step's
  first train call, as without staging; staging is off for the rest of the
  run. A stage the trainer ran and that raised there still ends the run
  with its own error: the worker is then in ERROR, so no checkpoint could
  follow anyway.
* trainer_worker: stage_fwd_bwd reports a failure in its device wait or in
  its update wait as the optimizer update's ("optimizer update() failed
  ... seen by stage_fwd_bwd"), as the fused fwd_bwd does. MaxText's
  wait_for_device waits on the update's gradient norm, so the device wait
  is where a failed update shows up first.
* trainer_worker: compile and restore_checkpoint drop a micro-batch staged
  before them, so an orchestrator that died without stop() no longer makes
  every successor's first stage fail.

Tests. New rl_program_pipelined_boundary_robustness_test, adapted from the
review: the take-ahead's memory bound, also in a production-like streaming
regime, with a runaway it catches and the equivalence suite does not; a
failed update in every mode against a token-wait and a device-wait
trainer; a stage that fails in transport, and a reference call that fails
in transport or in the reference worker, fall back and train exactly what
the base loop trains (with a negative control); the hole-rule fuzz; a
stale stage after a crash. trainer_worker_staging_test: the attribution
of both waits, and compile/restore dropping a held stage.
…ary test

The frozen copy of _train_loop was pinned against a local merge commit that
is not on GitHub. Its source hash (37032f00...14f1dd) is the same at
atwigg/mlperf's merge of #2719 (c521272) and at 21b62f2, so
cite those instead. Comments only.
With --stage_next_microbatch, the next step's first micro-batch went to the
trainer right after the step's last train call, which runs the update and,
when a stage follows, returns at the update's dispatch. Under MaxText's
retry_when_tokens_dropped (dropless replay, maxtext#5570, and the final
dropless micro-batch of #5673, in every MLPerf recipe today) update() reads
the last micro-batch's overflow flag on the host before it dispatches the
update, so that call returns only once the last micro-batch has finished.
The transfer then found the device idle (only the update left), the device
wait read ~0, and the latch switched staging off at the first boundary.

Stage between the last micro-batch and its update instead, when the trainer
can defer the update:

- TrainerWorker.fwd_bwd(apply_optimizer=True, defer_update=True) runs the
  step's last micro-batch (the trainer still sees apply_optimizer, so the
  final dropless program is still chosen) and holds the update.
  apply_deferred_update() waits for the device (device_wait_s: how long the
  last micro-batch still ran once the stage was in place), runs the update,
  and confirms it when the trainer exposes last_update_token. A stage while
  an update is deferred waits for nothing. While it is deferred the worker
  refuses whatever needs the step closed (another micro-batch, update,
  get_metrics, a checkpoint, weight sync, actor log-probs, eval); compile and
  restore_checkpoint drop it, as they drop a stale stage. info() advertises
  stage_before_update.
- DistributedRLEngine.train_step(defer_update=True) and
  apply_deferred_update(); trainer_staging_support() reports
  stage_before_update. A worker that runs the update anyway is handled as a
  fused call (one update, never two).
- StandardRLProgram(stage_before_update=True), --[no-]stage_before_update:
  at a planned stage, when the trainer supports it, the last call is sent
  with its update deferred, then _stage_next_before_update stages the next
  micro-batch under the same refusals as before and runs the update; the
  latch reads the deferred update's device wait. Otherwise (an older trainer
  image, or --no-stage_before_update) the stage goes after the last call, as
  before. The shared send/fallback path moves into _send_stage.

A replay of the last micro-batch inside update() needs nothing from the
staged micro-batch: its inputs are mapped from its own payload alone, and
the rerun reads the inputs it kept.

Tests:
- rl_program_stage_before_update_test.py: trainer doubles with MaxText's host
  blocking and real replays (the last, the first, or every micro-batch
  overflows and is rerun when its flag is read). Every harness scenario
  against seven doubles trains exactly what the base loop trains (strict
  check, reruns included) with every stage before its update; replays leave
  the staged micro-batch trained once and unchanged; under replay the stage
  hides (tr-s3 ratios: device waits 5.4-5.5 units, boundary gap 6.7 vs 15.3
  for the old hook, which latches after one stage); feed-bound still latches;
  randomized draws; hook selection, checkpoint stamps, failure fallbacks.
  Controls: a rerun that reads the staged micro-batch, an update run before
  the stage, a stamp at the last call's return, an update measured before
  the device wait, and an update never applied are each caught.
- trainer_worker_deferred_update_test.py, distributed_rl_engine_deferred_update_test.py.
- The pipelined-boundary suite pins the old hook (stage_before_update=False);
  the staging-support dicts gain stage_before_update.
…DED_HEAD)

compute_chunked_logps scans the sequence under GSPMD. On the MLPerf
Qwen3.5-397B trainer cell (fsdp32 x cp4 x ep2 on 256 devices, 64 x 65,536-token
packed micro-batches, compute_logps_chunk_size 1024) the compiled program
all-gathers the 2 GB vocab table in every scan step (forward and
rematerialized backward) and all-reduces a 2 GB table gradient in every
backward step: 129 table all-gathers and 65 table all-reduces per
micro-batch. Because the scan runs over a context-sharded sequence, every
context rank also computes every chunk, so the vocab matmuls are 4x
redundant.

With TUNIX_LOGPS_SHARDED_HEAD=local (or 1), packed rows go through a
shard_map'ed head that runs the same per-chunk math:
- each head parameter is all-gathered once per pass;
- each device scans only the tokens it holds (dynamic slices, no reshape
  of the sequence);
- the table gradient is accumulated per device in float32 and
  reduce-scattered once;
- the one-token shifts (targets left, outputs right) are explicit
  collective-permutes inside the shard_map, so they do not depend on how
  XLA partitions a shift of a sequence-sharded array.

The head falls back to compute_chunked_logps, with a log line, when there
is no mesh in context, when some mesh axis shards neither the batch nor
the sequence, or when a head parameter does not divide over the mesh.
Unset (the default), nothing changes.
Comments only, no code change: the ASTs of both modules are identical
before and after.

- rl_program.py, at the step's final micro-batch: why
  _stage_next_before_update, which always applies the update, runs before
  get_metrics, the checkpoint and the weight sync (the worker refuses them
  while an update is deferred); what a result without update_deferred means
  (the trainer ran the update inside the call, so the stage goes after it,
  as with --no-stage_before_update); and why stage_after is cleared once the
  deferred path ran (staging after the update's dispatch is the order this
  path replaces, and under dropless replay it would find the device idle and
  latch staging off).
- trainer_worker.py, apply_deferred_update: why the deferred flag is cleared
  only after the device wait (update() refuses to run while an update is
  deferred, and the wait is what tells the caller whether the stage hid
  behind the step's last micro-batch).
Text only. The stagger test's docstring named the shared CPU machine its
first runs used; a cost comment cited an internal write-up for the
siv-sk1r numbers, which come from that run's TRAINER_STEP_TIMING lines;
and test docstrings and comments cited internal review and design item
ids ("Rule 7", "review P1/P2", "review finding F1", "diag-L3 R1/R3/R5").
They now say what the tests do. No code change: the module ASTs are
identical apart from docstrings.
…omment

The --pipeline_step_boundary help text and the comment on
StandardRLProgram.pipeline_step_boundary said step k's metrics and logging
run while step k+1's first micro-batch "is on its way". The loop waits for
that train call to return first (the SENT_AHEAD block in _train_loop), so
they run once it has returned, while the device computes it. The help text
also names what the send waits for: the checkpoint, the weight sync (only
its staging with --async_weight_sync) and the commit.

Help text and comment only; no behaviour change.
TRAINER_TUNED_FLAGS (true or false) applies the trainer settings measured on
the RL trainer bench (AI-Hypercomputer/maxtext#5701) at the recipes' 128-chip
trainer cell. They are the settings of runs 59 and 62 in
AI-Hypercomputer/maxtext#5409 that the recipe does not use yet:

- the trainer libtpu flag --xla_tpu_enable_offloading_copy_to_sparsecore=false,
  appended to TRAINER_LIBTPU_INIT_ARGS before the trainer env, the Pathways
  workers and the Pathways proxy copy it (a value already there is kept);
- seven MaxText keys: context_parallel_strategy=all_gather
  sa_fuse_reciprocal=true sa_use_base2_exp=false gdn_cp_mode=seq
  gdn_cp_state=device moe_route=device, appended last (MaxText keeps the last
  value of a repeated key), and num_moe_token_chunks=2 through
  NUM_MOE_TOKEN_CHUNKS.

The switch is on in mlperf_gbs1024_submission.sh. It is off in
mlperf_397b_256_v7x.sh and in the recipes that source it, whose renders are
byte-identical to before; in the submission recipe, TRAINER_TUNED_FLAGS=false
renders the trainer as before. With the replay layout (a finite
RAGGED_BUFFER_FACTOR with RETRY_WHEN_TOKENS_DROPPED=true), where it was
measured, the switch sets two MoE token chunks instead of four. The fully
dropless layout (RAGGED_BUFFER_FACTOR=-1) keeps four, and an explicit
NUM_MOE_TOKEN_CHUNKS wins.

Bench, 128 v7x chips, ragged_buffer_factor 2.75: 130.8 s/step for the team
cell; 126.4 with the libtpu flag (another pod); 121.7 with the flag and the
seven keys (same pod as 126.4).

Tests: nine render tests in mlperf_submission_launcher_test.py. The existing
test that pins four chunks for the replay layout now renders with the switch
off.
TRAINER_TUNED_FLAGS=true now also appends gdn_cp_matmul_precision=high and
cast_grads_after_all_reduce=true to the trainer's MaxText flags, in the same
block as the seven keys:

- gdn_cp_matmul_precision=high: under gdn_cp_mode=seq, the float32 matmuls
  that compose the GDN state across the sequence shards run 3 bf16 passes
  instead of 6.
- cast_grads_after_all_reduce=true: a gradient that the forward/backward pass
  sums by an all-reduce alone, and that is not already float32, leaves it in
  the parameter's dtype and is cast to float32 after the all-reduce.

Both keys have these meanings at this branch's MaxText pin (60425bf) and in
the bench image's MaxText (3792fdef8).

Bench (AI-Hypercomputer/maxtext#5701, 128 v7x chips), measured with the
sharded log-prob head of #2873 on in both arms: 107.59 -> 106.24 s/step in
one pod, and 107.71 -> 106.41 in a second pod that trained on real gradients.
In that pod the gradient norm moved 1.47e-5 relative (two runs with the keys
differ by 1.92e-5), the forward log-prob metric moved 1.1e-6 (3.6e-7), and
the loss was identical as printed.

Four MoE settings measured alongside them (prefuse_moe_weights=false,
moe_accumulate_wi_dlhs=true, moe_accumulate_chunk_wgrad=true,
decoder_layer_input=device) moved the gradient norm by -0.80% and the forward
metric by 9.7e-4, so they are left out.

Tests: the exact-set test expects nine keys, and _MAXTEXT_DEFAULTS has the
two keys' MaxText defaults.
…ranch

One conflict, in tunix/experimental/orchestrator/rl_program.py, resolved by
keeping both sides: StandardRLProgram.__init__ takes the over-generation
arguments from #2633 (num_generations_to_dispatch, prefer_valid_rollouts)
followed by #2877's pipeline_step_boundary, stage_next_microbatch and
stage_before_update.
…ep boundary in the GBS-1,024 submission recipe

- mlperf_gbs1024_submission.sh: TUNIX_LOGPS_SHARDED_HEAD=local (#2873),
  PIPELINE_STEP_BOUNDARY=1 and STAGE_NEXT_MICROBATCH=1 (#2877, the env vars
  that mlperf_base.sh and k8s_launcher.sh read). STAGE_NEXT_MICROBATCH follows
  PIPELINE_STEP_BOUNDARY unless set, so PIPELINE_STEP_BOUNDARY=false alone
  turns both off.
- k8s_launcher.sh: passes TUNIX_LOGPS_SHARDED_HEAD to the trainer process when
  it is set. TRAINER_EXTRA_ENV cannot carry it from this recipe: a value set
  here replaces mlperf_397b_256_v7x.sh's default TRAINER_EXTRA_ENV, including
  the trainer's LIBTPU_INIT_ARGS. Unset, nothing is rendered, so the other
  recipes render as before.
- mlperf_submission_launcher_test.py: the boundary is on by default in this
  recipe, PIPELINE_STEP_BOUNDARY=false turns both flags off, the refusal test
  sets PIPELINE_STEP_BOUNDARY=false, the head reaches the trainer only and 0
  turns it off, and the other 397B v7x recipes keep both off.
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