Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine - #5060
Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine#5060NuojCheng wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request optimizes gradient accumulation and memory footprint in the MaxText training engine. It defers gradient scaling to the update step by tracking unreduced gradients and their accumulated denominators, introduces a split-kernel compilation strategy to optimize buffer allocations, and limits the metrics history buffer to prevent unbounded memory growth. The review feedback highlights potential issues with non-array leaves in the update marker logic and warns against implicit truthiness checks on the restored denominator that could fail when its value is legitimately zero.
| gradient norm for the same reason (`peft_trainer_v2.py`, `_last_update_grad_norm`); MaxText | ||
| only computes a norm when clipping or spike-skipping is on, hence a slice instead. | ||
| """ | ||
| leaves = [leaf for leaf in jax.tree.leaves(new_state_pure) if jnp.size(leaf) > 0] |
There was a problem hiding this comment.
Using jnp.size(leaf) in a list comprehension can fail if new_state_pure contains non-array leaves such as None (which is common in optax states). In JAX, None is treated as a leaf, and calling jnp.size(None) will raise a TypeError. It is safer to check if the leaf has a size attribute before accessing it.
| leaves = [leaf for leaf in jax.tree.leaves(new_state_pure) if jnp.size(leaf) > 0] | |
| leaves = [leaf for leaf in jax.tree.leaves(new_state_pure) if hasattr(leaf, "size") and leaf.size > 0] |
| # Restore intra-step state if it exists. | ||
| if restored_checkpoint_state.accumulated_grads: | ||
| self._accumulated_grads = restored_checkpoint_state.accumulated_grads | ||
| self._accumulated_denominator = jnp.float32(restored_denominator if restored_denominator else 0.0) |
There was a problem hiding this comment.
Using truthiness checks like restored_denominator if restored_denominator else 0.0 can be problematic if restored_denominator is legitimately 0.0 (e.g., a step with zero tokens). It is safer and more explicit to check is not None to handle the case where the denominator is missing versus when it is zero.
| self._accumulated_denominator = jnp.float32(restored_denominator if restored_denominator else 0.0) | |
| self._accumulated_denominator = jnp.float32(restored_denominator if restored_denominator is not None else 0.0) |
References
- Comparisons to singletons like None should always be done with is or is not, never the equality operators or implicit truthiness when 0 is a valid value. (link)
| # per-micro-batch losses just rebuilt above carry the very denominators that went into | ||
| # the saved gradients, so their sum is exactly what was lost. Only those count: any | ||
| # `_cached_losses` left over from before the restore belong to a different run. | ||
| if not restored_denominator and rebuilt_losses: |
There was a problem hiding this comment.
Using not restored_denominator will evaluate to True if restored_denominator is 0.0. This will trigger the rebuilding logic even if the denominator was successfully restored as 0.0. Check restored_denominator is None instead to correctly identify if the denominator was missing from the checkpoint.
| if not restored_denominator and rebuilt_losses: | |
| if restored_denominator is None and rebuilt_losses: |
References
- Comparisons to singletons like None should always be done with is or is not, never the equality operators or implicit truthiness when 0 is a valid value. (link)
Onboards the HBM and throughput work from tunix#1934 into the MaxText training engine. On llama3.1-8b (fsdp=8, per_device_batch_size=1, max_target_length=1024, 4 gradient-accumulation micro-batches, 8x TPU7x) an update drops from 3501 ms to 1303 ms (2.69x) and peak HBM from 27.97 GiB/device to 15.03 GiB/device (1.86x less), with resident memory unchanged at 11.29 GiB/device. In order of impact: 1. Trace the fwd/bwd and update kernels under the mesh and the logical axis rules the MaxText layers are written against. The engine jitted them outside `nn_partitioning.axis_rules`, so every `maybe_shard_with_logical` inside the layers was a silent no-op and XLA was left to guess how to partition activations and gradients. The same fwd/bwd measured 1012 ms outside the context against 581 ms inside it, with no numerical difference. `train.py` has always wrapped its own jit this way. 2. Donate buffers. The update kernel donates the train state, matching `maxtext_utils.get_functional_train_with_signature` (`donate_argnums = 0`), and the accumulating fwd/bwd kernel donates the gradient accumulator. Params are deliberately not donated: `micro_grads` has the same shard-shape, dtype and sharding, and JAX matches donations by shape rather than by position, so donating params would alias the weights straight into the gradient output. 3. Accumulate gradients inside the jit, with two kernels. The first micro-batch of an update returns its own gradients and the engine adopts them; later micro-batches fold into that buffer in place. Tunix v2 calls the same split non-persistent vs persistent mode. The Python-side `jax.tree.map(jnp.add, ...)` it replaces materialized the micro-batch gradients as a program output *and* allocated a fresh sum, two extra parameter-sized buffers live at once. 4. Normalize the accumulated gradients as sum/sum rather than as a mean of per-micro-batch means. Gradients now accumulate unreduced and `update()` divides once by the summed denominator. The two forms agree only when every micro-batch carries the same token count; under sequence packing or a ragged RL rollout the mean of means silently overweights short micro-batches. This is what MaxText's own pre-train path already does. The denominator is carried in checkpoint metadata so an intra-step save/restore round-trips. 5. Hand the inflight throttler one scalar read off the updated weights instead of the whole train state. The queue kept its entries alive until popped, pinning three parameter trees per entry, and after (2) those buffers are donated, so a late pop would have raised "Array has been deleted". 6. Bound the metrics history to the last 128 steps. `_metrics_buffer` grew one buffer of live device arrays per train step and nothing on the engine's step path removed one, so HBM, checkpoint size and checkpoint save latency all grew linearly in steps since the last read. Eviction warns once per window rather than dropping metrics silently. Tested: tests/end_to_end/tpu/test_training_engine_parity.sh passes all six verifications, including multi-micro-batch gradient accumulation weight parity against the lax.scan baseline; the engine unit tests and tests/unit/grpo_nnx_test.py pass.
Adds tests/end_to_end/tpu/compare_tunix_trainer.py, a head-to-head harness that drives MaxTextTrainingEngine and tunix.experimental.train.peft_trainer_v2.PeftTrainer over the same model, loss, micro-batches and optax transformation, plus an independently computed sum-of-grads / sum-of-denoms reference gradient. Documents the results in docs/reference/training_engine_tunix_parity.md. On Qwen3-0.6B / TPU v7x / fsdp=8: - At gradient_accumulation_steps=1 the two trainers are numerically equivalent (identical loss, gradients within rel_l2 3.0e-4). - With ragged micro-batches and GA>1 they diverge at rel_l2 0.68. MaxText accumulates the real token denominator and matches the exact gradient; peft_trainer_v2 accumulates denom=1.0 per micro-step (its own TODO(b/491970038)) and lands on mean-of-means. - MaxText is 1.91x faster per update at GA=1 and 2.28x at GA=4, in 1.77 GiB/device instead of 7.52 GiB. Cost analysis attributes this to memory traffic, not arithmetic: same FLOPs, 7.6x the bytes accessed, because nnx.jit carries no out_shardings and returns a fully replicated gradient tree. Also records a MaxText reporting bug found while measuring: the logged step loss is reduced with np.mean over micro-steps while the gradient uses sum/sum, so on ragged batches the reported loss disagrees in sign with the optimized one.
…length The previous step-time numbers were host wall clock at 16 tokens per example, taken from a loop that started its xplane trace before any warmup. Two consequences: a ~570 ms CompileAndLoad(jit__update_kernel) landed in the middle of every MaxText trace, because the donated update kernel re-lowers against the post-donation parameter layout; and the whole comparison sat at a shape where nothing is near roofline. compare_tunix_trainer.py now runs _WARMUP=2 untimed updates outside the trace context and takes --seq, so the same harness covers both a cheap shape for the numerics runs and a realistic one for step time. What the device timeline and the larger shape change: - MaxText's lead is real but shrinks with the shape. fwd_bwd is 2.75x at 16 tokens and 1.46x at 1024; total per update, untraced, is 1.85x and 1.25x. Tunix carries a constant ~13 GiB of extra memory traffic from its replicated gradient tree, so real workloads amortize it. - On device at 1024 tokens MaxText is 44.3 ms/step against Tunix's 63.7 ms. - xprof's step statistics are not usable for MaxText. It runs 424 XLA modules per 8 steps where Tunix runs 16, because MetricsRecorder._record_metric dispatches ~51 tiny eager ops per step, so step detection reports 424 fake steps with a 0.012 ms median. Those dispatches cost only ~2.8 ms of wall clock but they also make MaxText pay disproportionate per-dispatch tracing overhead, which is why all wall-clock figures are now from untraced runs. New traces, including GA=2 at 1024 tokens, are under gs://chengnuojin-xprof/maxtext-vs-tunix-2026-08-31/.
671cec4 to
3f3f902
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The previous sweep stopped at 1024 tokens and reported a 1.25x step-time lead for MaxText,
with a prediction that the two fwd_bwd implementations would "converge further" at
production lengths. Extending to 2048 / 4096 / 8192 confirms the prediction and makes the
1.25x figure misleading to quote:
tokens MaxText Tunix ratio
16 34.7 ms 64.0 ms 1.85x
1024 59.7 ms 74.7 ms 1.25x
2048 84.5 ms 102.1 ms 1.21x
4096 189.1 ms 203.9 ms 1.08x
8192 479.6 ms 493.2 ms 1.03x
At 8192 tokens the trainers are within run-to-run noise, so step time is not a reason to
prefer either.
The stated mechanism also only holds at small shapes. Tunix's excess bytes-accessed is
+14.7 GiB at 16 tokens and +13.0 at 2048, matching its replicated gradient tree, but it is
-0.7 GiB at 4096 and -12.4 GiB at 8192 -- MaxText accesses more there and is still
marginally ahead, because both are compute-bound on identical arithmetic by then.
Peak HBM is flat across the whole sweep (MaxText 1.77-1.79 GiB, Tunix 8.64-8.65 GiB). That
difference does not amortize, and is now called out as the durable one.
|
Two notes on change (4), both narrow — the normalization itself is right, and the 1. The scale can be computed once instead of per leaf. has_weights = accumulated_denominator > 0
safe_denominator = jnp.where(has_weights, accumulated_denominator, 1.0)
grads = jax.tree.map(
lambda g: jnp.where(has_weights, g / safe_denominator.astype(g.dtype), jnp.zeros_like(g)),
accumulated_grads,
)
scale = jnp.where(has_weights, 1.0 / safe_denominator, 0.0)
grads = jax.tree.map(lambda g: g * scale.astype(g.dtype), accumulated_grads)That expression is also exactly 2. The accumulator dtype should probably be pinned to float32.
tunix's
|
compare_tunix_trainer.py drives MaxText's qwen3-0.6b through both trainers, so it varies the trainer and holds the model fixed. These scripts add the third arm -- tunix's own `Qwen3` module -- which separates the two costs: tunix model + PeftTrainer v2 -> qwen3_tunix_profile.py (the baseline) MaxText model + PeftTrainer v2 -> qwen3_maxtext_profile.py (isolates the model) MaxText model + training engine -> qwen3_engine_profile.py (isolates the trainer) Everything not under test lives in qwen3_common.py -- constants, the synthetic dataset, the gen_model_input_fn and the StepTimer hook -- so "only one thing differs between arms" is enforced by construction rather than by diffing three scripts against each other. qwen3-0.6b because tunix's `ModelConfig.qwen3_0p6b` and MaxText's configs/models/qwen3-0.6b.yml describe the same 28-layer network field for field (vocab 151936, embed 1024, hidden 3072, 16 query heads, 8 KV heads, head_dim 128, norm eps 1e-6, rope theta 1e6, tied embeddings), so both arms run the real model with nothing truncated. An earlier gemma4-e2b attempt had to cut both sides to 12 of 35 layers and guess a matching KV-sharing split, and is not carried over. Measured on 4x TPU v5, fsdp=4/tp=1, batch 8 x seq 1024, float32, 23 steps at gradient_accumulation_steps=1, median of the 19 post-warmup steps: tunix Qwen3 + PeftTrainer v2 84.5 ms MaxText unscanned + PeftTrainer v2 96.0 ms (+13.6%) MaxText scanned + PeftTrainer v2 88.2 ms (+4.4%) MaxText unscanned + training engine 372 ms Reproducible to +/-1 ms: re-running the first two arms end to end gave 84.7 -> 84.5 and 96.8 -> 96.0. Every MaxText override moves it towards a tunix default and none the other way: dtype and weight_dtype float32, remat_policy none, scan_layers false, opt_type sgd, gradient_clipping_threshold 0.0, and warmup_steps_fraction 0.0 with learning_rate_final_fraction 1.0 to flatten the schedule into the constant 1e-5 the tunix arms use. remat_policy matters most: left at base.yml's `full`, MaxText would recompute each layer in the backward pass and the comparison would have measured a memory/compute tradeoff neither side asked for. One axis is deliberately not equalised, because it is part of what is being compared: MaxText's `attention: autoselected` takes the flash/splash path at seq 1024 while tunix's qwen3 leaves use_flash_attention False. That favours MaxText. The engine arm's figure was taken inside jax.profiler.trace, unlike the untraced wall-clock numbers in docs/reference/training_engine_tunix_parity.md, so the two are not directly comparable. StepTimer hooks on_train_step_start, which fires immediately after the inflight throttler's wait_for_next(). Once the queue saturates, the loop advances exactly as fast as the device retires steps, so the dispatch cadence is the step time and no profile has to be opened -- which matters, because trace.json.gz caps at 5,000,000 events and runs this size exhaust that on host events alone. Traces go to $PERF_PARITY_PROFILE_ROOT, defaulting to a local directory.
The arms hardcoded ga=1 and "every visible chip", so the only shape they could
measure was the one they were written for. None of that was reachable by flag:
* ga came from the ACCUM_STEPS constant, passed into both PeftTrainer arms.
* the mesh was built as (len(jax.devices()), 1), so device count and mesh shape
were the same knob.
* the engine arm could not have been fixed by a flag at all. The engine reads no
accumulation setting -- `_micro_step_count` counts `fwd_bwd` calls since the
last `update()` -- so ga is a property of the call pattern, and the loop had to
become `ga` fwd_bwd calls followed by one update.
Two consequences of ga > 1 that are easy to get silently wrong, both handled in
qwen3_common:
* the dataset grows by a factor of ga. Tunix counts `max_steps` in optimizer
steps but consumes one item per micro step, so 23 steps at ga=8 needs 184
items; the old sizing would have ended the run after 3 optimizer steps.
* StepTimer's hook fires per micro step, so `report(group=ga)` sums each run of
ga gaps back into one optimizer step. Without it the headline number would
silently change meaning from step time to micro-step time.
RunSpec holds the resolved shape in one object so the three arms cannot drift on
what is meant to be held fixed, and rejects a mesh that does not cover its
devices. A 2-device mesh on a 4-chip host is an explicit device subset passed to
jax.make_mesh / create_device_mesh / from_pretrained, not a restricted runtime, so
nothing about the process differs between shapes. MaxText's own
gradient_accumulation_steps stays at 1: the trainer is handed one micro-batch at a
time and does the accumulating, and setting it would have base.yml split
per_device_batch_size a second time.
Defaults are unchanged, so the committed 4-device ga=1 figures still reproduce and
their trace paths are unchanged -- tag() only suffixes non-default shapes.
Measured at ga=8 on 2x TPU v5, fsdp=2/tp=1, micro-batch 8 x 1024 (global 64),
23 optimizer steps, median of the 19 post-warmup:
tunix Qwen3 + PeftTrainer v2 1014.6 ms
MaxText unscanned + PeftTrainer v2 1158.4 ms (+14.2%)
MaxText unscanned + training engine 2033.4 ms (+75.5% over the row above)
The model delta is stable across shapes: +14.2% here against +13.6% at ga=1 on 4
devices. The trainer delta is not -- it was +288% at ga=1 and is +75.5% here,
which is what the structure predicts. At ga=1 PeftTrainer runs one fused jitted
step while the engine always splits fwd/bwd from update into two dispatches; at
ga=8 both accumulate across micro-batches and the engine's update cost amortizes
over 8 of them.
Two things not to over-read in the engine row. Its post-warmup steps are bimodal,
~1425 ms for 8 of 19 and ~2060 ms for the other 11, so the median lands on the
upper mode and the 1805.9 ms mean is the fairer summary; the alternation is
reproducible and unexplained. And the standalone host-graph probe swapped its two
largest entries between shapes -- split(model) 197.5 -> 33.6 ms, split(state)
33.2 -> 210.0 ms, same 310 leaves -- which confirms it measures cache warmth at
the moment it runs rather than steady-state per-call cost. It should not be quoted
as an attribution.
Formatted with pyink (indent 2, line length 122); pylint clean against pylintrc.
Three host-side changes to MaxTextTrainingEngine. Together they take qwen3-0.6b at tp=8 from 283.2ms to 92.3ms per step, against 71.1ms for tunix + PeftTrainer v2 and 82.0ms of TPU-busy time. No kernel changed; device time is untouched and the numerics are bit-identical. Carry the pure state across steps. fwd_bwd did nnx.split(model, ...) and update did nnx.split(state), every step, on a 1756-node graph -- 102ms and 25ms of pure Python per step that PeftTrainer v2 pays once. Both are now read from a cache seeded at compile time and republished from each kernel's output with nnx.split_state/merge_state, which walk 490 leaves instead of the graph and cost ~2ms. The cache self-disables with one warning on any structural surprise, so the fallback is the old behaviour rather than a wrong answer, and the two nnx.update calls stay exactly where they were: they are the publish barrier that keeps engine.model, save_checkpoint and prepare_weight_sync reading what the kernels produced. 283.2ms -> 140.7ms. Stop computing mean_loss when nothing reads it. _update_kernel consumes it only under skip_step_on_spikes, which is static at trace time and off by default, so WeightedMetric.compute()'s seven eager launches per step fed an argument XLA had already dropped. Defer the metric write past the next dispatch. wait_for_next reduced and pulled metrics to host inline, before the caller dispatched the step it had just made room for -- so the blocking np.asarray waited on the whole device backlog with nothing new running. Deferring to the following add_computation costs one dispatch of staleness in the log, which nothing can observe since buffers carry their own step id, and lets the transfer hide behind live work. 140.7ms -> 92.3ms. Also adds the first CPU test to cover the cached path: it needs a model with non-Param state, compile(), and update() together, which no existing test combined. Verified: 76 CPU tests pass; compare_training_engine.py passes all six parity verifications (eager_all and jit_all) on model_name=default. The suite's llama3.1-8b arms are unrunnable here for an unrelated reason -- rl.yml leaves config.ici_parallelism None -- which reproduces on the unmodified branch.
…prof The profiler charges per dispatch, so it does not tax the arms equally: an engine step issues dozens of tiny eager ops where a PeftTrainer step issues two. Timing the engine's host-path fixes under a trace therefore credits them with removing tracing overhead as well as real work -- traced, the same A/B reads 283.2 -> 92.3 ms/step against an untraced 160.1 -> 89.8. `maybe_trace` keeps the trace on by default and off under `--no-trace`, in one place so the three arms cannot drift.
Description
Onboards the HBM and throughput work from tunix#1934 into
src/maxtext/training_engine, closing the gap betweenMaxTextTrainingEngineandtunix/experimental/train/peft_trainer_v2.py.On llama3.1-8b,
ici_fsdp_parallelism=8,per_device_batch_size=1,max_target_length=1024, 4 gradient-accumulation micro-batches, 8x TPU7x:train.pySix changes to the compiled step, in order of impact. Three further changes to the host step path are in their own section below.
1. Trace the kernels under the mesh and the logical axis rules (
_sharding_ctx). The engine calledjax.jiton its fwd/bwd and update kernels outsidenn_partitioning.axis_rules, and those rules live in a context variable. A kernel traced without them sees an empty rule set, so everysharding.maybe_shard_with_logicalinside the MaxText layers silently becomes a no-op and XLA is left to guess how to partition activations and gradients. It guesses badly: the same fwd/bwd measured 1012 ms traced outside the context against 581 ms inside it, with no numerical difference.train.pyhas always wrapped its own jit this way, which is why the standalone trainer never hit this. The context is entered around the call, not aroundjax.jit(...), since jit is lazy and the rules must be live when tracing actually happens.2. Donate buffers.
_compiled_updatedonates the train state, matching whatmaxtext_utils.get_functional_train_with_signaturealready does for the standalone trainer (donate_argnums = 0), and the accumulating fwd/bwd kernel donates the gradient accumulator. Params are deliberately not donated:micro_gradshas the same shard-shape, dtype and sharding as the params, and JAX matches donations by shape rather than by position (jax/_src/interpreters/mlir.py:_set_up_aliases), so donating them would alias the weights straight into the gradient output. Gradients are not donated on the update either — every parameter-shaped output is already claimed by the incoming state, so the donation would have nothing to alias to and JAX would only warn.3. Accumulate gradients inside the jit, with two kernels. The first micro-batch of an update has nothing to add to, so it returns its own gradients and the engine adopts them; every later micro-batch folds into that buffer in place and donates it. Tunix v2 calls the same split "non-persistent vs persistent" mode. The Python-side
jax.tree.map(jnp.add, ...)it replaces materialized the micro-batch gradients as a program output and allocated a fresh sum — two extra parameter-sized buffers live at once.jax.jitis lazy, so the accumulating kernel costs nothing to compile when every update consumes a single micro-batch.4. Normalize as sum/sum rather than as a mean of per-micro-batch means. Gradients now accumulate unreduced (no
1/denominatorper micro-batch) andupdate()divides once by the summed denominator, so the optimizer seessum(grads)/sum(denom). The two forms agree only when every micro-batch carries the same token count; under sequence packing or a ragged RL rollout the mean of means silently overweights short micro-batches. This is what MaxText's own pre-train path already does (gradient_accumulation.py: accumulatexent_sum, divide once by the summedtotal_weights). It is also one fewer full pass over the gradient tree per micro-batch. The denominator is carried in checkpoint metadata, and rebuilt from the cached per-micro-batch losses for checkpoints written before it was tracked, so an intra-step save/restore round-trips.5. Hand the inflight throttler one scalar off the updated weights instead of the whole train state. The queue keeps its entries alive until popped, so the old behaviour pinned three parameter trees (params + two optimizer slots) per entry; and after (2) those very buffers are donated by a later step, so an entry popped after that would raise
Array has been deletedout ofjax.block_until_ready. The marker is produced by the same executable as the weight update and reads from its result, so its readiness still means the update landed. Tunix v2 tracks the update's gradient norm for the same reason (_last_update_grad_norm); MaxText only computes a norm when clipping or spike-skipping is on, hence a slice instead.6. Bound the metrics history to the last 128 steps (
MetricsRecorder)._metrics_buffergrew one buffer of live device arrays per train step and nothing on the engine's step path ever removed one:get_step_metricsreturns the newest by reference, and onlyget_metrics_history(clear_cache=True)andcleanup()clear. A driver that reads the engine's own TensorBoard output rather than callingget_metrics()never clears at all, and sincesave_checkpointserializes the whole retained history, checkpoint size and save latency grew linearly in steps too. Eviction is audible (one warning per window) rather than a silent drop. Tunix v2 keeps exactly one prior step (_prev_buffered_train_metrics); a window is kept here so batched readers ofget_metrics_historystill work.Residual gap at GA=4, and why it is not closed here
Per-micro-batch timing shows the first (318.7 ms) and accumulating (319-322 ms) kernels cost the same, so in-kernel accumulation costs about 1 ms, and at GA=1 the engine is within 4% of native. The remaining 1.29x at GA=4 is entirely native's
jax.lax.scanover micro-batches letting XLA pipeline FSDP collectives across micro-batch boundaries — native's per-micro-batch cost drops from ~305 ms standalone to ~245 ms inside the scan. Traces confirm it: the engine spends 247 ms/update in unfuseddynamic-update-sliceonf32[32,512,14336]/f32[32,14336,512](layout{2,0,1:T(8,128)}, each fed by a retiling%copy), where native has a singleconvert_dynamic-update-slice_fusionat 9.4 ms. Closing it needs a batched multi-micro-batch entry point, whichpeft_trainer_v2also does not have — it drives one micro-batch per call — so the engine now matches Tunix's design.The host step path: three further changes
The head-to-head below found the engine host-bound, not device-bound. Three changes address that; none of them touches a kernel.
Measured as a straight A/B on the two engine source files — this commit against its parent — on qwen3-0.6b,
ici_tensor_parallelism=8(all 8 devices), micro-batch 8, f32,optax.sgd(1e-5), no clipping,scan_layers=False, 23 steps, last 19 after warmup, 8x TPU7x. This is the third arm of the head-to-head harness, so the loss function and the arithmetic are literally Tunix's.--no-trace: see the note on why below.The saving is a fixed ~70 ms per optimizer step, not a percentage: 70.3 ms at seq 1024 and 70.4 ms at seq 4096, where the device work grew 4x. That is what "host-side" means — it shows up as 1.78x at 1024 tokens and 1.19x at 4096 for the same absolute win. It splits across the two dispatches as
fwd_bwd37.5 → 16.3 ms andupdate122.2 → 73.8 ms, summing to 69.6 of the 70.3.Turning the three on one at a time, same shape, so none of them is credited with another's win:
mean_loss(7) is the bulk of it and (8) is nearly free to skip but small — worth keeping as one fewer eager dispatch, not worth arguing about. Note (7) recovers 48.6 ms where the two
nnx.splitcalls cost 71.7 ms of wall time in isolation: the rest was already overlapping device work, so removing it buys nothing. Measured, not assumed — this is why the section quotes an A/B rather than a microbenchmark.The tail collapses too, and that is arguably the bigger result. The baseline's mean sits 30% above its median with a worst step of 464 ms against a 160 ms median; after the fixes mean and median agree to 0.2% and the worst step is 93.4 ms against 89.8. The jitter was the two
nnx.splitcalls: each allocates a large short-lived object graph, twice per step, and the GC pauses that follow land on whichever step is unlucky. Removing the allocation removes the pauses.Against the other two arms at the same shapes, untraced — the middle row is the control that isolates trainer from model, since it is the same MaxText model under Tunix's trainer:
PeftTrainerv2PeftTrainerv2Against
PeftTrainerdriving the identical model the engine goes from 1.92x slower to 1.08x at 1024 tokens, and from 1.21x to 1.019x at 4096. The residue is a near-constant 6.4–6.9 ms/step: the twonnx.updatepublish calls from (7), which are kept deliberately. The remaining distance to the Tunix model (14.3 ms at 1024, 36.7 ms at 4096) is a model difference, not a trainer one, and is what the rest of this section is about.A caution on the numbers this PR quoted earlier. The first version of this table was traced, and read 283.2 → 92.3 ms/step, i.e. 3.07x. That overstated the win. The profiler charges per dispatch and the engine dispatches dozens of tiny eager ops per step where
PeftTrainerdispatches two, so tracing taxes the baseline hardest and change (8) gets credited for removing tracing overhead as well as real work. The honest figure is the untraced 1.78x.--no-tracewas added to all three arms in this PR so the distinction is not re-litigated by hand next time.7. Carry the pure state across steps instead of re-splitting the module graph.
fwd_bwdcallednnx.split(model, nnx.Param, ...)andupdatecallednnx.split(state), once each per step. Those are full traversals of the NNX graph of an unrolled 28-layer qwen3-0.6b, and they are not cheap host work.peft_trainer_v2pays for its equivalent once, viannx.cached_partial, which is not reusable here: that cache is consulted only inside annnxtransform'sSplitContext.split(flax/nnx/graphlib.py:1824), and the engine's kernels are barejax.jit. (Tunix's ownfwd_bwdis not cached either — it is handed zerocached_args. Only its fusedtrain_stepbenefits, which is the real reason the two designs diverge here.)The engine now seeds a pure-pytree mirror of the model and of the train state at
compile()time and republishes it from each kernel's output withnnx.split_state/nnx.merge_state, which walk the 490-leafStaterather than the graph. Timed on the real state with the device idle:nnx.split(graph)nnx.split_state/merge_state(pure)Two orders of magnitude on the median. The median understates what a step pays, because the splits allocate a large short-lived object graph twice per step and the GC pauses that follow land on whichever step is unlucky — hence the sustained row, and hence the baseline's 464 ms worst step above. In the loop the cache recovers 48.6 of those 71.7 ms; the remainder was already overlapping device work. Three things make the cache safe rather than merely fast:
nnx.updatecalls stay. They cost ~12 ms/step combined and they are the publish barrier:engine.model,save_checkpointandprepare_weight_syncall read the live NNX objects, while the cache is a detached snapshot of kernel output from the firstupdate()onward. Dropping them would silently ship stale weights to an RL rollout, and no test on CPU or TPU would catch it.nnx.State.raw_mapping.Statestores its children as plain dicts but wraps them in aStateon__getitem__, so rebuilding from the wrapped views yields a tree that is equal key-for-key and leaf-for-leaf yet is a different pytree, one node deeper at every level — whichjax.jitrejects as anin_shardingsprefix mismatch naming neither the cause nor the site._check_pure_state_reusablecompares full treedefs for the same reason.modelentry, an update output that does not repartition into the same parameters, or afwd_bwdthat returns a wider non-parameter state than the model was split into. That last one is not hypothetical:record_max_logits,distill_beta > 0and multi-token prediction allsownnx.Intermediates that come back inrest, and adopting the wider tree would leave the cache disagreeing with therest_shardingsthe kernel was compiled against. Themodel/optimizer/statesetters andrestore_checkpointinvalidate it outright. The fallback is the old behaviour, not a wrong answer.8. Do not compute
mean_losswhen nothing reads it._update_kerneluses it only inside itsskip_step_on_spikesbranch, and that flag is read off the config at trace time and defaults off — so XLA had already dropped the argument, andupdate_in_shardingsalready declaresNonefor that position. Producing it was not free:WeightedMetric.compute()is seven eager XLA launches (theepsandmin_denomclamps plus a safe divide), i.e. seven dispatches per step feeding an input the executable does not contain.9. Defer the metric write past the next dispatch.
InflightThrottler.wait_for_nextblocked on the popped computation and then ranwrite_metricsinline, which reduces eachWeightedMetricon device and pulls the result to host withnp.asarray. Those reduction ops are dispatched behind whatever is already queued, so running them there — before the caller dispatches the step it had just made room for — stalls the host on the entire backlog with nothing new running. A cProfile of the step path put 54.6 ms/step in blockingjax/_src/array.py:_valueagainst an idle device; cProfile inflates that, and the clean A/B above credits this change with 18.4 ms/step. The write now happens at the top of the followingadd_computation, immediately after the caller dispatches, so the transfer hides behind live work. Metrics carry their own step id (MetricsBuffer.id), so nothing downstream can observe the one-dispatch delay, andwait_for_allflushes before returning so draining for a checkpoint or shutdown is unchanged. Tunix's throttler sidesteps this by not logging at all.Not included
train_step, which it builds only atgradient_accumulation_steps == 1). MaxText clips on a global norm, so the whole gradient tree is live at the optimizer step in both paths and fusing does not shorten its peak liveness. The structural cost is real — two dispatches also mean twowait_for_next()calls against one 2-deep queue, so the engine pipelines one optimizer step deep where Tunix pipelines two — but it is now bounded: after (7)-(9) the engine runs 6.4 ms/step behindPeftTrainerdriving the identical model at 1024 tokens and 6.9 ms behind it at 4096, so that is the size of the remaining prize. It is also unreachable through Tunix'sTrainerWorker, which exposes onlyfwd_bwdandupdate, andmaxtext_engine_test.py::test_update_with_inflight_throttlingpins the two-dispatch protocol entry by entry. A fused path belongs as a third method with that test rewritten deliberately, not folded into these two.metrics.pycontract for marginal gain.diff_wrapperdifferentiates onlyaux["xent_sum"], soz_loss,mtp_loss,indexer_lossandmoe_lb_lossare dropped from the gradient, whiletrain.pyadds all of them into the differentiated objective. For an MoE model the load-balancing loss currently contributes nothing through the engine path. Fixing it interacts with the sum/sum normalization above (those terms are already per-token-normalized), so it deserves its own PR rather than a silent change here.Tests
All run on a v7x-8 VM (4 Ironwood chips, 8 JAX devices).
End-to-end parity, all 6 verifications passed (~65 min), for changes (1)-(6):
Step 3 and step 6 are the ones that matter for change (4): they compare engine weights against the
lax.scanbaseline after each optimizer update, over 5 micro-batches withmask_prob=0.3, i.e. with per-micro-batch denominators that genuinely differ.Re-verified for changes (7)-(9), with one caveat. Steps 1-3 still pass. Steps 4-6 no longer start on this VM: the llama3.1-8b arm reaches
create_device_meshwithconfig.ici_parallelism is Noneand dies withAttributeError: 'NoneType' object has no attribute 'copy'(maxtext_utils.py:2202) before any engine code runs. That reproduces identically with all of (7)-(9) stashed, so it is unrelated to this PR — but it does mean the llama arms above have not been re-run. All six verifications were instead run againstmodel_name=default, which exercises the same code paths including the compiled and donatedupdate:Unit tests (the engine tests are
cpu_only, so they skip silently on a TPU host without the prefix):Test expectations that pinned the old per-micro-batch scaling were updated to the unreduced accumulation, and new tests cover the checkpointed denominator, the scalar throttler marker, and the bounded metrics history.
For (7),
maxtext_engine_test.pygainstest_compiled_steps_publish_weights_and_non_param_state— the first CPU test to combine a model with non-Paramstate (nnx.BatchStat, mutated by the loss function), a realcompile(), and two fullfwd_bwd+updaterounds. It asserts the cache survives both rounds, that the weights moved, and that the mutation reachedengine.model— the last of which fails with1.0 != 2.0when the publish in_publish_model_restis disabled, so the test is not vacuous.Benchmark reproduction. The llama numbers come from driving the engine directly (
engine.compile, thengaxfwd_bwd+ oneupdate, timed withblock_until_readyanddevice.memory_stats()), against the same config run throughtrain.pyfor the native column. The qwen3-0.6b tables in "The host step path" come from the threetests/end_to_end/tpu/perf_parity/qwen3_*_profile.pyarms, each run with--tp 8 --no-traceand the shape flags in the table headers; the engine arm's_report_nnx_graph_costadditionally prints the per-step graph cost split into what a step still pays and what the cache now saves. The (1)-(6) rows are the same arms run against this commit's parent with onlysrc/maxtext/training_engine/{maxtext_engine,inflight_throttler}.pyreverted, so nothing but the engine differs.pyinkandpylint(10.00/10) are clean on all changed files.Head-to-head against Tunix
peft_trainer_v2Added
tests/end_to_end/tpu/compare_tunix_trainer.pyand wrote the results up indocs/reference/training_engine_tunix_parity.md.It drives
MaxTextTrainingEngineandpeft_trainer_v2.PeftTrainerover the same model, the samealgo_core.grpo_loss_fn, the same micro-batches and the same optax transformation, plus an independently computed sum-of-grads / sum-of-denoms reference gradient.Qwen3-0.6B from
gs://maxtext-model-checkpoints/qwen3-0.6b/2025-10-27/scanned/0/items,fsdp=8, batch 8, f32,gradient_clipping_threshold=0.0(MaxText clips in its update kernel and Tunix never clips; leaving it on would mask the normalization difference being measured).Numerics. At GA=1 the two are equivalent — identical loss
-0.26746895909309387, gradients withinrel_l22.98e-4, weight deltas agreeing to nine digits. With GA=4 and ragged micro-batches (denominators 64/16/40/8) they diverge:rel_l2vs. exact referenceMaxText matches the exact gradient to the same ~6e-3 jit-vs-eager noise floor it hits at GA=1; Tunix lands on mean-of-means, per its own
# TODO(b/491970038): update denom for sequence packing.in_fwd_bwd_step. Trainer-vs-trainer isrel_l20.676. Worth noting the weight deltas still agree to four digits, because Adam normalizes per-element magnitude — the error is in gradient direction only, so it will not surface as a step-size anomaly. This is change (4) in this PR, measured against the external implementation.Performance and HBM. The first version of this section quoted host wall clock at 16 tokens per example, from a loop that opened its xplane trace before any warmup. Both were wrong to lean on: a ~570 ms
CompileAndLoad(jit__update_kernel)landed inside every MaxText trace, and 16 tokens puts the comparison nowhere near roofline. Re-measured with_WARMUP=2untimed updates outside the trace context, one trainer per process, and at two shapes. Wall clock is from untraced runs; TPU-busy is total/device:TPU:0XLA-module time from the traces. All of this section predates changes (7)-(9), which move the MaxText column only.On step time alone the two trainers are equivalent at production sequence lengths. MaxText's lead is a short-sequence effect that amortizes away:
fwd_bwdgoes 2.75x → 1.46x → 1.34x → 1.11x → 1.04x across the sweep, and at 8192 tokens the totals are within run-to-run noise. Do not quote the small-shape numbers as a general result.The mechanism only holds at small shapes. Tunix's
nnx.jitcarries noout_shardings, so its grads returnP()at 2.22 GiB/device against MaxText's sharded 0.278 GiB (params and optimizer slots shard identically), and the resulting extra memory traffic is roughly constant: +14.7 GiB at 16 tokens, +13.0 at 2048 — but −0.7 at 4096 and −12.4 at 8192, where MaxText accesses more and is still marginally ahead. By then both are compute-bound on identical arithmetic (1080.7 vs 1082.4 GFLOP at 1024 tokens), so the times converge.Peak HBM is the durable difference. It is flat across the whole sweep — a 512x sequence increase moves neither trainer — so unlike step time it does not amortize. The persistent accumulator from tunix#1934 improves it but does not close it: at GA=2 Tunix's accumulator is sharded and peak drops 8.65 → 4.79 GiB, while bytes-accessed is unchanged at 22.67 GiB and
fwd_bwdstays at 71.4 ms. Nothing further to port — the explicitin_shardings/out_shardingsthis PR keeps on_fwd_bwd_kernelare what v2 lacks.updateis the one phase Tunix wins, by ~6 ms at both shapes. MaxText's 14.0 ms is 0.71 ms on device, ~2.8 ms inMetricsRecorder._record_metric(measured by no-op'ing it), ~1.7 ms innnx.split, and ~8.8 ms of other host work. Tunix is 8.2 ms wall clock against 0.86 ms on device, so both are host-bound; MaxText is just more so. This paragraph is what changes (7)-(9) target — thennx.splitline is now cached away, and the "other host work" turned out to be dominated by the metric fetch in (9). The scan-free qwen3-0.6b arm quantifies it end to end in the table above.xprof's step statistics are not usable for MaxText, which is worth knowing before comparing these traces to any others. Over 8 steps MaxText executes 424 XLA modules where Tunix executes 16:
_record_metriccallsjnp.atleast_1d/jnp.appendonce per metric per micro-step, ~51 tiny eager dispatches per step. Step detection splits on module boundaries, so MaxText's reported step time is computed over 424 fake steps with a 0.012 ms median, and the device row renders as a picket fence next to Tunix's two clean bars. Read total TPU-busy time instead. The same dispatches make MaxText pay disproportionate per-dispatch tracing overhead — traced, itsupdatereads 27.0 ms against an untraced 14.0 ms, while Tunix goes 8.2 → 18.5 ms. Change (8) removes 7 of the ~51 per step; the picket fence remains, and shrinking it properly means batching_record_metric, which is ametrics.pychange rather than an engine one.GRPO integration test:
tests/post_training/integration/maxtext_engine_grpo_loss_test.py— 1 passed in 50.32 s on the real checkpoint. Note it never callsengine.compile(), so it measures the eager path (237 ms/update vs 14 ms).xprof traces, steady-state loop only, no compilation inside the window, with
StepTraceAnnotationboundaries andfwd_bwd/updateregions:Each holds
plugins/profile/<ts>/t1v-n-c9d27794-w-0.xplane.pb.One bug found while measuring, not fixed here. MaxText logs a different loss than it optimizes:
MetricsRecorder._record_metricappends one entry per micro-step, noaggregation_fnis registered for"loss", soMetricsLogger._process_metricsreduces it withnp.mean— mean-of-means, the normalization change (4) deliberately avoids. On the ragged GA=4 batch the logged loss is0.08643750101327896while the gradient uses-0.03790009766817093; the sign disagrees. Gradients are unaffected. Left for a separate change since it is reporting-only and predates this PR.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.