Skip to content

Defer the engine's live-object publish out of the step path - #5138

Draft
NuojCheng wants to merge 2 commits into
mainfrom
engine-defer-live-publish
Draft

Defer the engine's live-object publish out of the step path#5138
NuojCheng wants to merge 2 commits into
mainfrom
engine-defer-live-publish

Conversation

@NuojCheng

Copy link
Copy Markdown
Collaborator

Description

Removes the last two pieces of redundant per-step work from MaxTextTrainingEngine.

1. The step path no longer walks the live NNX module graph.

fwd_bwd and update each called nnx.update on the live NNX objects on every step. nnx.update walks the module graph, so its cost scales with the graph's size rather than with the state actually written: 6.2 ms to publish 180 leaves of non-parameter state into an unrolled 28-layer qwen3-0.6b, once per micro-batch, and 16.7 ms for the parameters once per update. That was the last per-step graph walk left after _refresh_pure_state moved the two nnx.split calls to once-per-compile.

Nothing inside the step path reads those objects back — _read_model_pure and _read_state_pure answer from the pure mirror, and the live objects serve only as containers — so the walk is pure publication, for readers outside the engine. So defer it. _publish_to_live marks the mirror as ahead of the live objects, and _sync_live_objects flushes before anything reads real values off them: the model / optimizer / state properties, _refresh_pure_state, checkpoint save and restore, _get_trainable_params_state, and the throttler's fallback path when there is no gradient norm to wait on. _invalidate_pure_state flushes before forgetting the mirror, so dropping the cache never drops a step's results with it.

The sync is not merely cosmetic. update() donates the state it hands to the update kernel, so a reader that saw a deferred publish would find arrays that are deleted, not merely stale. Publication stays eager whenever the pure state is disabled, since there would be nothing to publish from later.

2. The global gradient norm is computed once instead of twice.

_update_kernel computed l2norm_pytree(grads) for the metric and then called maxtext_utils.apply_gradient_clipping, which recomputes the same norm inside optax.clip_by_global_norm — a second full pass over every gradient plus a second cross-replica reduction of a scalar. _clip_by_grad_norm applies optax's own scale (1 under the threshold, else threshold / norm) to the norm already in hand.

Reusing it is also the more accurate of the two. The engine's norm is computed in float32 for the reason stated at its call site — a sum of squares over bf16 overflows on production-size models — while optax's runs in the gradients' own dtype. Under grad_dtype=bfloat16 the engine was therefore reporting a guarded norm and then clipping with an unguarded one. At the default grad_dtype=float32 the two are the same number and this changes nothing numerically. The fp8 path keeps the optax call, which holds OVERWRITE_WITH_GRADIENT out of both the norm and the scaling; that carve-out is not worth reproducing for the saving.

Benchmark

qwen3-0.6b on 4× v6e, ici_data_parallelism=4, per_device_batch_size=2, max_target_length=1024, adamw, bf16 compute / fp32 weights, synthetic data. Median steady-state step over 9 post-warmup steps, this branch against its merge base on main:

config main this PR delta
GA=8, unrolled, clip=1.0 878.4 ms 826.1 ms −52.3 ms (−6.0%)
GA=8, unrolled, clip=0 (isolates change 1) 878.4 ms 836.7 ms −41.7 ms (−4.7%)
GA=1, unrolled, clip=1.0 144.0 ms 143.8 ms no change
GA=8, scanned, clip=1.0 852.2 ms 850.4 ms no change

Losses are bit-identical across all 12 steps of every pair.

Two honest null results, and what I think is behind them:

  • Scanned layers have a tiny module graph, so there was never anything for change 1 to save. Expected — this change only matters for scan_layers=false.
  • GA=1 saves the same ~23 ms of host work, but it does not surface. The likely reason is that with a single micro-batch in flight the host work hides behind async dispatch, whereas at GA=8 the InflightThrottler (max_inflight_computations: 2) forces the host to synchronize every couple of micro-batches and the graph walk stops being free. I did not instrument this, so treat it as the probable mechanism rather than a measured one.

Change 2 in isolation does not move wall time at this model size, but it is not a no-op. Lowering and compiling the update kernel on its own and reading cost_analysis():

flops bytes accessed HLO lines
main, grad_dtype=float32 1.371e10 6.917e10 33670
this PR, grad_dtype=float32 1.311e10 (−4.3%) 6.897e10 32609
main, grad_dtype=bfloat16 2.205e10 6.362e10 42935
this PR, grad_dtype=bfloat16 2.086e10 (−5.4%) 5.738e10 (−9.8%) 36765 (−14.4%)

Real work is removed; it is simply invisible next to eight forward/backward passes on a 0.6B model. It should matter more at low gradient accumulation on larger models, where the update kernel is a bigger share of the step.

Tests

JAX_PLATFORMS=cpu pytest tests/post_training/unit/maxtext_engine_test.py
JAX_PLATFORMS=cpu pytest tests/post_training/unit/maxtext_engine_xaot_test.py

JAX_PLATFORMS=cpu matters: most of these are marked cpu_only and silently skip on an accelerator testbed.

55 passed on this branch (50 pre-existing, unmodified, plus 5 new); the same 50 pass on main. New coverage:

  • test_live_objects_are_not_walked_on_the_step_path — a steady-state fwd_bwd + update issues no nnx.update against the engine's own live objects. This is the whole point of the change, so a regression that quietly reintroduces the walk should fail a test rather than only show up in a profile.
  • test_reading_the_model_flushes_a_deferred_publish — a deferred publish is invisible to .model.
  • test_dropping_the_pure_state_flushes_rather_than_drops_the_step_invalidate_pure_state flushes instead of discarding.
  • test_publication_stays_eager_without_a_pure_mirror — the fallback still writes through.
  • test_clip_by_grad_norm_matches_optax — the hand-rolled scale matches optax.clip_by_global_norm on both sides of the threshold.

The benchmark above is the end-to-end evidence; no long-running convergence run was done, on the grounds that the losses are bit-identical.

Checklist

Before submitting this PR, please make sure (put X in square brackets):

  • I have performed a self-review of my code. For an optional AI review, add the gemini-review label.
  • I have necessary comments in my code, particularly in hard-to-understand areas.
  • I have run end-to-end tests tests and provided workload links above if applicable.
  • 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.

`MaxTextTrainingEngine.fwd_bwd` and `update` each call `nnx.update` on the
live NNX objects on every step. `nnx.update` walks the module graph, so its
cost scales with the graph's size rather than the state written: 6.2 ms to
publish 180 leaves of non-parameter state into an unrolled 28-layer
qwen3-0.6b, once per micro-batch, and 16.7 ms for the parameters once per
update. That was the last per-step graph walk left after `_refresh_pure_state`
removed the two `nnx.split` calls.

Nothing inside the step path reads those objects back -- `_read_model_pure`
and `_read_state_pure` answer from the pure mirror, and the live objects serve
only as containers -- so the walk is pure publication, for readers outside the
engine. Defer it: `_publish_to_live` marks the mirror ahead of the live
objects, and `_sync_live_objects` flushes before anything reads real values
off them (the `model`/`optimizer`/`state` properties, `_refresh_pure_state`,
checkpoint save and restore, weight sync, and the throttler's fallback when
there is no gradient norm to wait on). `_invalidate_pure_state` flushes before
forgetting the mirror, so dropping the cache never drops a step with it. The
sync is not merely cosmetic: `update()` donates the state it hands the kernel,
so a deferred reader would find deleted arrays, not stale ones. Publication
stays eager whenever the pure state is disabled.

Also stop computing the global gradient norm twice. `_update_kernel` computes
`l2norm_pytree(grads)` for the metric and then calls
`maxtext_utils.apply_gradient_clipping`, which recomputes the same norm inside
`optax.clip_by_global_norm` -- a second full pass over every gradient plus a
second cross-replica reduction. `_clip_by_grad_norm` applies optax's own scale
to the norm already in hand. It is also the more accurate of the two: the
engine's norm is computed in float32 because a sum of squares over bf16
overflows on production-size models, while optax's runs in the gradients'
dtype, so under `grad_dtype=bfloat16` the engine was reporting a guarded norm
and clipping with an unguarded one. At the default `grad_dtype=float32` the
two are the same number. The fp8 path keeps the optax call, which holds
`OVERWRITE_WITH_GRADIENT` out of both the norm and the scaling.

qwen3-0.6b on 4x v6e, dp=4, per-device batch 2, seq 1024, adamw, bf16 compute
and fp32 weights; median steady-state step over 9 post-warmup steps:

  GA=8 unrolled            878.4 ms -> 826.1 ms  (-6.0%)
  GA=8 unrolled, no clip   878.4 ms -> 836.7 ms  (-4.7%)
  GA=1 unrolled            144.0 ms -> 143.8 ms  (no change)
  GA=8 scanned             852.2 ms -> 850.4 ms  (no change)

Losses are bit-identical across all 12 steps of every pair.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request optimizes the MaxText training engine by deferring the publication of pure state changes to live NNX objects, thereby avoiding expensive module graph walks on the step path. It also introduces a custom gradient clipping method (_clip_by_grad_norm) that reuses the pre-computed gradient norm to eliminate redundant passes and cross-replica reductions. The feedback suggests addressing a potential division-by-zero or NaN propagation issue in JAX when the gradient norm is exactly zero by using a safe division idiom, and adding a corresponding unit test case to verify robustness.

Comment on lines +1082 to +1086
threshold = self._config.gradient_clipping_threshold
if maxtext_utils.OVERWRITE_WITH_GRADIENT in grads:
return maxtext_utils.apply_gradient_clipping(grads, None, threshold)
scale = jnp.where(grad_norm < threshold, 1.0, threshold / grad_norm)
return jax.tree.map(lambda g: g * scale.astype(g.dtype), grads)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential division-by-zero warnings or undefined behavior in JAX when grad_norm is 0.0 (e.g., when gradients are all zero), it is safer to use a standard JAX idiom that replaces the denominator with a dummy non-zero value in the unselected branch of jnp.where. Even though jnp.where selects the 1.0 branch when grad_norm < threshold, both branches are evaluated, which can trigger division-by-zero or NaN/inf propagation under certain compiler configurations.

Suggested change
threshold = self._config.gradient_clipping_threshold
if maxtext_utils.OVERWRITE_WITH_GRADIENT in grads:
return maxtext_utils.apply_gradient_clipping(grads, None, threshold)
scale = jnp.where(grad_norm < threshold, 1.0, threshold / grad_norm)
return jax.tree.map(lambda g: g * scale.astype(g.dtype), grads)
threshold = self._config.gradient_clipping_threshold
if maxtext_utils.OVERWRITE_WITH_GRADIENT in grads:
return maxtext_utils.apply_gradient_clipping(grads, None, threshold)
safe_grad_norm = jnp.where(grad_norm < threshold, 1.0, grad_norm)
scale = jnp.where(grad_norm < threshold, 1.0, threshold / safe_grad_norm)
return jax.tree.map(lambda g: g * scale.astype(g.dtype), grads)

exercised by a given gradient tree.
"""
t = maxtext_engine.MaxTextTrainingEngine(self.mock_config)
for scale, case in ((100.0, "above the threshold"), (1e-3, "below the threshold")):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case for 0.0 (zero gradient) to verify that the gradient clipping logic handles zero gradients robustly without division-by-zero or NaN propagation.

Suggested change
for scale, case in ((100.0, "above the threshold"), (1e-3, "below the threshold")):
for scale, case in ((100.0, "above the threshold"), (1e-3, "below the threshold"), (0.0, "zero gradient")):

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/training_engine/maxtext_engine.py 84.61% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

…ange

`eval_step` is the one step path that reads real values off the live model rather
than the pure mirror, so it is the one place a deferred publish is observable --
and it does not observe stale weights, it raises: `update()` donates the state it
hands the update kernel, so the parameters `eval_step` splits out of the live
model are deleted arrays. `_compile_eval_for_batch` splits them too, to take its
shardings, so it syncs as well rather than leaning on its only caller.

The regression test goes after an `update()` deliberately. The pre-existing eval
tests call `eval_step` on a freshly compiled engine, which has never deferred
anything, which is why this got through them.

Also documents why one write to `self._state` discharges both deferral sites, and
why the three mirror fields have to keep being set and cleared as a group -- the
deferral is gated on `_params_pure` and the flush on `_state_pure`, so they are
only equivalent for as long as that holds.

`_clip_by_grad_norm` moves to its own PR. It is unrelated to deferring the
publish, and it is a numerical change rather than a pure optimization -- under
`grad_dtype=bfloat16` it clips with the float32 norm where optax clipped with a
bf16 one -- so it should be reviewed on its own terms.
@NuojCheng

Copy link
Copy Markdown
Collaborator Author

Pushed 8fac116 with two changes.

1. eval_step did not sync, and that was a real bug

eval_step is the one step path that reads real values off the live model rather than the
pure mirror, so it is the one place the deferral is observable -- and it does not observe
stale weights, it raises. update() donates the state it hands the update kernel, so the
parameters eval_step splits out of the live model are deleted arrays:

RuntimeError: Array has been deleted with shape=float32[2].
  maxtext_engine.py:1639 in eval_step -> self._compiled_eval(params, rest, dynamic_batch)

Four calls reproduce it on the mocked config: compile -> fwd_bwd -> update -> eval_step.
_compile_eval_for_batch splits the live model too, to take its shardings, so it syncs as
well rather than leaning on its only caller.

The new test_eval_after_update_flushes_a_deferred_publish fails with exactly the error
above on ec6a698 and passes on 8fac116. It sits after an update() deliberately -- the
pre-existing eval tests call eval_step on a freshly compiled engine, which has never
deferred anything, which is why they missed this.

I audited every other reader of self._model / self._state: these two were the only
unsynced ones.
The rest are writes, or touch the live objects only in the
_*_pure is None branch where _publish_to_live is already eager. The invariant holds
everywhere else.

Also documented, no behaviour change: why one write to self._state discharges both
deferral sites (_publish_model_rest has already folded fwd_bwd's rest into
_state_pure, and the live model is the object the state holds under _MODEL_STATE_KEY),
and why the three mirror fields have to keep being set and cleared as a group -- the
deferral gates on _params_pure and the flush on _state_pure, so they are equivalent
only for as long as that holds.

2. _clip_by_grad_norm moved to #5143

It is unrelated to deferring the publish, and it is a numerical change rather than a pure
optimization: under grad_dtype=bfloat16 it clips with the float32 norm where optax clipped
with a bf16 one. That deserves review on its own terms instead of riding along here.

@gemini-code-assist's division-by-zero comment goes with it and is addressed there --
worth noting it turned out to be the right call in the opposite direction: optax is the
side that fails, since clip_by_global_norm divides unguarded and relies on lax.select
to discard the NaN, so JAX_DEBUG_NANS stops on it for an all-zero gradient tree. The
guarded version removes a hazard rather than avoiding one it would have added.

Perf

Separately benchmarked on qwen3.5-35b-a3b (v7-8, ep=8, GA=8, bf16, seq 1024): no
measurable throughput change
, which is the expected result and not an argument against
the PR. It removes ~4.4 ms/step of host Python (update(model) 0.9 + update(state) 3.5)
from a step that is 99.55% device-bound with the 2-deep InflightThrottler already hiding
it. The saving is real in the regime this targets -- low GA on a small model, the qwen3-0.6b
numbers in the _publish_to_live docstring -- which that sweep does not cover.

Full suite: 55 passed on CPU.

@NuojCheng

Copy link
Copy Markdown
Collaborator Author

End-to-end against tunix HEAD, on qwen3.5-35b-a3b

Ran this branch against a checkout of tunix HEAD 139c3a9 (not the pinned 1b0e3c5e8,
and not the installed google-tunix 0.1.8), on a v7-8 — 8 devices.

Config: qwen3.5-35b-a3b, --scan, ep=8, ring-of-experts + ragged-sort, GA=8, bfloat16,
seq 1024, adamw, 23 steps, remat=none. Engine arms additionally run 4 eval_step calls
after the training loop, which is the path this PR changes.

arm trainer shard_mode step median peak HBM eval after update()
H1 MaxTextTrainingEngine auto 4043.8 ms 61.39 G 4 steps OK — 1327.4 ms median
H2 tunix PeftTrainer v2 auto 4045.4 ms 52.37 G
H3 MaxTextTrainingEngine explicit 4185.3 ms 61.40 G 4 steps OK — 139.9 ms median
H4 tunix PeftTrainer v2 explicit 4187.8 ms 52.39 G

(n=19 after dropping the first 3 steps; H1 mean 4043.9 / min 4040.9 / max 4049.0. Eval
medians exclude the first call, which carries the eval compile — 28.2 s auto, 21.6 s explicit.)

Engine vs. PeftTrainer is a dead heat on step time — 0.04% apart in auto, 0.06% in
explicit — and the engine costs ~9 G more HBM. Same conclusion as on the pinned tunix, which
is the expected result: peft_trainer_v2.py and sft/peft_trainer.py are byte-identical
between 0.1.8 and HEAD. The only delta in the modules this path touches is datatypes.py, and
it is purely additive (format_traj_id, a traj_id property, max_response_length).

Against the same sweep on the pinned tunix: engine/auto was 4042.0 ms / 61.39 G and
peft/explicit 4188.2 ms / 52.39 G. So tunix HEAD introduces no regression for this engine,
and no import or API break — maxtext_engine imports clean against it.

The counterfactual, on this model

This PR's eval_step fix is not a stale-weights guard, it is a crash fix. With the two
_sync_live_objects() calls removed from eval_step and _compile_eval_for_batch and
everything else on this branch unchanged, the same qwen3.5-35b run dies on the first eval:

File ".../maxtext_engine.py", line 1624, in eval_step
    loss, aux = self._compiled_eval(params, rest, dynamic_batch)
File ".../jax/_src/array.py", line 606, in _check_if_deleted
RuntimeError: Array has been deleted with shape=bfloat16[2048].

update() donates state_pure to the update kernel, so an unsynced live model holds
deleted arrays, not merely stale ones. Restoring the two syncs makes the identical run
pass — that is the H1/H3 rows above.

One thing this surfaced that is not in this PR

H3 initially failed outright. Under shard_mode=explicit the engine's eval_step raises
ValueError: Using PartitionSpec when you are not under a mesh context is not allowed,
because the eval path never enters _sharding_ctx() while fwd_bwd and update both do.

That is pre-existing on main and unrelated to the deferral — it is fixed separately in
#5144, which is what the H3 row above is measured with. Flagging it here only because it
lives on the same method, so the two want reviewing together.

Repro

PYTHONPATH=<tunix-head-checkout>:<maxtext>/src \
python tests/end_to_end/tpu/perf_parity/engine_profile.py \
  --model qwen3.5-35b-a3b --scan --seq 1024 --opt adamw --dtype bfloat16 --ga 8 \
  --ring-of-experts --ragged-sort --ep 8 --steps 23 --no-trace --eval-steps 4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant