Repository navigation
Conversation
…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.
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.
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.
One conflict, in tunix/experimental/orchestrator/rl_program.py: #2633 and this branch each added keyword parameters at the end of StandardRLProgram.__init__. Both are kept: #2633's num_generations_to_dispatch and prefer_valid_rollouts, then this branch's pipeline_step_boundary, stage_next_microbatch and stage_before_update.
|
Heads-up from an e2e run of this PR together with #2713 (TRAINER_STEP_TIMING), on the 1013b image lineage (Tunix 21b62f2 + #2873 + #2878 + this PR's head 42ada6c + #2713), 128-chip trainer, 128 engines, The run crashed at the first step boundary (end of step 0): The failing line is the "train_step fwd_bwd RPC returned ..." log statement from #2713, which reads A one-line fix on the #2713 side (log |
|
Follow-up with the exact fix we are running with, for review (interaction between this PR's staged path and the #2713 log line in @@ -1062,7 +1062,7 @@ class DistributedRLEngine(rl_engine_interface.AbstractRLEngine):
" (request_id=%s, apply_optimizer=%s)",
role_name,
time.monotonic() - t_rpc,
- request.request_id,
+ staged_request_id if staged_request_id is not None else request.request_id,
apply_optimizer,
)
if is_actor:Commit on our branch: sivaibhav/mlperf-1013b-allprs @ 3d301c4 (ultrons/tunix), on top of 21b62f2 + #2873 + #2878 + this PR's head 42ada6c + #2713. With it the same e2e configuration (128-chip trainer, 128 engines, |
TL;DR
--pipeline_step_boundarypacks and sends step k+1's first micro-batch without waiting for step k's bookkeeping.--stage_next_microbatchalso uploads it to the trainer while the devices still compute step k's last micro-batch, by default holding step k's update back until the upload is in.How it works
The default order at a staged boundary (both flags on):
sequenceDiagram participant O as Orchestrator participant W as TrainerWorker participant D as TPU devices Note over O: first micro-batch of step k+1 already packed O->>W: fwd_bwd(last micro-batch of step k, defer_update=True) W->>D: run the last micro-batch of step k W-->>O: returns early, update held back rect rgba(46, 160, 67, 0.2) O->>W: stage_fwd_bwd(first micro-batch of step k+1, about 5 GB) Note over W,D: the upload overlaps the last micro-batch on the devices end O->>W: apply_deferred_update() W->>D: wait for the devices, then run the update of step k O->>W: get_metrics, save_checkpoint, weight-sync staging O->>W: fwd_bwd_staged(request_id), no data attached W->>D: first micro-batch of step k+1 starts right away Note over O: step k bookkeeping (metrics, logs, on_step_end) runs nowWith the flags off, the devices sit idle from step k's update until step k+1's first micro-batch arrives: through the metrics fetch, save, weight-sync staging, the orchestrator's bookkeeping and packing, and the ~5 GB upload.
--pipeline_step_boundaryalone takes the bookkeeping and packing off that path;--stage_next_microbatchtakes the upload off too. The update is held back because under MaxText'sretry_when_tokens_droppeda call that runs the update returns only once the last micro-batch is done, so a stage sent after it would have only the ~1.5 s update left to hide behind.Results
Trainer-only replay rig on 128 v7x chips: the team's MLPerf trainer cell (fsdp32 x cp4 x ep2,
retry_when_tokens_dropped, lr 0), driven by the realStandardRLProgramandTrainerWorkerover gRPC with frozen real trajectories. Runparambole-l3m, 3 micro-batches per step; all arms back to back in one run on the same pods, 5 steps each; medians.--pipeline_step_boundary--stage_next_microbatch --no-stage_before_update--stage_next_microbatch(default order)On the MLPerf RL trainer bench (AI-Hypercomputer/maxtext#5701, the team's trainer program with its real MaxText/libtpu flags; 128 v7x chips; one pod, same order; the bench driver given the same stage-before-update order behind an off-by-default switch):
−7.7 s per step (pre-registered −7.4, band −8.5 to −5.5), from the step boundary only: the regular micro-batch did not change. Same micro-batches, order and updates in both arms.
Exact in every arm: the same micro-batches in the same order (content digests), the same summed loss denominator at every step, and every micro-batch of step k+1 entering after update k. For scale, a team training run (
siv-sk1r, 128-chip trainer) idles 27.6 s per boundary (a different run, not an A/B). On the 2-micro-batch rig (parambole-l3x), where this window is closed by construction, the default order cut idle from 12.40 to 7.56 s, but only about 2.3 s of that survives netting out each boundary's own upload time (upload speed varied between arms).Caveats: rig numbers only (no rollouts, weight sync or checkpoint save; production has ~9 micro-batches per step). The loss does not repeat step to step at this cell even with the flags off, so numerics rest on the exact checks above and on the CPU tests. The rig ran this branch at
26b06c504(later commits change only comments, docstrings and a help string) with the MaxText PR's code (MaxText2d821dfc1).Changes
orchestrator/rl_program.py: a step's intake state moves into_StepState._take_groupand_prepackpack step k+1's groups ahead (at most one feed, one step ahead);_send_first_microbatch_aheadsends its first micro-batch before step k's bookkeeping._stage_planneddecides before step k's last call whether to stage;_stage_next_before_update(default) or_maybe_stage_next_microbatch(after the update) stage through_send_stage, which falls back to an unstaged send if the transfer fails.worker/trainer_worker.py:fwd_bwd(..., wait_for_update=True, defer_update=False); newstage_fwd_bwd,fwd_bwd_stagedandapply_deferred_update. While an update is deferred, the worker refuses every call that needs the step closed;compileandrestore_checkpointdrop a stale stage or deferred update.orchestrator/distributed_rl_engine.py:stage_train_step(returnsStagedTrainStep),apply_deferred_update,trainer_staging_support, andtrain_step(staged_request_id=..., defer_update=...).queue_manager/trajectory_queue_manager.py:BatchOrderedQueueManageralso passes a hole the serving cursor has already read past (serving >= next); unchanged for in-order consumers (fuzzed).run_deepswe_dist.py,k8s_launcher.sh,mlperf_base.sh: the flags; the recipe exits if staging is on without the pipelined boundary.--[no-]pipeline_step_boundaryPIPELINE_STEP_BOUNDARY--[no-]stage_next_microbatch(needs the flag above)STAGE_NEXT_MICROBATCH--[no-]stage_before_update(where the stage goes; only acts with staging)Size: ~1,650 source lines and ~7,400 test lines.
Testing
599 CPU tests pass in the new and extended files (jax 0.11.1). Each new hook, broken on purpose, is caught: 11/11 stage-before-update mutants, plus 9/9 loop, engine, worker and launcher mutants and 11/11 failure-path mutants on the commit before stage-before-update. The existing suites fail on the same 22 pre-existing test ids on the base (
atwigg/mlperfat8d245c561) and on this branch.The TPU rig is a harness around the real classes and is not part of this PR; its setup is in the details below.
Risks / rollout
PIPELINE_STEP_BOUNDARYand watch orchestrator RAM (+5.11 GB for the held micro-batch on an earlier 128-chip rig; skip it where the orchestrator is near its limit, e.g. ~205 of 240.5 GB at 1,024 chips). Then turn onSTAGE_NEXT_MICROBATCHonce the MaxText pin includes feat(training_engine): add prepare_fwd_bwd_input and wait_for_device to overlap the next step's input copy (-7.2% s/step, 397B RL trainer bench, 128 v7x) AI-Hypercomputer/maxtext#5718. Rollback is the flag.USE_ROLLOUT_LOGPS=true(the launcher's default; the MLPerf recipes set it to false). Without dropless replay, the default order pays one readiness round trip of idle per boundary (not measured on TPU);--no-stage_before_updatemay be better there (CLI only for now).Related
fwd_bwd+ update and deferredget_metrics; this builds on it.prepare_fwd_bwd_inputandwait_for_device, which turn staging on.TRAINER_STEP_TIMING, the instrument behind the team-run numbers.Full evidence and notes
The previous description, verbatim: the full rig setup, both TPU runs with every caveat, the CPU test table and the reproduction steps.
Summary
Opt-in changes that take orchestrator and transfer work off the trainer's step boundary. Everything is off by
default, and off runs exactly today's loop.
--pipeline_step_boundary(PIPELINE_STEP_BOUNDARY=true):deferred metrics fetch are done;
on_step_endand host-memory release once that call has returned, while thedevice computes it.
--stage_next_microbatch(STAGE_NEXT_MICROBATCH=true; needs the first flag): also ships that micro-batch's data tothe trainer host while the device still computes step k's last micro-batch, so its train call carries no data.
--stage_before_update) step k's last train call holds the update back (defer_update=True). Thestage goes in between, and
apply_deferred_updatethen runs the update.--no-stage_before_updatestages after the update's dispatch instead.wait_for_device, and usesprepare_fwd_bwd_inputwhen present (both fromfeat(training_engine): add prepare_fwd_bwd_input and wait_for_device to overlap the next step's input copy (-7.2% s/step, 397B RL trainer bench, 128 v7x) AI-Hypercomputer/maxtext#5718). With a MaxText that lacks
wait_for_device, the flag logs a warning and the boundaryruns pipelined but unstaged.
The trainer receives the same micro-batches, in the same order, and the same updates. Staging adds one data-only stage
call, and staging before the update moves the update into its own call.
Why
At every step boundary the trainer's devices sit idle until step k+1's first micro-batch is on them. Measured on the
team's 128-chip trainer (run
siv-sk1r, itsTRAINER_STEP_TIMINGlines, 6 normal boundaries):[pipeline] COMMITto the next train call)--pipeline_step_boundarytakes the bookkeeping leg off the boundary. Staging targets the transfer and input legs.Why stage before the update: under MaxText's
retry_when_tokens_dropped(opt-in in the recipes asRETRY_WHEN_TOKENS_DROPPED=true; the team's 128-chip trainer runs and the TPU cell below use it withragged_buffer_factor=2.75),update()reads the last micro-batch's overflow flag on the host before it dispatches theupdate. So a train call that runs the update returns only once the last micro-batch has finished. A stage sent after
that call finds the device with only the ~1.5 s update left, so it hides almost nothing, and the latch switches staging
off at the first boundary (shown on CPU, both ways). With the update deferred, the last train call returns as soon as
the micro-batch before it is done. The stage then overlaps the whole last micro-batch, and the update goes out when
update()would have dispatched it anyway (from the source; not measured separately). That needs at least 3micro-batches per step (see caveat 1 under Evidence). The default order is chosen for replay; without replay,
--no-stage_before_updatemay be the better one (see Risks).How it works
The order on the trainer at a boundary where a stage is planned:
fwd_bwd(last) + update(one call, #2719) ->get_metrics->save-> weight-sync staging ->fwd_bwd(k+1 first, payload)--pipeline_step_boundaryfwd_bwd(k+1 first)returns, while the device computes it--stage_next_microbatch(default: before the update)fwd_bwd(last, defer_update=True)->stage_fwd_bwd(k+1 first)->apply_deferred_update->get_metrics->save-> weight-sync staging ->fwd_bwd_staged(k+1 first)--stage_next_microbatch --no-stage_before_updatefwd_bwd(last) + update(returns at the update's dispatch) ->stage_fwd_bwd(k+1 first)->get_metrics->save-> ...With a trainer that confirms its update (
last_update_token, AI-Hypercomputer/maxtext#5571),get_metricsstaysdeferred until after the save, as #2719 does for a confirmed update.
The rules every mode keeps:
get_metricsfollows step k's update and precedes step k+1's firstfwd_bwd;The decision to stage is made before step k's last call, because that call has to know whether to defer. No stage is
planned:
val_start_steponwrites one);
USE_ROLLOUT_LOGPS=true, thelauncher's default; the MLPerf recipes set it to false.
A planned stage is skipped when the next micro-batch is not packed by the time the last call returns. The update is
then still deferred, and pays the device wait before it.
While an update is deferred, the worker refuses
fwd_bwd,update,get_metrics,save_checkpoint,prepare_weight_sync,per_token_logps,eval_stepandrun_eval. A stage that fails on the way to the trainer fallsback: the micro-batch is sent whole, staging switches off for the run, and the deferred update still runs. A stage the
trainer ran and that raised ends the run, as a failed train call does. In the default order that happens before step
k's update and checkpoint, which the base loop would have saved.
apply_deferred_updatewaits for the device first and reports how long (device_wait_s); while an update is deferred,stage_fwd_bwdreports no wait. A wait under 50 ms means the upload outlasted the last micro-batch, and staging latchesoff for the run. The wait does not cover the host-to-device copy of the staged input (caveat 5).
Changes
tunix/experimental/orchestrator/rl_program.py_StepState._take_group,_prepack): at most one feed's output, one step ahead._send_first_microbatch_ahead, logged[pipeline] SENT_AHEAD step=k)._stage_planned,_trainer_can_stage_before_update,_stage_next_before_update, theafter-the-update hook
_maybe_stage_next_microbatch, and the shared send-and-fall-back path_send_stage.tunix/experimental/worker/trainer_worker.pyfwd_bwd(..., *, wait_for_update=True, defer_update=False).stage_fwd_bwdholds one micro-batch. It prepares it withprepare_fwd_bwd_inputwhen the trainer has that. Whilean update is deferred it waits for nothing; otherwise it waits for the device and confirms the update.
fwd_bwd_stagedandapply_deferred_update.info()advertisesstage_fwd_bwd,reports_device_waitandstage_before_update.compileandrestore_checkpointdrop a stale stage or deferred update left behind by a dead orchestrator.tunix/experimental/orchestrator/distributed_rl_engine.py:stage_train_step,trainer_staging_support,apply_deferred_update, andtrain_step(staged_request_id=..., defer_update=...). These go through perf(orchestrator): fuse final microbatch fwd_bwd+update RPC and defer get_metrics off weight-sync path #2719's responsehandling, so a worker that ran the update after all counts as a fused call (one update, never two).
tunix/experimental/queue_manager/trajectory_queue_manager.py:BatchOrderedQueueManagerpasses a hole the servingcursor has already read past (
serving >= next). Without read-ahead this gives traces identical to today's rule(fuzzed).
run_deepswe_dist.py,k8s_launcher.sh,mlperf_base.sh: the flags below. The recipe refuses staging without thepipelined boundary.
Size: ~1,650 source lines and ~7,400 test lines.
Flags
--[no-]pipeline_step_boundaryPIPELINE_STEP_BOUNDARY--[no-]stage_next_microbatchSTAGE_NEXT_MICROBATCHStandardRLProgramraises)--[no-]stage_before_update--stage_next_microbatchand a trainer that advertisesstage_before_update, otherwise the stage goes after the update's dispatch, with a warningEvidence
TPU, 128 v7x chips (our run
parambole-l3x, 2026-10-14)A trainer-only replay rig at the team's MLPerf trainer cell: fsdp32 x cp4 x ep2,
ragged_buffer_factor=2.75,retry_when_tokens_dropped=true, lr 0. It ran this PR's Tunix code (byte-identical to this branch at26b06c504; thelater commits change only comments, docstrings and one help string, and merge two upstream files this PR does not
touch) with the MaxText PR's code on the image's MaxText. Five steps per scored arm, all arms in one trainer process. All numbers are MEASURED except the
derived column.
--pipeline_step_boundary--stage_next_microbatch --no-stage_before_update--stage_next_microbatch(this PR's default)"Boundary device idle" is measured from step k's last micro-batch finishing on the device to step k+1's first
micro-batch starting, minus the update's own device time:
G_full = (k+1's first micro-batch starts) - (update landed) + max(0, update dispatched - last micro-batch finished).A3's four boundaries (6.86-8.17 s) are all below every flags-off boundary (9.40-12.76 s).
Exact checks:
content digests), with equal summed loss denominators (1,020,089);
train_stepit reported), andthe program's logged loss and grad_norm equal the trainer's;
fwd_bwd(the warm-up arm took thefirst-time compiles);
Read these numbers with the caveats:
This rig cannot show the overlap the change is for, except at one boundary. It has 2 micro-batches per step;
production has about 9. MaxText flushes the previous step's metrics inside a step's second
fwd_bwd, and that flushwaits for the device. With 2 micro-batches per step the second call is the last one, so it returns only once its
micro-batch has finished. At every scored boundary of every arm the micro-batch was already done when the call
returned (the readiness watcher, started at the return, fired 0.005-0.020 s later), so no scored stage overlapped
compute. The one open window was the warm-up arm's first boundary, before any metrics were queued: there the last
call returned 13.0 s early, the stage hid behind 5.56 s of remaining compute, and the boundary idle was 0.98 s (n=1).
On the scored boundaries, what A3 gains comes from doing the upload and the input placement before the update
instead of after the step's bookkeeping.
Upload speed varied. The same 5.15 GB payload took 4.0-7.6 s to reach the trainer, and A3's four stages happened
to be fast (4.0-5.1 s) while A0's uploads were slow (6.9-7.5 s). Net of each boundary's own upload time (the derived
column), stage-before-update saves about 2.3 s per boundary against flags off on this rig, not 4.8 s. Staging after
the update does a little better here (2.3 vs 2.9 s net), INFERRED to be because its upload starts 1.5 s before the
update lands and so overlaps it. Without compute to overlap, there is nothing for the before-update order to win.
Placing a prepared input on the devices took a further 3.7-3.9 s after
prepare_fwd_bwd_inputreturned, about whatan unprepared payload costs inside its train call (3.7-4.0 s here).
Pre-registered before either attempt of this run, for this rig: a closed window at every boundary but the
warm-up arm's first; A3's pre-update idle 8-11 s; A3's boundary idle about A2's + 1.6 s; the in-pod gap reading a
false win of 0.4-1 s for A3; the A2 latch not tripping; and A3's step wall not below A1's. Measured: closed window
(held), 7.54 s, A2 - 1.03 s raw (+0.60 s net of upload), 0.016 s, not tripped (held), and 3.7 s below A1. So two
held and four missed, all in A3's favour, mostly through the upload speed in caveat 2.
The rig's in-pod verdict reads FAIL (63 of 70 gates; 9 of 11 predictions), and it cannot certify numerics
either way. No exact check failed. The 7 misses:
run these are unscorable, because the flags-off arms fail the same band themselves (only 1 of 5 steps each is
within ±2%, at identical inputs and lr 0). The trainer's loss is not repeatable step to step at this cell: the two
flags-off arms' per-step losses range from -0.0415 to -0.0621 (cause not identified), and the scored staged
arms' (A2, A3; -0.0471 to -0.0885) fall partly outside that range;
stayed inside it at every step;
latches.
The two missed predictions are the step-wall savings: staged 4.78 s against 9-20 s, pipelined 1.06 s against
1.5-8 s. Some hits are not evidence either: the three in-pod gap gates for A3 use a gap measured from the update's
landing, which the reading rule calls a false win on this rig, and the "every stage hid" wait gates pass only
because of the wait floor in caveat 5. The numerics claim rests on the exact checks above and on the CPU tests
below.
The 50 ms latch is blind on this stack. The device wait the latch reads (in
apply_deferred_updatefor A3, instage_fwd_bwdfor A2) read 2.7-2.9 s at all 8 scored stages, although the last micro-batch had finished beforethe stage began. It also returned 0.97-1.01 s before the staged input's copy was ready. So on this stack a stage
that does not hide cannot switch itself off, and a copy that overruns is not seen.
Production gain is INFERRED, not measured. With ~9 micro-batches per step the last call returns about one
micro-batch (13.4 s at this cell) before the device finishes. That is room for production's ~11.2 s upload (8.45 s
median outside the weight-sync transfer window, 11.58 s inside it). If the ~2.8 s wait floor measured here also
applies while compute remains, the update slips by up to ~1 s; the placement can finish after the update, before
the staged call. Estimates made before this run: 16.8-20.7 s of boundary idle hidden per trainer-bound boundary.
End to end at today's recipes the gain is small, because rollouts usually dominate the step: about 16-18 s per
~26-minute team run in one analysis of three team runs (about 0 in a run whose scored step waited on rollouts), and
17-41 s per run in another; the pipelined boundary alone, about 12 s. None of these include the ~2.8 s device-wait
floor measured here. The 3-micro-batch run below settles the rig question; an end-to-end run is still needed before quoting
a whole-run speedup.
TPU, 128 v7x chips, 3 micro-batches per step (our run
parambole-l3m, 2026-10-14): the decisive A/BThe rig above has 2 micro-batches per step, which closes the staging window. This run serves the same frozen real
trajectory groups twice per step, so every step has exactly 3 micro-batches (checked at every step of every arm). The
decision rules, bands and scorer were written and stored before the run.
--pipeline_step_boundarythe larger of two independent estimators. Every one of A3's 4 boundaries is at most 0.045 s.
denominator at every step (2,040,178), and every micro-batch of step k+1 entering after update k. In A3 the stage
reached the trainer before the update at every boundary.
trainer at this cell does not repeat step to step), so numerics rest on the exact checks above plus the CPU tests.
26b06c504, MaxText2d821dfc1); later commits onthis branch change comments, docstrings and a help string only.
(see the estimates above).
CPU tests (jax 0.11.1, on a shared Linux machine; logs available)
rl_program_stage_before_update_test.pyrl_program_pipelined_boundary_test.py+..._robustness_test.py(the after-the-update hook, pinned withstage_before_update=False)tests/experimental/{orchestrator,worker,queue_manager}+ launcher test, baseatwigg/mlperf(8d245c561) vs this branch (the three long files above run separately)sqlalchemy, 1 needstpu_sync(collection error), 5PaddedBatchAssemblerTestcases are stale on the base (they treat_pack_chunk's tuple as a payload), and 5AbstractWorkerTestcases fail in this environment. TheAbstractWorkerTestones constructTrainerWorker, so they give no signal about this PR's worker changes. TwoRLProgramTestcases that write to hard-coded/tmppaths were deselected at the head; both pass on the base and passed in an earlier head run. The comparison reads DIFFERENT when one failure is removed (control)26b06c504, the code of this branch. Loop, engine, worker and launcher: 9 of 9; failure paths: 11 of 11; both on the code before the stage-before-update commit, which then reworked some of their targets (the fall-back path, now_send_stageand shared by both orders, and the latch). The unmutated controls pass, and a mutation whose target is missing reports NOT-APPLIEDStandardRLProgram+TrainerWorker+ gRPC against a timed trainer stub that blocks like MaxText's replay--exit-zero): a few long docstring or pragma lines andnot-callablewarnings ongetattr+callable()probes, a pattern the base already usesHow flags-off is shown to be today's loop: every scenario also runs through a byte-identical frozen copy of the loop as
it was before this change, pinned by the sha256 of its source, which equals
atwigg/mlperf's_train_loop. Flags offmust match it in every trainer call, program event and metric. Both flag modes must match it in every trainer call:
get_metricsfetch;This is checked over 14 scenarios x 3 modes plus randomized draws, against a trainer that can confirm its update and
one that cannot. The stage-before-update file does the same against seven trainer doubles: two that do not block
(with and without an update token), two that block like MaxText's replay but never overflow, and three that also
overflow and really rerun (the last micro-batch, with and without a token, or the first). A separate test has every
micro-batch overflow. A replayed last micro-batch leaves the staged micro-batch trained once and unchanged.
How to reproduce
CPU, from the repo root, with MaxText and
google-metraximportable:TPU: the rig is a harness around the real classes. It is not part of this PR, and I can share it.
StandardRLProgramandTrainerWorkerdrive it over gRPC.step, 5.15 GB of payload each. No rollout, weight sync or checkpoint save.
each.
trellis-experimental@sha256:b4647c188b25638e644c7a6fc53fb8e0751741525a277a1bcf07dca92ba9a6acwiththis PR's files overlaid; Pathways server and proxy from the Oct-9 build (
662f33b0…,e714351c…).13.38 s micro-batch time.
To try it in a recipe:
PIPELINE_STEP_BOUNDARY=true, then optionallySTAGE_NEXT_MICROBATCH=true. Staging needs aMaxText pin that includes the MaxText PR.
Risks and rollout
PIPELINE_STEP_BOUNDARYand measure orchestrator RAM. It holds one packed micro-batch ahead: +5.11 GB onan earlier 128-chip rig, and up to ~5.2 GB at 64 rows (inferred). Do not enable it where the orchestrator is near
its limit (at 1,024 chips it already peaks at ~205 GB of 240.5 GB).
STAGE_NEXT_MICROBATCHafter the MaxText pin bump (the 3-micro-batch A/B above is done: boundary idle12.46 s -> 0.02 s).
Train stepline andon_step_endcome after[pipeline] SENT_AHEAD step=k, andon_step_begin(k+1)fires after step k+1's first call returns. Under ARRIVAL order the dispatch window opens one traincall later. PROMPT_BATCH, the recipe default, is unaffected.
apply_deferred_updatewaitsfor the device before it dispatches the update. That costs at least one readiness round trip of device idle per
staged boundary, which the after-the-update order does not pay (not measured on TPU). For runs without replay,
--no-stage_before_updatemay be the better order; the launcher has no env var for it yet.difference. The latch is meant to switch staging off after the first such boundary, but under Pathways it cannot see
it (caveat 5), so the slip would recur at every staged boundary.
prepare_fwd_bwd_input, about 20 MB per device of prepared inputs from the stageuntil that micro-batch trains (5.15 GB over 256 devices); without it, the 5.15 GB payload on the trainer host.
fwd_bwd_stagedcalled while an update is deferred would drop the staged micro-batch beforefwd_bwdrefuses. Theprogram never makes that call (it applies the update first), and no test covers it.
checkpoint but loses step k's step line, StepMetrics and
on_step_end, which the base loop keeps. The run is endingin both cases.
BoundaryTimingTest,StagingLatchTest, the stagedFailedUpdateTestcases, and the per-boundary stage checks inPipelinedBoundaryEquivalenceTestandStageBeforeUpdateEquivalenceTest. One of those checks failed once in theearlier run above and passed 3 of 3 serial reruns. Before this leaves draft, those checks should count only boundaries
whose next micro-batch was packed in time (or the tests should hold the feed), or be marked as perf tests.
rl_program.py), sowhichever lands second ports the other's bookkeeping. Split rollout worker pools: pin batch b to pool b % (max_staleness+1), optional per-pool weight sync #2868, orchestrator: hold router-replay routing bit-packed until packed (opt-in, 0.6x per held trajectory) #2866 (
__init__keyword lines) and test(orchestrator): start-up stagger end to end on the dispatch loop and launcher-to-parser #2849 (launcher testmethods) conflict trivially, 1 hunk each, keep both sides. feat(orchestrator): support MIN_ROLLOUT_REPLICAS and full-pipeline trajectory queueing metrics #2847, recipes(397b v7x): knobs for remat, GDN CP mode and expert-weight prefetch #2860, [DO NOT MERGE] Opt-in sharded GRPO log-prob head: gather the vocab table once, scan local tokens (-11.8% step time, v7x 397B bench) #2873 and fix(raiden): pin FFI control-plane listeners to primary host IP #2874 merge cleanly with this
branch.
Related
fwd_bwd+ update and the deferredget_metrics; this builds on it.prepare_fwd_bwd_inputandwait_for_device, which turn staging on, plus enginetests of the stage-before-update order under dropless replay.
update, and
get_metricsstays deferred.TRAINER_STEP_TIMING, the instrument behind the numbers in "Why".Merge order
requirements/maxtext_requirements.txtto a MaxText commit that has 1 (and #5571 for update confirmation)before enabling
STAGE_NEXT_MICROBATCH.