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] |
| restored_additional_metadata = restored_metadata.get("additional_metadata", None) | ||
|
|
||
| # 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)
671cec4 to
3f3f902
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
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
|
5702b09 to
d6739d0
Compare
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.
The engine half of this change -- the pure-state cache and the deferred metrics write -- is folded into "Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine" so the branch carries the engine as one reviewable commit. What is left here is the profiling arm's accounting of what the cache saves per step.
…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.
The arms were pinned to qwen3-0.6b unscanned, which is the shape the fix flatters most. `--model` and `--scan` move the two MaxText-side arms off it; the tunix arm refuses both, since it implements one architecture and has no scanned variant to ask for. Measured on the second model the fix is worth 1.004x, against 1.80x on the first -- same absolute ~70 ms of host time, but qwen3.5-35b-a3b runs a 2.3 s device step and, scanned, has 70 parameter leaves to qwen3-0.6b's 310. Its median barely moves; its worst step still drops 2641.8 -> 2314.6 ms, because what the cache removes is the allocation the GC pauses were following. Section 9 of the parity doc records both, with the traces.
The §9 tables were per-step medians. Total run time follows the mean, so it picks up the GC tail the median hides: the 23-step loop goes 8.9 -> 6.3s on qwen3-0.6b (1.41x, against 1.80x on the median) and 56.3 -> 55.7s on qwen3.5-35b-a3b. Also records the fixed per-run overheads, which explain why the larger model builds in less than half the time of the smaller one -- scanned, it constructs and compiles one decoder layer rather than 28.
The GA=1 rows in section 9 measure the fix at its weakest. The removed nnx.split(model, nnx.Param, ...) sits in fwd_bwd, which runs once per micro step, so the saving scales with the accumulation count while the update-side part does not. At GA=8 on qwen3-0.6b the engine goes 3421.0 -> 593.6 ms/step, 5.76x against the 1.80x the GA=1 row reports. fwd_bwd carries it: 355.8 -> 60.6 ms paid eight times is 2362 of the 2827 ms saved, while update stays flat at ~80 ms. Same fixed quantity of host time as before, just charged per micro-batch -- 28 unscanned decoder layers walked eight times a step instead of once. It also moves the trainer comparison that section 4 found to be a wash. PeftTrainer walks the graph per micro step too, so at GA=8 the engine is 1.98x against the identical model and 1.52x against the tunix-model baseline. The PeftTrainer + MaxText-model arm is bimodal at this shape, alternating ~700 and ~1180 ms steps, so it is recorded as a range. Measured on a 4-device v6e host rather than the 8-device v7x the GA=1 table used, so the new rows are noted as internally comparable only.
…baseline The GA=8 rows landed as a wall-clock A/B with no trace to check them against, and the baseline was wrong: `before` was `59f49ac90^`, which reverts both of this PR's engine commits, while §9's A/B is the host-path fix alone. Profiling all three points exposed it — device time differs by 4x across `59f49ac90`, so that 5.76x was never a host-only number. Re-measured against `98e6886e8^`: 1233.0 -> 593.6 ms/step, 2.08x, at 570.6 vs 570.7 ms of TPU-busy per step and the same `jit_accum_kernel` module hash. That is the stronger claim, not the weaker one — the saving is provably host time, where 5.76x was two effects added together. The whole-PR figure is kept, in its own table, attributed to `59f49ac90` folding ~2340 eager `jit_add` launches per step into one kernel per micro-batch. Every table that rests on a trace now names it inline: §4's step-time sweep, §4b's module counts, §9's two-model A/B, and the three GA=8 tables, which also gain TPU-busy and utilization columns. The GA=8 traces are new — the runs had used --no-trace — and show the MaxText model losing 277 ms/step to `PeftTrainer` while being the faster of the two on device, at 52% utilization against 96%.
The code half -- computing the norm on every update rather than only under `skip_step_on_spikes`, and handing it to the throttler -- is folded into "Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine".
The GA=8 rows were re-run on top of the norm, but §4 and §9's GA=1 tables were not, so they were quietly reporting an engine that skipped a reduction Tunix always paid for. Say so where each table sits, and measure what it is actually worth. GA=1 is where the norm is most exposed -- one update per step rather than one per eight micro-batches -- so the A/B runs there, two runs per arm, `maxtext_engine.py` at 5b9337d72^ for the "without" side. qwen3-0.6b, batch 8 x seq 1024, fsdp=4 on 4 x v6e: with norm 81.8 / 82.1 ms per step, update 65.9 / 66.2 ms without 81.3 / 81.5 ms per step, update 65.6 / 65.9 ms ~0.6 ms per update: 0.7% of a GA=1 step, 0.1% of a GA=8 one, and below the trace noise floor on device (568.0 vs 570.7 ms TPU-busy per optimizer step at GA=8). XLA fuses the reduction into an update kernel that already streams every gradient leaf. §4's `update` row therefore reads ~14.6 ms against Tunix's 8.2 rather than 14.0 -- a slightly worse number for MaxText, and the first version of that row to compare equal work. §9's GA=1 A/B is unaffected either way, since the host-path fix and the norm touch opposite sides of the dispatch.
Tracing under `nn_partitioning.axis_rules` is what makes MaxText's logical constraints real, which is where the device-side speedup comes from -- and it is also the one behaviour change in this PR: a micro-batch that data x fsdp cannot split is padded by XLA into all-zero sequences that mask themselves out of attention, and come back as NaN on the pad token's embedding row under a finite loss. Say so in the summary, and give it a section next to the measurement that credits the axis rules. Also record why the GRPO test now derives its batch from the mesh and pins matmul_precision.
…n main Main took the norm before clipping; keep that placement rather than this branch's post-clipping one. It is where Tunix's own `optax.global_norm` sits -- its clipping, if a caller configures any, is a later link in the optax chain -- so it is the better match for the parity claim, at the cost of being `train.py`'s `raw_grad_norm` rather than its `learning/grad_norm`. Every measurement in this document runs unclipped, so no number changes.
…y vary Extends the perf_parity harness to a second model. No engine changes: nothing under src/ is touched, and the 35b runs used the existing arms unmodified via `--model qwen3.5-35b-a3b --tp 2 --scan`. Three of the four arms were already model-agnostic but carried a `qwen3_` prefix that implied otherwise, so they are renamed for the axis they vary: qwen3_common.py -> perf_parity_common.py qwen3_engine_profile.py -> engine_profile.py qwen3_maxtext_profile.py -> peft_trainer_profile.py qwen3_tunix_profile.py -> qwen3_0p6b_tunix_profile.py Only the last is genuinely tied to a model -- it hardcodes `ModelConfig.qwen3_0p6b()` and rejects `--model`/`--scan`, because tunix implements one architecture -- so it is the one that keeps a model in its name. The profile-directory tag is deliberately left alone: renaming it would orphan the trace paths already cited in the parity doc. `run_qwen3_0p6b.sh` and `run_qwen3_5_35b_a3b.sh` pin each model's shape, which is where the model belongs now that the arms take it as a flag. The 35b runner documents why its shape is forced: 2 KV heads make `--tp 8` illegal, the unscanned decoder OOMs, and PeftTrainer cannot accumulate on this model at all. `xplane_device_summary.py` and `xplane_host_summary.py` read TPU-busy, launch counts and host events straight off the `.xplane.pb` wire format. Neither xprof nor the generated profiler protos import in this venv. They are what the device-side table in the results doc is derived from, so without them that section is unreproducible. RESULTS-qwen35-35b-20260902.md records the run: the two trainers are at parity at GA=1 (2314.3 vs 2303.0 ms/step, both ~99.7% utilization), PeftTrainer cannot run any GA>1 here, and at tp=1 -- the only shape where the pre-PR engine runs at all -- this PR is worth 1.79x device-side. Also fixes a latent collision: `run_name` was hardcoded to `perf_parity_qwen3_0p6b` in both model-agnostic arms, so a 35b run wrote into the 0.6b output directory. It now derives from `--model`.
Three things, all in the perf_parity rig. RESULTS-qwen35-35b-ep-20260902.md gains a section 8 on the engine side of the comparison. The short version is that throughput is not where the engine is weak -- its forward/backward is within 0.1% of PeftTrainer's and it is ahead at GA=2 -- but `jit_accum_kernel` wants 22.40 G more HLO temp than `jit_first_kernel` for one elementwise add into a donated buffer. Donation, the sharding annotations, the MoE flags and `scan_layers` are each ruled out by measurement; compiled as its own executable the same add needs 0.00 G, so it is the fusion into the backward pass that allocates. Two plausible fixes are ruled out the same way: a fused GA=1 step wants 52.97 G against 27.01, and donating the gradient tree into the update kernel changes nothing at all. `maybe_trace` now builds base.yml's `enable_tpu_profiling_options` advanced configuration, because the unrestricted capture silently drops events under `--ragged-sort` -- a `jit_accum_kernel` execution read 225.99 ms against a real 724 ms, with no warning. `--no-tpu-profiling-options` restores the old capture. `engine_profile.py` no longer loses a run's summary to a StatisticsError when `--steps` leaves one of the two medians without samples.
7561ddf to
40d0da4
Compare
…ro-1
Zero-1 (`shard_optimizer_over_data`) could not be measured with what this rig had.
It shards parameter-shaped optimizer state over the `data` axis, so it is vacuous
under the `sgd` every arm hard-coded, it is refused unless `shard_mode` is
`explicit`, and it is mutually exclusive with the `--fsdp 8` that was the only way
to fill the mesh. Four new flags cover the gap: `--dp`, `--opt {sgd,adamw}`,
`--explicit` and `--zero1` (which implies `--explicit`). `RunSpec` rejects the three
combinations the engine would reject anyway -- Zero-1 with FSDP, with `data` of 1,
and with sgd -- rather than letting them run and quietly measure the baseline.
The Adam constants come from base.yml (b2=0.95, weight_decay=0.1), not from optax's
defaults (0.999, 1e-4), and `optimizer_overrides`/`optax_optimizer` build the two
trainers' optimizers from the same numbers so the arms stay comparable.
Two things that look incidental and are not:
* the engine arm now builds its mesh with `maxtext_utils.get_mesh_from_config`.
A bare `jax.sharding.Mesh(create_device_mesh(...), axes)` leaves every axis
`Auto`, and `_zero1_active` requires all of them `Explicit` -- so the old mesh
silently disabled the feature under test.
* every engine arm prints a `zero1:` line saying ACTIVE, DECLINED with a reason,
or UNSUPPORTED on a build without the engine-side support. A declined run is
the baseline wearing the feature's name, which is invisible in a step time.
`report_peak_hbm` reads `peak_bytes_in_use` off the allocator for every device in
the mesh. Unlike `Compiled.memory_analysis()` it means the same thing on both
trainers, and reporting all 8 devices is what would expose a lopsided Zero-1 shard.
run_qwen3_0p6b_zero1.sh drives the five arms. It traces the explicit-without-Zero-1
control as well: `shard_mode` is not only a layout choice here -- Explicit axes are
what let the cotangents come out `unreduced` -- so the control compiles different
kernels from the baseline, and without it there is no way to attribute the Zero-1
arm's time.
Five arms at GA=1 and GA=8, on 8 Ironwood devices, both trainers driving the same MaxText model. The engine arms differ in one thing each -- auto, explicit, explicit plus Zero-1 -- so the mesh mode and the feature come apart; `PeftTrainer v2` has no Zero-1, so its arms shard the parameters instead. The headline is not the feature. Switching `shard_mode` to `explicit` and changing nothing else takes GA=8 from 371.7 ms to 247.4 ms, a 1.50x speedup: under Explicit axes the data-parallel all-reduce moves out of every micro-batch and into `update()`. The same ~20 ms shows up three times in the GA=8 trace -- the first kernel drops 20.08 ms, each of seven accumulates drops 18.54, and the update rises 21.86 -- which is one all-reduce of the 2.4 G gradient tree, moved rather than removed. It is a 1.78 ms loss at GA=1 and pays from GA=2 on. Zero-1 on top of that control costs 3.2 ms (1.3%) at GA=8 and returns 4.04 G of 12.74 G. Its cost is provably confined to the optimizer step: `jit_first_kernel` and `jit_accum_kernel` carry identical HLO program hashes in the control and Zero-1 traces, and only `jit__update_kernel` differs, by +2.90 ms at GA=8 and +2.85 at GA=1. What the comparison does not support is preferring Zero-1 to FSDP at this size. FSDP reports 2.04 G against Zero-1's 8.70 G, because it shards the parameters and gradients too, and at 0.6 B those are most of what is left; it is also 1.4x faster at GA=1. Zero-1's advantage is that it adds no collectives to forward and backward, not that it saves more bytes. The engine forbids combining them. Three measurement notes worth keeping, all of which would have produced wrong numbers taken naively: per-kernel times are comparable only within a GA setting (identical program hashes drift 1.8-4.6 ms between GA=1 and GA=8); traced wall clock reverses the GA=8 ranking outright, so wall clock comes from `--no-trace` runs; and tracing lowers the engine's peak HBM by ~2.4 G while leaving PeftTrainer's unchanged, which is a dispatch-depth artifact and not a property of the arms. Traces: gs://chengnuojin-xprof/zero1-qwen3-0p6b-20260903/
…king The Zero-1 sweep had an explicit control on the engine but not on the other trainer, so it could not tell an engine capability from a property of Explicit axes. It is the former: `--explicit` makes the engine 1.50x faster at GA=8 and PeftTrainer 1.31x slower (356.8 -> 465.7 ms), its per-micro-batch step going 42.73 -> 56.04 ms with the update unchanged. Nothing moves out of the micro-batch there; the collectives just stop being rearranged. Same mesh, same optimizer, same shard_mode, GA=8: 247.4 against 465.7, 1.88x.
Three results files, two models, four trace prefixes and no single place that says what the comparison concluded. This adds that place: the six conclusions, the trainer-vs-trainer tables for qwen3.5-35b-a3b and qwen3-0.6b, a decision table, and all 24 xplane paths in one index. Every repro command moves into an appendix, starting from `git fetch origin pull/5060/head` so anyone with an 8-device TPU VM can rerun the arms without reading the detail reports first.
`tests/end_to_end/tpu/compare_tunix_trainer.py` drives MaxTextTrainingEngine and tunix's peft_trainer_v2.PeftTrainer over the same micro-batches and diffs their gradient digests. It is not on main: PR #5060's engine half landed via #5088, but the harness and its parity doc did not, and chengnuojin-trainer-fix is still open. The bulk of this file is chengnuojin's, from commits 150ff614e and 3f3f90229 on that branch. Squashed into one commit here rather than replaying the thirteen commits the old merge brought in, ten of which are already in main by content under different SHAs. Drop this commit if #5060 lands upstream. My part is the packed case: --packed, --segments-per-row and --loss-agg-mode, plus _packed_train_example, which draws the same tokens as _train_example for a given seed and folds them into one row with segment ids and restarting positions. Two findings from verifying it on CPU are recorded in the module docstring. The loss is blind to the packing defect -- against the pre-M1 adapter, packed and unpacked agreed on loss to seven decimals while their gradients were 70% apart in relative L2, because ref_per_token_logps comes from the same forward pass the loss scores, so contamination cancels and survives only in the derivative. Compare gradients, not loss. And both arms wrap with TunixMaxTextAdapter, so before M1 they shared the defect and would have agreed while both were wrong.
Sequence packing landed on main in ed64d6c (#5100) with no test proving it correct. This is that evidence, plus coverage for the three adapter and engine fixes in the previous commit. `tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle: * The adapter contract packing depends on. Tunix gates its whole segment path on an exact-name lookup for a `segment_ids` parameter on the model's `__call__`, with a `**kwargs` escape hatch. TunixMaxTextAdapter offered `decoder_segment_ids` and neither, so the gate returned False, `compute_per_token_logps` took its fallback branch, and passed `attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back to causal-only masking over the whole packed row and every sequence attended to the ones before it, at a finite loss and normal throughput. * Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack a prior investigation had three sampled tensors match exactly while the aggregate over all 310 showed under one percent delivered, so sampling did not merely miss the defect, it pointed the work the wrong way for three runs. * A hand-computed absolute loss, ported from `tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed against unpacked, and that whole family stays green when both sides are wrong the same way -- aggregating by row rather than by segment is precisely such a fault, since by-row is what the *unpacked* layout should do. * The accumulation identity over four partitions of 8-32 examples (ports `peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is invisible at K=2 in a way it is not at K=8. Derived here rather than hand-supplied as in tunix, by counting live segments in a packed row -- that derivation is what packing put at risk. * The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`). Nothing else in the file calls `update()`, so the apply half of the engine was unexercised on packed input. `test_packing_config_keeps_cond_path` is deliberately not ported: the engine has no `is_update_step` flag and no `lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python calls and the cadence belongs to the caller. * The compiled branch of `fwd_bwd`. Everything above runs eager, while the orchestrator's worker lifecycle calls `compile()` unconditionally on every worker -- so packing had been proven only on a branch a real run does not take. Segment lengths within a packed row are unequal throughout. With equal lengths `sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture executes `_aggregate_loss_segmented` on every step without ever distinguishing it from the code it replaces, and a packed run that reduced per row would pass. `tests/end_to_end/tpu/compare_tunix_trainer.py` (new) drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests. The bulk of that file is chengnuojin's, from commits 150ff614e and 3f3f90229 on `chengnuojin-trainer-fix` (PR #5060, still open). PR #5060's engine half landed via #5088 but the harness did not. It is squashed in here rather than replaying that branch's thirteen commits, ten of which are already in main by content under different SHAs. If #5060 lands upstream, those parts should come out -- note that they are no longer separable as a single commit to drop. The packed half is mine: `--packed`, `--segments-per-row`, `--loss-agg-mode` and `_packed_train_example`, plus `--compare-layouts`. `--packed` hands both trainers the same packed examples, so a defect they share is invisible to it -- and they share TunixMaxTextAdapter. `--compare-layouts` takes the orthogonal axis: one trainer, byte-identical data laid out packed and unpacked, diffed at the step-0 gradient. It is the port of tunix's `grpo_learner_test.py::test_sequence_packing`, keeping its four budgets as multiples of one sequence rather than absolute token counts. The step-0 gradient is the verdict metric, not the final weights: Adam's first step is essentially `lr * sign(g)`, so any near-zero component that flips sign moves that weight by a full `2 * lr` regardless of how small the difference was. The other three files extend existing suites for the same reasons.
Sequence packing landed on main in ed64d6c (#5100) with no test proving it correct. This is that evidence, plus coverage for the three adapter and engine fixes in the previous commit. `tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle: * The adapter contract packing depends on. Tunix gates its whole segment path on an exact-name lookup for a `segment_ids` parameter on the model's `__call__`, with a `**kwargs` escape hatch. TunixMaxTextAdapter offered `decoder_segment_ids` and neither, so the gate returned False, `compute_per_token_logps` took its fallback branch, and passed `attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back to causal-only masking over the whole packed row and every sequence attended to the ones before it, at a finite loss and normal throughput. * Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack a prior investigation had three sampled tensors match exactly while the aggregate over all 310 showed under one percent delivered, so sampling did not merely miss the defect, it pointed the work the wrong way for three runs. * A hand-computed absolute loss, ported from `tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed against unpacked, and that whole family stays green when both sides are wrong the same way -- aggregating by row rather than by segment is precisely such a fault, since by-row is what the *unpacked* layout should do. * The accumulation identity over four partitions of 8-32 examples (ports `peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is invisible at K=2 in a way it is not at K=8. Derived here rather than hand-supplied as in tunix, by counting live segments in a packed row -- that derivation is what packing put at risk. * The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`). Nothing else in the file calls `update()`, so the apply half of the engine was unexercised on packed input. `test_packing_config_keeps_cond_path` is deliberately not ported: the engine has no `is_update_step` flag and no `lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python calls and the cadence belongs to the caller. * The compiled branch of `fwd_bwd`. Everything above runs eager, while the orchestrator's worker lifecycle calls `compile()` unconditionally on every worker -- so packing had been proven only on a branch a real run does not take. Segment lengths within a packed row are unequal throughout. With equal lengths `sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture executes `_aggregate_loss_segmented` on every step without ever distinguishing it from the code it replaces, and a packed run that reduced per row would pass. The other three files extend existing suites for the same reasons. A TPU harness that drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests is held on `packing-compare-harness`, stacked on this branch. It is kept out of this change because it needs a TPU, has no automated caller, and carries unlanded code from PR #5060.
…tTrainer `tests/end_to_end/tpu/compare_tunix_trainer.py` drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests. It is held on this branch rather than in the packing PR: it needs a TPU, no CI job or shell script invokes it, and it carries code from an unlanded PR. The bulk of the file is chengnuojin's, from commits 150ff614e and 3f3f90229 on `chengnuojin-trainer-fix` (PR #5060, now superseded by #5099 and #5104). PR #5060's engine half landed via #5088 but the harness did not. It is squashed in here rather than replaying that branch's thirteen commits, ten of which are already in main by content under different SHAs. The packed half is mine: `--packed`, `--segments-per-row`, `--loss-agg-mode` and `_packed_train_example`, plus `--compare-layouts`. `--packed` hands both trainers the same packed examples, so a defect they share is invisible to it -- and they share TunixMaxTextAdapter. `--compare-layouts` takes the orthogonal axis: one trainer, byte-identical data laid out packed and unpacked, diffed at the step-0 gradient. It is the port of tunix's `grpo_learner_test.py::test_sequence_packing`, keeping its four budgets as multiples of one sequence rather than absolute token counts. The step-0 gradient is the verdict metric, not the final weights: Adam's first step is essentially `lr * sign(g)`, so any near-zero component that flips sign moves that weight by a full `2 * lr` regardless of how small the difference was.
Sequence packing landed on main in ed64d6c (#5100) with no test proving it correct. This is that evidence, plus coverage for the three adapter and engine fixes in the previous commit. `tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle: * The adapter contract packing depends on. Tunix gates its whole segment path on an exact-name lookup for a `segment_ids` parameter on the model's `__call__`, with a `**kwargs` exemption. TunixMaxTextAdapter offered `decoder_segment_ids` and neither, so the gate returned False, `compute_per_token_logps` took its fallback branch, and passed `attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back to causal-only masking over the whole packed row and every sequence attended to the ones before it, at a finite loss and normal throughput. * Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack a prior investigation had three sampled tensors match exactly while the aggregate over all 310 showed under one percent delivered, so sampling did not merely miss the defect, it misdirected the investigation for three runs. * A hand-computed absolute loss, ported from `tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed against unpacked, and that whole family passes when both sides are wrong the same way -- aggregating by row rather than by segment is precisely such a fault, since by-row is what the *unpacked* layout should do. * The accumulation identity over four partitions of 8-32 examples (ports `peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is invisible at K=2 in a way it is not at K=8. Derived here rather than hand-supplied as in tunix, by counting live segments in a packed row -- that derivation is what packing put at risk. * The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`). Nothing else in the file calls `update()`, so the apply half of the engine was unexercised on packed input. `test_packing_config_keeps_cond_path` is deliberately not ported: the engine has no `is_update_step` flag and no `lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python calls and the cadence belongs to the caller. * The compiled branch of `fwd_bwd`. Everything above runs eager, while the orchestrator's worker lifecycle calls `compile()` unconditionally on every worker -- so packing had been proven only on a branch a real run does not take. Segment lengths within a packed row are unequal throughout. With equal lengths `sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture executes `_aggregate_loss_segmented` on every step without ever distinguishing it from the code it replaces, and a packed run that reduced per row would pass. The other three files extend existing suites for the same reasons. A TPU harness that drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests is held on `packing-compare-harness`, stacked on this branch. It is kept out of this change because it needs a TPU, has no automated caller, and carries unlanded code from PR #5060.
…tTrainer `tests/end_to_end/tpu/compare_tunix_trainer.py` drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests. It is held on this branch rather than in the packing PR: it needs a TPU, no CI job or shell script invokes it, and it carries code from an unlanded PR. The bulk of the file is chengnuojin's, from commits 150ff614e and 3f3f90229 on `chengnuojin-trainer-fix` (PR #5060, now superseded by #5099 and #5104). PR #5060's engine half landed via #5088 but the harness did not. It is squashed in here rather than replaying that branch's thirteen commits, ten of which are already in main by content under different SHAs. The packed half is mine: `--packed`, `--segments-per-row`, `--loss-agg-mode` and `_packed_train_example`, plus `--compare-layouts`. `--packed` hands both trainers the same packed examples, so a defect they share is invisible to it -- and they share TunixMaxTextAdapter. `--compare-layouts` takes the orthogonal axis: one trainer, byte-identical data laid out packed and unpacked, diffed at the step-0 gradient. It is the port of tunix's `grpo_learner_test.py::test_sequence_packing`, keeping its four budgets as multiples of one sequence rather than absolute token counts. The step-0 gradient is the verdict metric, not the final weights: Adam's first step is essentially `lr * sign(g)`, so any near-zero component that flips sign moves that weight by a full `2 * lr` regardless of how small the difference was.
Sequence packing landed on main in ed64d6c (#5100) with no test proving it correct. This is that evidence, plus coverage for the three adapter and engine fixes in the previous commit. `tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle: * The adapter contract packing depends on. Tunix gates its whole segment path on an exact-name lookup for a `segment_ids` parameter on the model's `__call__`, with a `**kwargs` exemption. TunixMaxTextAdapter offered `decoder_segment_ids` and neither, so the gate returned False, `compute_per_token_logps` took its fallback branch, and passed `attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back to causal-only masking over the whole packed row and every sequence attended to the ones before it, at a finite loss and normal throughput. * Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack a prior investigation had three sampled tensors match exactly while the aggregate over all 310 showed under one percent delivered, so sampling did not merely miss the defect, it misdirected the investigation for three runs. * A hand-computed absolute loss, ported from `tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed against unpacked, and that whole family passes when both sides are wrong the same way -- aggregating by row rather than by segment is precisely such a fault, since by-row is what the *unpacked* layout should do. * The accumulation identity over four partitions of 8-32 examples (ports `peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is invisible at K=2 in a way it is not at K=8. Derived here rather than hand-supplied as in tunix, by counting live segments in a packed row -- that derivation is what packing put at risk. * The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`). Nothing else in the file calls `update()`, so the apply half of the engine was unexercised on packed input. `test_packing_config_keeps_cond_path` is deliberately not ported: the engine has no `is_update_step` flag and no `lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python calls and the cadence belongs to the caller. * The compiled branch of `fwd_bwd`. Everything above runs eager, while the orchestrator's worker lifecycle calls `compile()` unconditionally on every worker -- so packing had been proven only on a branch a real run does not take. Segment lengths within a packed row are unequal throughout. With equal lengths `sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture executes `_aggregate_loss_segmented` on every step without ever distinguishing it from the code it replaces, and a packed run that reduced per row would pass. The other three files extend existing suites for the same reasons. A TPU harness that drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests is held on `packing-compare-harness`, stacked on this branch. It is kept out of this change because it needs a TPU, has no automated caller, and carries unlanded code from PR #5060.
Sequence packing landed on main in ed64d6c (#5100) with no test proving it correct. This is that evidence, plus coverage for the three adapter and engine fixes in the previous commit. `tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle: * The adapter contract packing depends on. Tunix gates its whole segment path on an exact-name lookup for a `segment_ids` parameter on the model's `__call__`, with a `**kwargs` exemption. TunixMaxTextAdapter offered `decoder_segment_ids` and neither, so the gate returned False, `compute_per_token_logps` took its fallback branch, and passed `attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back to causal-only masking over the whole packed row and every sequence attended to the ones before it, at a finite loss and normal throughput. * Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack a prior investigation had three sampled tensors match exactly while the aggregate over all 310 showed under one percent delivered, so sampling did not merely miss the defect, it misdirected the investigation for three runs. * A hand-computed absolute loss, ported from `tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed against unpacked, and that whole family passes when both sides are wrong the same way -- aggregating by row rather than by segment is precisely such a fault, since by-row is what the *unpacked* layout should do. * The accumulation identity over four partitions of 8-32 examples (ports `peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is invisible at K=2 in a way it is not at K=8. Derived here rather than hand-supplied as in tunix, by counting live segments in a packed row -- that derivation is what packing put at risk. * The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`). Nothing else in the file calls `update()`, so the apply half of the engine was unexercised on packed input. `test_packing_config_keeps_cond_path` is deliberately not ported: the engine has no `is_update_step` flag and no `lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python calls and the cadence belongs to the caller. * The compiled branch of `fwd_bwd`. Everything above runs eager, while the orchestrator's worker lifecycle calls `compile()` unconditionally on every worker -- so packing had been proven only on a branch a real run does not take. Segment lengths within a packed row are unequal throughout. With equal lengths `sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture executes `_aggregate_loss_segmented` on every step without ever distinguishing it from the code it replaces, and a packed run that reduced per row would pass. The other three files extend existing suites for the same reasons. A TPU harness that drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests is held on `packing-compare-harness`, stacked on this branch. It is kept out of this change because it needs a TPU, has no automated caller, and carries unlanded code from PR #5060.
- Fix build_maxtext.md relative paths in elastic training and 4
posttraining tutorials (docs/build_maxtext.md → tutorials/build_maxtext.md)
- Replace unresolvable relative .ipynb path in gepa_optimization.md
with GitHub URL (src/ is outside the Sphinx docs source tree)
- Add orphaned mtp_cp_packing page to reference toctree
- Add missing rl_gemma4_e4b tutorial to post_training_index
Integrate sharded Muon into MaxText.
- Implements sharded_muon_utils.py to pair Muon dimension numbers paired with NamedSharding trees.
- Adds flags for muon_type ('maxtext_muon' vs 'optax_muon') and muon_use_all_to_all.
- Plumbs mesh through create_training_optimizer in train_utils.py, train_compile.py, and maxtext_engine.py. This is necessary to make sharded muon work.
- Adds comprehensive unit test coverage.
PiperOrigin-RevId: 979313258
Avoid JAX recompilations in get_mrope_input_positions by using inline NumPy.
These show up on profiles as taking about 1s per rollout for generation and significantly slows down generations.
PiperOrigin-RevId: 979313868
Move mmap index builder into maxtext utils
revert
Implement continuous stream chunking for C4 MLPerf dataset
[NNX] Delete Linen (4/5): remove Linen decoder/attention layers and *_as_linen model wrappers
feat(training_engine): integrate Raiden weight sync and clean up rollout path
Combines Raiden FFI weight synchronization integration in MaxTextTrainingEngine
and rollout path cleanup.
Training Engine & Weight Sync:
- Integrate Raiden weight sync in MaxTextTrainingEngine with FFI support and persistent synchronizer lifecycle.
- Access config properties directly without getattr.
- Generalize WeightConverter and MaxTextToMaxTextConverter for arbitrary kv_tp_size and moe_mlp_tp_size.
- Support target-free KV head replication along axis -2 when kv_tp_size > base_num_kv_heads.
- Safely default MoE lane_size to 0 for TPU GMM_v2 plain per-shard concatenation.
- Add helper utilities is_verify_weights_enabled and resolve_prefuse_moe_weights in convert_utils.
- Add unit tests in prepare_weight_sync_test and weight_converter_test.
Rollout Path Cleanup:
- Remap vLLM hybrid KV cache groups using layer_name_to_kvcache_index before passing to decoder layers.
- Strip auxiliary runner kwargs before forwarding to self.model.
- Write updated KV caches back to original physical cache slots.
- Add rollout_tensor_parallelism field to VLLM config.
- Remove redundant rollout weight sync logic and tunix_compat_context in favor of Raiden FFI path.
[NNX] Delete Linen (5/5): remove the pure_nnx/enable_nnx/pure_nnx_decoder config flags
Drop the three migration flags from types.py (the Fields plus the now-dead qwix
validator), base.yml, inference/vllm.yml, the distillation configs and
pyconfig_deprecated.py, and clear the remaining mentions in the docs, the LoRA
demo notebook and the shell scripts.
setup_initial_state was the one reader parked by pre-train 1/3, because its
Linen branch was entangled with the checkpoint restore overlay. Collapse it
here. The Linen arm is unreachable now that get_abstract_state always returns an
nnx.State, and the weight_sparsity_n/m merge it carried is already covered by
_reshard_aligned, which keeps the freshly initialized value for any leaf the
checkpoint did not supply. The trailing unbox_logicallypartioned only unwrapped
Linen LogicallyPartitioned boxes, so it goes with it.
Removing pure_nnx_decoder also un-skips four train_compile pipeline tests that
were guarded on it. Three pass as they are, so drop all four guards.
test_pipeline_subset turned up a real porting bug: the NNX decoder tagged the
scan axis of the layers outside the pipeline "layers", which base.yml maps to
the stage mesh axis, so a remainder that does not divide the stage count failed
to shard. Linen used "layers_outside_pipeline", which has no rule; use that.
Tests follow: drop the flag from every config dict and SimpleNamespace stand-in
that still set it, and delete test_diloco_requires_pure_nnx, which asserted the
message of the validator this change removes.
Add flag governing whether to apply Muon to MoE routers.
PiperOrigin-RevId: 979560160
Add collective psum reduction for MoE expert bias load balance calculation.
* In `calculate_load_balance_updates`, expert token counts were computed locally
per device shard without reduction across distributed data and FSDP ranks.
* When running under `shard_map` in `sparse_matmul`, this caused shards to compute
conflicting expert bias updates and failed to balance load over the global batch.
* More details in b/559292272
PiperOrigin-RevId: 979567508
Add separate final normalization for Multi-Token Prediction in DeepSeek models.
This work was done in collaboration with @shuningjin.
This change adds the MTP-specific final normalization layer in DeepSeek models. It updates the checkpoint conversion script to load the MTP final norm weight instead of skipping it. It also updates the decoder's output head to skip normalization, allowing the MTP block to apply its own final norm before projecting hidden states to logits.
- Added unit tests in `tests/unit/multi_token_prediction_test.py`
- E2E: Verified convergence parity against reference run (baseline vs NewMTP at Step 48).
- Verified MTP acceptance rate on HumanEval (~90% average).
- Workload links and verification details documented in the bug.
Before submitting this PR, please make sure (put X in square brackets):
- [X] I have performed a self-review of my code. For an optional AI review, add the `gemini-review` label.
- [X] I have necessary comments in my code, particularly in hard-to-understand areas.
- [X] I have run end-to-end tests tests and provided workload links above if applicable.
- [X] I have made or will make corresponding changes to the doc if needed, including adding new documentation pages to the relevant Table of Contents (toctree directive) as explained in our documentation.
PiperOrigin-RevId: 979635864
Keep qwix out of tpu-inference's fused MoE kernel and pre-quantize weights
Fix three defects on the Tunix adapter and engine packing path
Sequence packing itself landed on main in ed64d6c (#5100). These are three
adjacent defects that came with it or were exposed by it; none is in the
packing implementation.
1. The adapter took `attention_mask` and dropped it, rebuilding an equivalent
mask from `pad_id`. Two independent sources of truth about which tokens are
padding, with no mechanism to keep them in agreement: a `pad_id` that differs
from the one Tunix padded with -- or a `pad_id` left None for a padded batch
-- silently trains on padding, producing well-formed logits and incorrect
log-probs. Segment ids now derive from the mask, so there is one source.
Nothing is lost. `tunix/sft/utils.py:make_causal_attn_mask` builds
`input_mask[..., None, :] * tril`, applying the mask on the key side only, so
the `[B, L, L]` array carries no more than a `[B, L]` pad mask. Plumbing the
dense array through to attention would be dot-product-only -- flash and
splash take a `SequenceDescriptor` built from segment ids -- for identical
numerics.
Priority is explicit segment ids, then the mask, then `pad_id` synthesis.
Packing passes `attention_mask=None` and lands on the `pad_id` fallback
exactly as before; the unpacked Tunix path is the one whose behaviour changes
(`peft_trainer.py:1241` passes a mask positionally and no segment ids). The
new rank check raises under `jit` as well as eagerly, replacing a value-level
cross-check that could not: it was skipped under `jit` by default, and on the
only path that reached it both sides descended from one
`prompt_completion_mask` through one `process_ids` call, so it compared a
value against itself.
2. MaxText declared its own `RouterReplayTrainerPayload`, redeclaring
token_ids/token_mask on the grounds that they were inherited. Tunix commit
984e6ce7 emptied `TrainerPayload` -- the arrays live on its subclasses now --
so the redeclaration had become the definition, and the payload
MaxText read no longer resembled the one Tunix sends. It now consumes tunix's
`RLTrainerPayload`, re-exported from `abstract_engine`.
Two consequences beyond the names. `inputs_position` was a global cumsum over
the mask, so the second sequence in a packed row started where the first left
off and every one of its tokens was rotated by the first sequence's length.
The function was already half packing-aware -- `targets_segmentation` masked
the seams correctly -- and the existing test built exactly the packed row that
triggers it, but only ever asserted the mask. Positions now restart per
segment. Separately, `segment_positions` was never read at all: the packed
assembler computes it, and re-deriving it produces a second value that can
disagree with the positions the rollout used. It is used when present,
with the segment-aware derivation as the fallback.
Consuming `RLTrainerPayload` means joining the prompt and completion halves.
That is uniform across both assemblers rather than a special case for either:
`SequencePackedBatchAssembler` emits a zero-width prompt with the whole packed
row in the completion, so the concatenation is a no-op there. Tunix types
`segment_ids` as [B, T] or [B, C], which coincide only because of that
zero-width prompt; a completion-width array against a real prompt would
broadcast or truncate silently, so it is rejected.
3. Three engine docstrings describe the code inaccurately now that the adapter
takes packed `segment_ids`. `tokenizer_pad_id`'s docstring and the matching
ValueError both said the adapter "cannot build decoder_segment_ids" without
it; it can, from two higher-priority sources, and `pad_id` is the last-resort
fallback. `_shard_micro_batch`'s note offered "a sequence-packed micro-batch,
always size 1" as its example of a batch dim that fails to divide the mesh
axis, and neither half held: the distributed packer takes its row count from
tunix's `train_micro_batch_size`, which does default to 1
(`rl_program.py:140`), while the colocated packer sizes rows as `fsdp * dp`
(`rl/utils.py:401`) and so divides the axis by construction. Say which is
which, so nobody reads this as "packing wastes compute" in general.
Removing `RouterReplayTrainerPayload` invalidates the seven call sites in
`router_replay_engine_test.py` that construct it, so their migration to an
`_rl_payload` helper over Tunix's `RLTrainerPayload` is part of this commit
rather than the next one: the suite has to pass here. One of those tests,
`test_packed_segment_boundaries_are_masked`, already built the packed row that
exposes the position defect above and asserted only the mask; it now asserts the
restart as well, and is renamed to say so. New coverage for all three fixes is
in the following commit.
Add the packing test suite: a unit oracle for the engine and adapter
Sequence packing landed on main in ed64d6c (#5100) with no test proving it
correct. This is that evidence, plus coverage for the three adapter and engine
fixes in the previous commit.
`tests/post_training/unit/maxtext_engine_packing_test.py` (new) is the oracle:
* The adapter contract packing depends on. Tunix gates its whole segment path
on an exact-name lookup for a `segment_ids` parameter on the model's
`__call__`, with a `**kwargs` exemption. TunixMaxTextAdapter offered
`decoder_segment_ids` and neither, so the gate returned False,
`compute_per_token_logps` took its fallback branch, and passed
`attention_mask` -- None for a packed batch. Nothing raised: MaxText fell back
to causal-only masking over the whole packed row and every sequence attended
to the ones before it, at a finite loss and normal throughput.
* Packed-vs-unpacked gradients over the whole tree, not a sample. On this stack
a prior investigation had three sampled tensors match exactly while the
aggregate over all 310 showed under one percent delivered, so sampling did not
merely miss the defect, it misdirected the investigation for three runs.
* A hand-computed absolute loss, ported from
`tunix/tests/rl/algo_core_test.py:49`. Every other test here compares packed
against unpacked, and that whole family passes when both sides are wrong
the same way -- aggregating by row rather than by segment is precisely such a
fault, since by-row is what the *unpacked* layout should do.
* The accumulation identity over four partitions of 8-32 examples (ports
`peft_trainer_test.py:1487` and its `:1563` bridge); an off-by-one divisor is
invisible at K=2 in a way it is not at K=8. Derived here rather than
hand-supplied as in tunix, by counting live segments in a packed row -- that
derivation is what packing put at risk.
* The accumulate-then-apply cadence (`peft_trainer_test.py:1886,1891,1925`).
Nothing else in the file calls `update()`, so the apply half of the engine was
unexercised on packed input. `test_packing_config_keeps_cond_path` is
deliberately not ported: the engine has no `is_update_step` flag and no
`lax.cond` to assert on, since `fwd_bwd` and `update` are separate Python
calls and the cadence belongs to the caller.
* The compiled branch of `fwd_bwd`. Everything above runs eager, while the
orchestrator's worker lifecycle calls `compile()` unconditionally on every
worker -- so packing had been proven only on a branch a real run does not take.
Segment lengths within a packed row are unequal throughout. With equal lengths
`sequence-mean-token-mean` is algebraically `token-mean`, so a uniform fixture
executes `_aggregate_loss_segmented` on every step without ever distinguishing
it from the code it replaces, and a packed run that reduced per row would pass.
The other three files extend existing suites for the same reasons.
A TPU harness that drives MaxTextTrainingEngine and tunix's
`peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their
gradient digests is held on `packing-compare-harness`, stacked on this branch.
It is kept out of this change because it needs a TPU, has no automated caller,
and carries unlanded code from PR #5060.
fix Gemma4 vllm_decode
Fix EOS token detection and Qwen vision model routing in multimodal_eval
validate_converter: fix linters error
fix
…tTrainer `tests/end_to_end/tpu/compare_tunix_trainer.py` drives MaxTextTrainingEngine and tunix's `peft_trainer_v2.PeftTrainer` over the same micro-batches and diffs their gradient digests. It is held on this branch rather than in the packing PR: it needs a TPU, no CI job or shell script invokes it, and it carries code from an unlanded PR. The bulk of the file is chengnuojin's, from commits 150ff614e and 3f3f90229 on `chengnuojin-trainer-fix` (PR #5060, now superseded by #5099 and #5104). PR #5060's engine half landed via #5088 but the harness did not. It is squashed in here rather than replaying that branch's thirteen commits, ten of which are already in main by content under different SHAs. The packed half is mine: `--packed`, `--segments-per-row`, `--loss-agg-mode` and `_packed_train_example`, plus `--compare-layouts`. `--packed` hands both trainers the same packed examples, so a defect they share is invisible to it -- and they share TunixMaxTextAdapter. `--compare-layouts` takes the orthogonal axis: one trainer, byte-identical data laid out packed and unpacked, diffed at the step-0 gradient. It is the port of tunix's `grpo_learner_test.py::test_sequence_packing`, keeping its four budgets as multiples of one sequence rather than absolute token counts. The step-0 gradient is the verdict metric, not the final weights: Adam's first step is essentially `lr * sign(g)`, so any near-zero component that flips sign moves that weight by a full `2 * lr` regardless of how small the difference was.
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.This is the one behaviour change in the PR, as opposed to a timing change, and it follows directly from the constraints becoming real:
micro_batch_size_to_train_onmust now be a multiple ofdata x fsdp. A batch those devices cannot split is padded out by XLA, and the padded lanes are all-zero sequences that mask themselves out of attention entirely -- their contribution comes back as NaN on the pad token's embedding row, giving unusable gradients under a finite loss. MaxText's splash kernel already asserts on exactly this ratio ("Batch dimension should be shardable among the devices in data and fsdp axis",attention_op.py);dot_productdoes not, so there it surfaces as a NaN rather than an error. Callers relying on an undersized micro-batch are the group affected; in practice such a batch was already leaving devices idle. This is how it first showed up -- the GRPO integration test hard-coded a batch of 2 and ran on a 4-device host.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.Rebased onto
2fbedac89.202a89ab8landed the always-on gradient norm on main while this was in review; the branch now keeps main's placement — before clipping, which is where Tunix'soptax.global_normsits — rather than this branch's post-clipping one, and adds the float32 reduction and the throttler's use of it on top. Every measurement below runs unclipped, so no number changes. Main's LoRA rejection andperplexitymetric are carried through unchanged.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.On a second model the same fix is worth almost nothing, and that is the honest framing. Re-run on qwen3.5-35b-a3b — 2 KV heads, so
--tp 8is rejected and unscanned it does not fit in HBM, leaving--tp 2 --scanas the shape that runs on 8 devices:PeftTrainerv28.5 ms on the median, against 71.8 ms on qwen3-0.6b. Two things set what the fix is worth, and neither is model size:
nnx.splitcosts 5.0/4.7 ms against 21.3/22.3 ms.The tail still improves where the median does not: the 35b's worst step drops 2641.8 → 2314.6 ms and its mean converges onto its median — 327 ms of jitter removed from a model whose median moved 8.5 ms. And it reaches 1.005x of
PeftTrainerdriving the identical model, from 1.008x. So: a large win on unscanned models with short steps, a jitter fix everywhere else, and nothing that regresses.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 tables in "The host step path" come from the threetests/end_to_end/tpu/perf_parity/qwen3_*_profile.pyarms, each run with--no-traceand the shape flags in the table headers (--model qwen3-0.6b --tp 8, or--model qwen3.5-35b-a3b --tp 2 --scan; the tunix arm refuses--model/--scan, having only the one architecture); 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, whose §9 carries the (7)-(9) A/B on both models.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.Trainer parity:
MaxTextTrainingEnginevs tunixPeftTrainer v2Reproduced from
tests/end_to_end/tpu/perf_parity/README.mdon this branch, which supersedes the2026-08-31 performance and HBM numbers this section used to quote. Those, and the (7)-(9)
host-path A/B, remain in
docs/reference/training_engine_tunix_parity.md.Consolidated conclusions from two studies run on a v7-8 (Ironwood) host, 2026-09-02 and
2026-09-03. Both trainers drive the same MaxText model in every arm, so what varies is
the trainer, the mesh and the sharding mode — never the implementation of the network.
Two models, chosen because they stress opposite things:
qwen3.5-35b-a3bqwen3-0.6bRESULTS-qwen35-35b-ep-20260902.md,RESULTS-qwen35-35b-20260902.mdRESULTS-qwen3-0p6b-zero1-20260903.mdEvery command needed to reproduce every number is in Appendix A.
Conclusions
On raw throughput the two trainers are at parity, and the gap is a fixed dispatch, not a
scaling one. On the 35b model they land within 0.5% of each other at every mesh
(670.3 vs 666.6 ms at ep=8; 2313.8 vs 2303.1 at tp=2), both above 99% device utilization.
PeftTrainer's lead is its single fusedjit__train_stepagainst the engine's splitfwd_bwd+update— a constant ~16 ms, which is 0.7% of a 2.3 s step and a third of a61 ms one.
Under explicit sharding the engine wins decisively, and the win is the engine's, not the
mesh mode's. This is the largest trainer-attributable difference measured. Setting
shard_mode=expliciton qwen3-0.6b at GA=8 makes the engine 1.50x faster (371.7 →247.4 ms) and
PeftTrainer1.31x slower (356.8 → 465.7 ms) — the same flag, the samemesh, opposite signs. Same-mesh, same-mode, GA=8: 247.4 ms against 465.7 ms, 1.88x.
Under Explicit axes the engine's cotangents come out
unreduced, so the data-parallelall-reduce moves out of every micro-batch into
update(); tunix's step has no such pathand simply pays the collectives literally where GSPMD had been optimizing them.
The decisive difference is capability, not speed:
PeftTrainer v2cannot do gradientaccumulation on the 35b model at the tunix revision MaxText pins. GA=2, 4 and 8 each OOM
with a byte-identical 161.94 G against 94.74 G available. The engine has no such wall and
its per-micro-batch cost improves with depth. Tunix
44a35eeaffixes it with one line(
GradientAccumulator.reset()usingv[...] * 0instead ofjnp.zeros_like, which losessharding inside a traced function and materialises the full 129 G tree). Recommend
bumping
src/dependencies/extra_deps/post_train_github_deps.txtpast that commit.Mesh choice dominates trainer choice by an order of magnitude. Moving the 35b model
from
fsdp=4 x tp=2toep=8 --ring-of-experts --ragged-sortis worth 3.45x on bothtrainers alike. Nothing trainer-side in this study is worth more than 1.9x. Tune the mesh
before arguing about trainers.
For memory on small models, FSDP beats Zero-1 and it is not close. 2.04 G against
8.70 G at qwen3-0.6b/GA=8. Zero-1 shards the two Adam moments; FSDP shards the parameters
and gradients too, and at 0.6 B those are most of the residual. Zero-1's advantage is that
it adds no collectives to forward and backward — not that it saves more bytes. The engine
forbids combining them.
Neither trainer currently offers both speed and memory. At qwen3-0.6b/GA=8 the engine's
explicit path is 1.36x faster than
PeftTraineron FSDP but uses 6.2x the HBM — 4.3x evenwith Zero-1 on top. That is the open gap this comparison leaves.
Environment
remat_policy=none--no-traceXLA Modulesline, per-execution, from a separate--steps 6traced runpeak_bytes_in_useoff the TPU allocator, max over the 8 devices,--no-traceruns1b75c2479. 0.6b:18f4a2332(PR #5060 scripts + PR #5099 engine)remat_policy=none,scan_layersmatched, anddtype=float32on both arms throughout —tunix's
ModelConfigdefaults to f32 and itsRematConfig.NONEdoes not rematerialize, soleaving MaxText's defaults in place would have MaxText winning on numerics rather than on
implementation.
attention: autoselectedis deliberately not equalized; the chosen kernelis part of what is compared, and it is logged.
1.
qwen3.5-35b-a3b— MoE, mesh-bound40 layers, 256 routed + 1 shared expert, emb 2048, 16 query / 2 KV heads, head_dim 256,
vocab 248320. Scanned.
optax.sgd(1e-5), constant schedule, no clipping.--tp 8is illegal on this model:_validate_kv_head_shardingrequiresnum_kv_heads % tp == 0and there are 2 KV heads, sofsdp=4 x tp=2is the widest legaltensor-parallel shape on 8 devices.
1.1 Trainer vs trainer, same mesh
PeftTrainer v2ep=8+ ring + raggedep=8+ ring + raggedfsdp=4 x tp=2fsdp=4 x tp=2¹ tunix
07dbe293(head). OOM at the pinnedc4ec573— see §1.3.² OOM at the pinned revision; not run at head.
Device utilization is 99.2–99.8% on every row. At 2.3 s of device work per step every
host-side difference between the trainers hides; the 0.5% that remains is the engine's second
dispatch (
jit__update_kernel, 16.0 ms at ep=8 and 16.46 ms at tp=2).Per-execution device cost, ep=8, GA=1:
jit_first_kernel648.9 +jit__update_kernel16.0PeftTrainer v2jit__train_step663.01.2 The mesh is worth 3.45x, and the two trainers track each other exactly
Every row
--scan --seq 1024 --ga 1 --no-trace, median of 19 steps.PeftTrainer v2--tp 2(fsdp=4 x tp=2)--tp 2 --ep 4 --ring-of-experts --ragged-sort--ep 8(no MoE flags)--ep 8 --ragged-sort--ep 8 --ring-of-experts--ep 8 --ring-of-experts --ragged-sortep=8with no kernel flags is already 2.17x. TPsplits a 2048-wide embedding and 2 KV heads and pays all-reduces for it; EP splits 256
experts, which is what this model actually has a lot of.
1.07x on top of the ring.
layers/moe.pyselects thering_ragged_sortkernels only underuse_ragged_sort and use_ring_of_experts.--tp 2 --ep 4with bothflags on (1726.9 ms) is slower than a bare
--ep 8with none (1063.8 ms).Wall and device speedups agree to two decimals (3.45x / 3.47x), which is what says the gain is
work removed rather than overhead rearranged.
1.3 Gradient accumulation: a capability difference
At
ep=8+ ring + ragged, median ms/step:PeftTrainer, tunixc4ec573(pinned)PeftTrainer, tunix07dbe293(head)The figure is byte-identical at GA=2, 4 and 8, which rules out the obvious reading that N
micro-batches are live at once. The allocation is parameter-shaped, not depth-shaped:
PeftTrainer._is_single_microstep()is true only atgradient_accumulation_steps == 1, andonly that path skips allocating the accumulator.
The cause is one line, tested directly. Reverting only tunix
44a35eeafon top of head —leaving the other 208 commits in place — brings the failure back byte for byte.
reset()runsinside the traced
_update_step, and therejnp.zeros_likedoes not carry its operand'ssharding, so XLA materialises the full unsharded tree:
Once fixed, the two trainers are at parity at every depth and
PeftTrainerpulls 1.2% aheadby GA=8, because it amortizes its update slightly better (667.3 → 659.8 per micro against the
engine's 670.3 → 667.9).
2.
qwen3-0.6b— dense, sharding-bound28 layers, emb 1024, 16 query / 8 KV heads, head_dim 128, mlp 3072, vocab 151936, tied
embeddings; ~0.6 B params ≈ 2.4 G in f32. Unscanned.
adamw(b1=0.9, b2=0.95, eps=1e-8,wd=0.1, lr 1e-5 constant, no clipping), matched term for term across both trainers — base.yml's
constants, not optax's defaults.
adamwand--dp 8rather than thesgd/--fsdp 8the 35b arms use, because Zero-1 shardsparameter-shaped optimizer state over the
dataaxis: it is vacuous under SGD and mutuallyexclusive with FSDP.
2.1 Trainer vs trainer, all six arms
Median ms/step and peak HBM per device (of 101.72 G).
shard_modePeftTrainer, dp=8PeftTrainer, dp=8PeftTrainer, fsdp=8The same-mode comparison is the one that matters, and it flips sign with the mode:
PeftTrainer v2shard_mode=autoshard_mode=explicit2.2 Where the time goes
Per-execution device cost,
--steps 6traces. A GA=8 step is onefirst_kernel, sevenaccum_kerneland one_update_kernelon the engine; eight_fwd_bwd_stepand one_update_steponPeftTrainer.PeftTrainer, autoPeftTrainer, explicitPeftTrainer, fsdp=8The all-reduce is moved, not removed, and the same ~20 ms shows up three times. Going
auto→expliciton the engine at GA=8: the first kernel drops 20.08 ms, each of sevenaccumulates drops 18.54 ms, and the update rises 21.86 ms. That is one all-reduce of the 2.4 G
f32 gradient tree over 8 devices, which this host does in about 20 ms — paid eight times under
auto, once underexplicit. Extrapolated from these kernels it is a 1.78 ms loss at GA=1(measured at 4.63 ms in the GA=1 trace, which runs slower throughout) and pays from GA=2 on.
PeftTrainergets no such benefit; Explicit costs it 31%. Its per-micro-batch step goes42.73 → 56.04 ms while its update is unchanged (6.82 → 6.89). Nothing moves out of the
micro-batch — the collectives stay where they were and get more expensive, because Explicit
axes stop GSPMD from rearranging them.
Zero-1 costs +2.9 ms, entirely in
update(), and this is provable rather than inferred.The Zero-1 arm's
jit_first_kernelandjit_accum_kernelcarry identical HLO program hashesto the explicit control's (
1445966014043162594and614701509375139023), at both GA depths.Only
jit__update_kerneldiffers (8707565451094719151), by +2.90 ms at GA=8 and +2.85 ms atGA=1 — the price of replacing the gradient all-reduce with a reduce-scatter plus an all-gather
of the updated parameters.
FSDP does not supply a deferral either. It cuts the per-micro cost only 42.73 → 40.79 ms:
sharding the parameters swaps the gradient all-reduce for a parameter all-gather plus a
gradient reduce-scatter, about the same traffic, still once per micro-batch. The engine's
explicit path runs the same micro-batch in 25.03 ms, 1.63x faster, by not running the
collective there at all.
2.3 Where the memory goes
At ~0.6 B params in f32: parameters 2.4 G, Adam
mu+nu4.8 G, gradients 2.4 G.data(4.8 G → 0.6 G)PeftTrainer, fsdp=8fsdpZero-1's predicted saving is 4.8 − 0.6 = 4.2 G; it delivers 4.04 G of that at GA=8 but only
2.20 G at GA=1, because with no accumulator live the high-water mark is set partly by the
activation peak instead. Zero-1's saving is capped by whatever else is at the peak, which
is why it looks better the deeper the accumulation goes.
FSDP's 2.04 G is not a better-tuned version of the same idea — it shards a strictly larger set
of tensors.
3. Which to use
--ep 8 --ring-of-experts --ragged-sortinstead — 3.45x.PeftTrainerOOMs outright. Capability, not speed.44a35eeafPeftTrainer1.2% ahead at GA=8.PeftTrainer--explicitPeftTrainer+ FSDP--zero1--explicit, which brings the deferral with it.Do not read the small-model host overhead as a general engine defect: on the 35b model the
same NNX graph work is 3.1 ms against a 714 ms step, 0.4%.
4. Traces
All 24
.xplane.pbfiles, full paths.-engineisMaxTextTrainingEngine;-maxtextis thePeftTrainer v2arm (the MaxText model under tunix's trainer).qwen3-0.6b, Zero-1 and explicit sharding (2026-09-03)
The last two paths are the fsdp=8 arm despite not saying so.
RunSpec.tag()only annotatesnon-default mesh fills and
--fsdp 8is the default on 8 devices, so the FSDP arm gets nomesh suffix while the
--dp 8arms get-dp8. Cross-check on device time if in doubt: thefsdp arm's GA=1
jit__train_stepis 41.07 ms, the dp arm's 50.83 ms.qwen3.5-35b-a3b, expert parallelism (2026-09-02)
qwen3.5-35b-a3b, tensor parallelism and the PR #5060 A/B (2026-09-02)
qwen3.5-35b-a3b, engine GA anatomy (2026-09-02)
Read any of them with:
Appendix A: Reproducing
A.0 Prerequisites
A TPU VM with 8 devices (these numbers are from a v7-8; other 8-device generations will
give different absolute times but the same comparisons). Then:
The engine-side Zero-1 measured in §2 is PR #5099, not #5060. On a #5060-only checkout
every engine arm prints
zero1: UNSUPPORTEDorzero1: DECLINED (...)and the Zero-1 rowsbecome a second copy of the baseline — the runner greps for that line so it cannot pass
unnoticed. The other five arms of §2 and all of §1 reproduce on #5060 alone.
A.1 One command per study
Each prints its step times, peak HBM and trace paths at the end, and writes one log per arm
into the output directory. They run serially — every arm wants the whole host, and two
concurrent runs will fight over the 8 devices.
A.2 Individual arms — §1,
qwen3.5-35b-a3b--ring-of-expertsand--ragged-sortare rejected at--ep 1, mirroring MaxText, whichinfers the EP rank from
logical_axis_rulesrather than fromici_expert_parallelismandraises "When EP rank is 1, use_ring_of_experts must be False".
The GA table's
PeftTrainerhead column needs tunix past44a35eeaf. Installing it withouttouching the repo's pin:
pip install --no-deps "google-tunix @ https://github.com/google/tunix/archive/07dbe293.zip"A.3 Individual arms — §2,
qwen3-0.6b--zero1implies--explicit.RunSpecrejects the three combinations the engine wouldreject anyway — Zero-1 with FSDP, with
dataof 1, and withsgd— rather than letting themrun and quietly measure the baseline.
A.4 Flags
--modelqwen3-0.6bmodel_name; onlyqwen3_0p6b_tunix_profile.pyis model-locked--dp / --fsdp / --tp / --ep--ga--seq,--steps--scan--optsgdsgdoradamw, matched across arms--explicitshard_mode=explicit— Explicit rather than Auto mesh axes--zero1shard_optimizer_over_data; implies--explicit; engine arm only--ring-of-experts,--ragged-sort--ep > 1--no-trace--devices NPERF_PARITY_PROFILE_ROOT./profilesAppendix B: Measurement notes
These are the traps that produce plausible wrong numbers rather than errors. Each was hit.
Wall clock must come from
--no-traceruns. The profiler charges per dispatch, and the twotrainers dispatch very differently — at qwen3-0.6b/GA=8 the traces record 147.3 module
launches per step on the engine against 18.0 on
PeftTrainer. Both run nine substantialkernels; the engine's other ~138 are small eager dispatches, each a separate host round trip.
Traced GA=8 wall clock runs 345–586 ms against 247–372 untraced, and inflates the arms
unevenly — a traced A/B reverses the GA=8 ranking outright. On the 35b model the same
overhead is at most 0.08%, because a 2.3 s step with ~14 dispatches gives it nothing to bite on.
Per-kernel device times are comparable within a GA setting, not across one. The same HLO
program hash measures 1.8–4.6 ms slower at GA=1 than at GA=8 in all three engine arms. Every
subtraction in §2.2 is taken within one GA column.
Tracing lowers the engine's peak HBM by ~2.4 G, and only the engine's. 12.73 G untraced
against 10.36 G traced at GA=8; both
PeftTrainerarms are identical either way. Step count isnot the variable — re-running untraced at
--steps 6reproduces the 23-step figures exactly.The likely cause is dispatch depth (the engine runs further ahead of the device, and the
profiler's synchronization drains the queue), consistent with the engine-only incidence and the
~2.4 G size, but not verified directly. Memory figures come from
--no-traceruns.Read TPU-busy off the
XLA Modulesline, notXLA Ops. On a scanned MoE the Ops line sumsto roughly twice both the module time and the wall step, because ops inside the scan are
emitted underneath a fusion that already covers them.
Take the maximum per-execution duration, never a mean or a total-over-steps. Ragged sort
runs on SparseCore and floods the trace buffer (~1.3 M events against ~18 k at tp=2), dropping
most of the
XLA Modulesline; a clipped event then averages in as a fast step.maybe_tracenow passes base.yml's
enable_tpu_profiling_optionstojax.profiler.trace, which caps thecapture and keeps every execution;
--no-tpu-profiling-optionsrestores the raw behaviour.A bare
jax.sharding.Mesh(create_device_mesh(...), axes)silently disables Zero-1._zero1_activerequires allmesh.axis_typesto beExplicit, and that constructor leavesthem
Auto. The arms build their mesh withmaxtext_utils.get_mesh_from_config(...), andevery engine arm prints a
zero1: ACTIVE | DECLINED (reason) | UNSUPPORTEDline — a declinedrun is the baseline wearing the feature's name, which is invisible in a step time.
The engine's own
fwd_bwd / updatesplit does not decompose the step.fwd_bwddispatchesasynchronously and the blocking wait lands inside
update(), so at GA=1 the whole step appearson the update side. Use the device figures.
enable_checkpointing=Falsedoes not stop the engine writing a final checkpoint.close()ends in
save_checkpoint(..., force=True)and the manager arms itself oncheckpoint_dirbeingnon-empty. On the 35b model in f32 that is a ~140 G write per run, which filled this host's disk
and killed the first sweep.
engine_profile.pynow disarms the manager when the config sayscheckpointing is off; the write lands after the timed loop either way.
GRPO integration test:
tests/post_training/integration/maxtext_engine_grpo_loss_test.py— 1 passed in 51.67 s on the real checkpoint. Note it never callsengine.compile(), so it measures the eager path (237 ms/update vs 14 ms). Two things in it are now load-bearing: the batch is derived from the mesh rather than hard-coded, for the reason under change (1); andmatmul_precision=highestis pinned, because the KL assertion compares log-probs from two code paths — Tunix'scompute_per_token_logpsfor the reference against the engine's sharded forward for the policy — which at bf16 disagree by ~2e-2 on values near -12.6, andlow_var_klsquares that into a KL of ~1.2e-4, an order of magnitude above the tolerance the assertion is trying to police.xprof traces for the (7)-(9) A/B, engine arm only,
base/= this fix's parent commit andhead/= the fix, at the two shapes in "The host step path":Traced, that A/B reads 271.4 → 92.8 ms and 2350.0 → 2315.2 ms. Compilation is inside these windows — the engine arm calls
compile()under the trace on purpose — so read the steady-state region, not the whole trace.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.