Skip to content

Make shard_optimizer_over_data (Zero-1) work in MaxTextTrainingEngine - #5104

Merged
NuojCheng merged 1 commit into
engine-ga-unreducedfrom
engine-zero1
Sep 2, 2026
Merged

Make shard_optimizer_over_data (Zero-1) work in MaxTextTrainingEngine#5104
NuojCheng merged 1 commit into
engine-ga-unreducedfrom
engine-zero1

Conversation

@NuojCheng

@NuojCheng NuojCheng commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #5099 (base engine-ga-unreduced).

What

shard_optimizer_over_data (Zero-1) is a silent no-op in MaxTextTrainingEngine. It is read only by gradient_accumulation.py, which the engine does not go through, so setting it allocates a fully replicated optimizer and says nothing about it.

This makes it work. It matters here specifically because Zero-1 is what makes #5099 usable: the deferred all-reduce needs data to be the sole batch axis, which rules out FSDP — and Zero-1 is already mutually exclusive with FSDP by config validation (types.py:4727). So Zero-1 is the memory story you reach for when the whole point of the mesh is data parallelism, and the two compose into one cross-replica reduction per optimizer step, on 1/N of the optimizer.

How

nnx.Optimizer allocates its moments eagerly, as zeros_like of each parameter, so they inherit the parameter layout. Resharding them once — before _compile_for_batch reads the state's layout back off the arrays — is all it takes for the rest of the engine to follow, since the update kernel's in/out shardings are derived from exactly those arrays.

_update_kernel then does two reshards in opposite directions:

  • down, so the gradients and the parameters meet the moments on the sharded layout. Slicing a replicated array is local, and it is what puts the optimizer's arithmetic and the memory traffic under it on 1/N of every parameter;
  • up, once the step is applied, because the forward pass needs whole parameters. This is the one all-gather Zero-1 costs. The moments stay behind, sharded.

The gradient reshard is the same one that discharges #5099's unreduced tag, which is why the two cost one collective between them rather than two.

Nothing else moves. No kernel signature changes, the parameters cross every jit boundary replicated exactly as before, and the checkpoint format is untouched — _reduced_accumulated_grads still writes a plain replicated total.

Placement goes through the pre-train path's own sharding.add_data_to_sharding, one function of (shape, base sharding), applied to the parameters, the gradients and the moments alike. That is what makes the three agree without matching up two pytrees: a moment mirrors its parameter's shape and starts from its layout, so it lands on the same spec. A leaf with no dimension the data axis divides — a scalar count, an odd-sized bias — comes back unchanged and stays replicated, on all three trees.

Results — qwen3-0.6b on 4× v6e

data=4, fsdp=1, micro-batch 8×1024, shard_mode=explicit, adamw (sgd carries no parameter-shaped state, so there would be nothing to shard), no clipping, remat=none. Median steady-state step over ~19 post-warmup steps, untraced; HBM is the max over the 4 devices.

GA arm step peak HBM live HBM
8 baseline 593.1 ms 12.39 GiB 7.93 GiB
8 defer (#5099) 437.6 ms 12.34 GiB 7.88 GiB
8 zero1 597.3 ms 9.08 GiB 4.62 GiB
8 defer + zero1 437.5 ms 9.01 GiB 4.55 GiB
4 baseline 304.6 ms 12.39 GiB 7.93 GiB
4 defer + zero1 238.1 ms 9.01 GiB 4.55 GiB
1 baseline 91.1 ms 9.53 GiB 7.30 GiB
1 defer + zero1 90.5 ms 7.78 GiB 3.96 GiB

Zero-1 is time-neutral under accumulation — its all-gather is once per step and the micro-batches amortize it — and returns 3.3 GiB per device, which is 3/4 of the two f32 moments over 596M parameters, as expected. At GA=1 it costs 1.5–5 ms, where there is nothing to amortize it over.

The two are independent and stack: the deferral buys the time, Zero-1 buys the memory, and neither takes back what the other gave.

Losses with Zero-1 alone are bit-identical to the baseline at every GA — every operation adamw applies is elementwise in the parameter, so splitting the tensor across replicas changes nothing. All the divergence in the combined arm is #5099's float32 reassociation (≤ 9.3e-5 relative, max over GA ∈ {1, 4, 8}).

Collectives, from the optimized HLO on the CPU mesh:

kernel baseline defer zero1 defer + zero1
first_kernel / accum_kernel (per micro-batch) 1 all-reduce 1 all-reduce
_update_kernel (per step) 1 all-reduce 21 all-gather 1 all-reduce + 21 all-gather

Results — Zero-1 + explicit + GA=8 vs. pure tensor parallelism

Same rig, same 4× v6e, GA=8 throughout, global batch 64×1024. per_device_batch_size is 8/4 = 2 in every arm, so the micro-batch is 8×1024 no matter how the mesh is cut and all seven arms do identical work.

arm mesh step tok/s peak HBM live HBM trace
defer + zero1 dp4 437.0 ms 150k 9.01 GiB 4.55 GiB …-zero1-ga8-dp4fsdp1tp1-explicit/…/2026_09_02_16_36_51/
defer, no zero1 dp4 437.6 ms 150k 12.34 GiB 7.88 GiB …-ga8-dp4fsdp1tp1-explicit/…/2026_09_02_16_39_09/
neither dp4 593.1 ms 110k 12.39 GiB 7.93 GiB …-ga8-dp4fsdp1tp1-explicit-nodefer/…/2026_09_02_16_41_37/
pure TP, explicit tp4 874.3 ms 75k 3.76 GiB 2.64 GiB …-ga8-dp1fsdp1tp4-explicit/…/2026_09_02_16_44_20/
pure TP, auto tp4 886.1 ms 74k 3.60 GiB 2.62 GiB …-ga8-dp1fsdp1tp4/…/2026_09_02_16_46_46/
hybrid + zero1 dp2 tp2 766.7 ms 85k 5.68 GiB 3.45 GiB …-zero1-ga8-dp2fsdp1tp2-explicit/…/2026_09_02_16_34_07/
hybrid, no zero1 dp2 tp2 759.7 ms 86k 6.77 GiB 4.54 GiB …-ga8-dp2fsdp1tp2-explicit/…/2026_09_02_16_31_11/

Trace paths are relative to gs://chengnuojin-xprof/engine-zero1-vs-tp/, and each ends in plugins/profile/<timestamp>/t1v-n-76d392d5-w-0.xplane.pb (the above elides qwen3-0.6b-engine-adamw at the front and plugins/profile in the middle). Point xprof at the arm directory.

Neither strategy dominates: Zero-1 + DP is 2.0× faster, pure TP holds 1.7× less live HBM. The per-kernel medians say where it comes from — TP's fwd_bwd is 97.4 ms per micro-batch against Zero-1's 43.3 ms, because TP all-reduces activations inside every layer of every micro-batch and accumulation cannot amortize that; update() is much closer (139.6 vs 80.9 ms). At this size TP is buying memory nobody needed: 596M f32 parameters is 2.2 GiB, and the whole Zero-1 arm fits in 4.55 GiB live against v6e's 32 GiB.

Hybrid dp2 × tp2 is the worst of the three. It pays TP's in-layer collectives and collects only half of Zero-1's saving (1.09 GiB live, exactly the 2-way shard of the moments), landing at 85k tok/s.

Numerics hold across the two strategies: pure TP and defer + zero1 track to 8.7e-5 max relative difference over 23 steps (247.0099 → 169.1138 vs. 247.0115 → 169.1284), which is float32 reassociation between two completely different shardings.

Read live for the memory comparison, not peak: peak_bytes_in_use is a high-water mark since process start and so includes compilation transients that vary run to run (the traced re-runs above report the same live to the centibyte and 1.2–2.2 GiB less peak). The Zero-1 saving is exactly 3.33 GiB of live in both, which is 3/4 of the two f32 moments over 596M parameters.

Timings are from untraced runs; the traces are separate runs of the same arms, 5–8% slower for being watched.

Dependency: #5099 needed a fix to survive this

--dp 2 --tp 2 did not run at all at first. The deferral's gate refused a second mesh axis on the batch dimension, which caught fsdp and missed tensor reaching the same contradiction through the feature dimension:

ShardingTypeError: out_sharding's unreduced axes should be equal to the contracting specs.
Got unreduced axes=frozenset({'data'}) and contracting spec=('data', None, 'tensor')

Not a Zero-1 bug — it reproduced with --zero1 off — so the fix is a commit on the base branch (#5099), which now declines on any non-data mesh axis above size 1 rather than enumerating the axes known to break. Zero-1 itself is fine alongside TP, as the two dp2 × tp2 rows show.

A real bug in add_data_to_sharding

if "data" in jax.tree.leaves(pspec):   # never true
  return sharding

A PartitionSpec is a pytree leaf, so flattening one gives back the spec itself and this guard never fired. A leaf already sharded over data got a second one, and NamedSharding rejects the result outright:

DuplicateSpecError: ... P(('data', 'data'), None) has duplicate entries for `data`

That is exactly what a recompile under Zero-1 hits — _compile_for_batch re-places the moments, which are already sharded by then. Fixed to walk tuple(pspec). The pre-train path has not hit it because its parameters are sharded over fsdp/tensor rather than data.

When it engages

_zero1_active returns a reason, or None when Zero-1 can run. It declines unless shard_mode=explicit, the mesh's axis types are all Explicit, and the mesh has a data axis of size > 1 — under auto the reshards are hints GSPMD may ignore, which would give a silently replicated optimizer again, i.e. the bug this fixes.

When it declines something that was asked for, it now says so, once per engine instance. The failure mode this replaces was silence.

Tests

tests/post_training/unit/maxtext_engine_zero1_test.py, 23 cases on a 4-device CPU mesh, reusing #5099's tiny-real-decoder rig so both features are exercised on one config:

  • the gate opens on an explicit data-parallel mesh and declines with the flag off, under auto shard mode, on an Auto-axis mesh, with no data replicas, and with no mesh;
  • _zero1_sharding places one leaf: adds the axis to the first dimension that divides, skips one that does not, leaves scalars and unshardable shapes alone, and leaves a leaf already sharded over data alone (this one fails without the add_data_to_sharding fix);
  • the moments come back sharded and each device holds 1/N of them, with the mirror-image assertion that they do not when the flag is off;
  • the parameters themselves stay replicated — Zero-1, not Zero-2/3;
  • Zero-1 adds all-gather volume to update() and none to either micro-batch kernel, measured as a difference against the same kernel compiled without it;
  • it composes with the deferral: no array all-reduce in the micro-batch kernels, one in update();
  • weights match a non-Zero-1 run, including with clipping on — l2norm_pytree sums squares over elements that are now spread across replicas, and a replica-local sum would come out low by a factor of N;
  • a recompile does not shard the already-sharded moments twice;
  • the moments survive a mid-step checkpoint round trip still sharded. Restoring does not recompile, so what Orbax hands back has to land where it left or the resumed step dies on an in_shardings mismatch.

Existing maxtext_engine_deferred_all_reduce_test.py + maxtext_engine_test.py + maxtext_engine_constructor_test.py + sharding_nnx_test.py: 92 passed.

Follow-ups, not in scope here

  • The pre-train path's Zero-1 + GA still all-reduces the gradients once per micro-batch. Confirmed from optimized HLO metadata (op_name="jit(train_step)/while/body/…"). The reduced/unreduced scaffolding in gradient_accumulation.py:73-86 is dead — both arms of the data_parallel_active branch assign the same thing, because scan carries reject those specs. So the engine with Defer the data-parallel gradient all-reduce to update() under gradient accumulation #5099 + this PR is currently the only place in MaxText where that reduction is actually deferred.
  • Reproducing the benchmark needs --zero1 and --opt added to the perf_parity rig from Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine #5060, which is not on this branch. The command was python qwen3_engine_profile.py --ga 8 --dp 4 --fsdp 1 --tp 1 --shard-mode explicit --opt adamw --no-trace [--zero1] [--no-defer], varying --dp/--fsdp/--tp for the mesh comparison and dropping --no-trace for the xplane files.

@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 implements the shard_optimizer_over_data (Zero-1) optimization in MaxTextTrainingEngine. It introduces logic to shard the optimizer's parameter-shaped state over the data axis, performs the optimizer update on these slices, and gathers the updated parameters back. It also fixes a bug in add_data_to_sharding where PartitionSpec was treated as a pytree leaf, preventing duplicate sharding. Additionally, a comprehensive suite of unit and integration tests has been added to verify the correctness, performance characteristics, and checkpoint compatibility of the Zero-1 implementation. There are no review comments, so I have no feedback to provide.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 26.92308% with 57 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/training_engine/maxtext_engine.py 27.27% 48 Missing and 8 partials ⚠️
src/maxtext/utils/sharding.py 0.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

The flag was read only by `gradient_accumulation.py`, which the engine does
not go through, so setting it here allocated a fully replicated optimizer and
said nothing about it.

`nnx.Optimizer` builds its moments eagerly as `zeros_like` of each parameter,
so they inherit the parameter layout. Moving them onto the data axis once,
before `_compile_for_batch` reads the state's shardings back off the arrays,
is enough for the rest of the engine to follow -- the update kernel's in/out
shardings are derived from exactly those arrays.

`_update_kernel` then reshards the gradients and the parameters onto that
layout, and gathers the new parameters back on the way out. Nothing else
changes: no kernel signature moves, and the parameters cross every jit
boundary replicated as before. The gradient reshard is the same one that
discharges the deferral's `unreduced` tag, so the two compose -- one
cross-replica reduction per optimizer step, on 1/N of the optimizer.

`add_data_to_sharding`'s "already present" guard never fired: a PartitionSpec
is a pytree leaf, so `jax.tree.leaves(pspec)` gives back the spec. A leaf
already sharded over "data" got a second one and `NamedSharding` rejected it
(`DuplicateSpecError`), which is what a recompile under Zero-1 would hit.

qwen3-0.6b on 4x v6e, dp=4, adamw, micro-batch 8x1024, median steady-state
step over ~19 post-warmup steps:

  GA  arm                  step      peak HBM   live HBM
   8  baseline            593.1ms    12.39 GiB  7.93 GiB
   8  defer               437.6ms    12.34 GiB  7.88 GiB
   8  zero1               597.3ms     9.08 GiB  4.62 GiB
   8  defer + zero1       437.5ms     9.01 GiB  4.55 GiB

Zero-1 is time-neutral under accumulation (the all-gather is once per step,
amortized over the micro-batches) and returns 3.3 GiB per device. At GA=1 it
costs 1.5-5ms, where there is nothing to amortize it over.

Losses with Zero-1 alone are bit-identical to the baseline across all 9 steps
at every GA -- the optimizer's arithmetic is elementwise, so splitting the
tensor changes nothing. The deferral's float32 reassociation accounts for all
the divergence in the combined arm (<= 9.3e-5 relative).
)


_ZERO1_DECLINED_WARNING = (

@gobbleturk gobbleturk Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should error instead of warn when a user setting cannot be applied

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

agree

return
self._params_pure, self._rest_pure, self._state_pure = params_pure, rest_pure, new_state_pure

def _note_zero1_declined(self, reason: str) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

error instead


return jax.tree.map(target, params_pure, params_shardings)

def _shard_optimizer_state_over_data(self) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this function is surprising, I think the optimizer should always be sharded, we only need to construct such a sharding spec? Are we expecting this is a no-op?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

optimizer states never gets sharded by "data". This function enforce sharding optimizer states using "data" axis for zero-1. We have similar function for maxtext pre-train.

local_state = nnx.merge(self._state_graphdef, state_pure, copy=True)
if hasattr(local_state, "apply_gradients"):
if self._config.skip_step_on_spikes:
local_state.apply_gradients(grads, loss=mean_loss, grad_norm=grad_norm)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

woah this is very surprising to me, I guess this is existing code but I thought fwd_bwd only was meant to compute gradients, and applying them was dedicated to separate API like update

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

indeed fwd_bwd only computes/accumulates gradients in tunix http://google3/third_party/py/tunix/experimental/train/peft_trainer_v2.py;l=586;rcl=974880264

self._reduced_params_shardings: Any = None
self._unreduced_grad_shardings: Any = None
self._plain_grad_shardings: Any = None
# Set together by `_compile_for_batch` when Zero-1 is on: the parameters as

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am very surprised this can be implemented without touching the _compile_for_batch function http://google3/third_party/py/maxtext/src/maxtext/training_engine/maxtext_engine.py;l=738;rcl=974852334

I would think we need two separate functions fwd_bwd and fwd_bwd_unreduced, or at least we need to modify the call signature of fwd_bwd to return unreduced gradients. However I may have the signature of fwd_bwd incorrect, does it not return gradients?

@NuojCheng
NuojCheng merged commit 6605358 into engine-ga-unreduced Sep 2, 2026
128 of 131 checks passed
@NuojCheng
NuojCheng deleted the engine-zero1 branch September 2, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants