Skip to content

Onboard explicit sharding to the Gemma models - #4996

Open
NuojCheng wants to merge 3 commits into
mainfrom
gemma-explicit-sharding
Open

Onboard explicit sharding to the Gemma models#4996
NuojCheng wants to merge 3 commits into
mainfrom
gemma-explicit-sharding

Conversation

@NuojCheng

@NuojCheng NuojCheng commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Onboard explicit sharding to the Gemma models

Description

Adds shard_mode=explicit support to the Gemma 1 / 2 / 3 decoder blocks, and makes the combination ZeRO-1 + gradient accumulation + explicit sharding work end to end (previously it raised a ShardingTypeError for every model, including the already-onboarded llama2 and deepseek).

Under shard_mode=auto, a missing sharding annotation costs nothing — GSPMD infers a layout. Under shard_mode=explicit, JAX type-checks the sharding of every operation, so the same omission is a hard error and every intermediate has to be named. This PR follows the pattern established for llama2 in #2470 and #2783:

  • nn.with_logical_constraint(...)maybe_shard_with_logical(..., shard_mode=...)
  • sub-modules (RMSNorm, Attention, MlpBlock) are handed an explicit out_sharding (and intermediate_sharding for the MLP) instead of relying on inference.

All of it is a no-op under shard_mode=auto: maybe_shard_with_logical falls back to jax.lax.with_sharding_constraint, and RMSNorm.__call__ drops out_sharding when the mode is not explicit.

Key changes

  1. Gemma 1/2/3 decoder blocks annotate every activation and pass out_sharding / intermediate_sharding down to their sub-modules; gemma, gemma2 and gemma3 are added to the explicit-sharding allowlist in configs/types.py.
  2. ZeRO-1 + explicit sharding is fixed framework-wide. With shard_optimizer_over_data the optimizer moments are additionally sharded over the data axis. auto reconciles the mismatch silently; explicit refuses to add a gradient sharded P('fsdp') to a moment sharded P(('data','fsdp')). A new sharding.get_grad_shardings() returns the layout the gradients must reach the optimizer in, and both the gradient-accumulation path and the single-microbatch path now target it.
  3. QK/V-norm under tensor parallelismgemma3-4b with ici_tensor_parallelism=2 failed to compile because the QK-norm scale carries the generic ("norm",) -> ["tensor"] rule while the heads dimension is already tensor-sharded. Naming the normed output layout fixes it.

Detailed changes by file

File Change
src/maxtext/models/gemma.py maybe_shard_with_logical for every activation; out_sharding on both norms, attention and the MLP; intermediate_sharding on the MLP; shard_mode threaded into RMSNorm.
src/maxtext/models/gemma2.py Same, for both the local and the global halves of the layer, including the post-attention / post-FFW norms.
src/maxtext/models/gemma3.py Same, for Gemma3DecoderLayer and Gemma3ScannableBlock.
src/maxtext/configs/types.py gemma, gemma2, gemma3 added to the explicit-sharding allowlist.
src/maxtext/layers/attentions.py QK-norm and V-norm outputs are given the query/key/value axis layout explicitly, so explicit sharding gathers the norm scale instead of rejecting the einsum. No-op under auto.
src/maxtext/utils/sharding.py New get_grad_shardings(); _extract_param_only() hoisted to module level so it can be shared; truncate_out_sharding() now preserves unreduced / reduced annotations instead of raising on them.
src/maxtext/utils/gradient_accumulation.py New out_grad_shardings argument (defaults to params_shardings): resolving the unreduced annotation against it emits the cross-replica combine straight into the optimizer's layout — a single reduce-scatter over data rather than an all-reduce plus a slice.
src/maxtext/trainers/pre_train/train.py Computes grad_shardings once and passes it to GA; reshards gradients on the single-microbatch path; under ZeRO-1 + explicit, no longer writes the all-gathered params back into state.model (which would hand the optimizer params and moments in disagreeing layouts). The parameter_memory_host_offload device_put targets grad_shardings rather than params_shardings, so moving gradients back to device memory no longer relayouts them out of the ZeRO-1 layout.

Tests

New:

  • tests/unit/gemma_layers_test.py — runs one forward pass per Gemma layer family in both shard modes on an 8-device CPU mesh and asserts the activations match. Verified to fail on all four layer families when the model changes are reverted.
  • tests/unit/sharding_nnx_test.py::TestGetGradShardings — the five branches of get_grad_shardings, including that the returned tree matches the structure of nnx.split(model, nnx.Param, ...).
  • tests/unit/sharding_nnx_test.pytruncate_out_sharding keeps unreduced / reduced.
  • tests/unit/gradient_accumulation_nnx_test.py::TestGradientAccumulationOutGradShardingsout_grad_shardings defaults to params_shardings, overrides the returned layout, and does not change the gradient values.
  • tests/integration/train_tests.py::test_tpu_gemma_zero1_gradient_accumulation_explicit — gemma / gemma2 / gemma3 trained for 3 steps with ZeRO-1 + GA + explicit sharding on TPU, plus a gemma-host-offload subtest covering parameter_memory_host_offload=True on the same combination.

Existing tests/unit/sharding_nnx_test.py, tests/unit/gradient_accumulation_nnx_test.py and tests/unit/sharding_test.py pass unchanged.

End-to-end runs (TPU v7-8, 4 Ironwood chips / 8 devices)

Losses under explicit match auto to 3 decimals over 20 steps on gemma-2b, and in every smoke run across gemma-2b, gemma2-2b, gemma2-9b, gemma3-4b, gemma3-12b — with and without tensor parallelism, ZeRO-1 and gradient accumulation.

Step time, gemma-2b (2.506 B params), per_device_batch_size=1, max_target_length=2048, ici_data_parallelism=2, ici_fsdp_parallelism=4:

Config auto explicit Δ
ZeRO-1, no GA (pdb=4) 0.358 s / 354.3 TFLOP/s/device 0.331 s / 383.7 TFLOP/s/device −7.5 %
GA=4, no ZeRO-1 0.665 s / 190.9 0.681 s / 186.5 +2.4 %
ZeRO-1 + GA=4 0.694 s / 183.0 0.712 s / 178.1 +2.6 %

Peak memory after parameter init is identical in both modes (5.87 GB / 94.75 GB per device).

With parameter_memory_host_offload=True on top of ZeRO-1 + GA, explicit and auto agree to 3 decimals as well (10.879 / 10.869 / 10.863–4 over the first three steps).

Known gap. With gradient accumulation, explicit is 1–3 % slower than auto. The HLO shows explicit emitting the nine intended data-axis reduce-scatters for the scanned parameters, plus a duplicated data-axis reduction of the ~262 MB tied-embedding gradient (one plain all-reduce and one all-reduce-scatter lowered as pad + all-reduce + slice). The gap scales with vocabulary size, lives in the shared embedding/logits code rather than in any Gemma layer, and reproduces identically on llama2-7b (1.887 s auto vs 1.912 s explicit with ZeRO-1 + GA=4), which was onboarded before this PR. It is therefore tracked as a follow-up rather than fixed here.

Unrelated pre-existing issue. parameter_memory_host_offload=True combined with shard_optimizer_over_data=False fails with memory_space of all inputs passed to add must be the same — identically under shard_mode=auto, so it is not caused by this PR and is left alone.

Platform note. ici_fsdp_parallelism=1 (data-parallel only) with ZeRO-1 + GA segfaults the TPU7x compiler for llama2 and Gemma alike, with and without this change — that is b/517509898, which the existing llama2 test already skips for. The new Gemma integration test uses ici_data_parallelism=2 with FSDP filling the remaining devices, which runs cleanly.

Checklist

  • Tests added for the new behaviour
  • Existing unit tests pass
  • End-to-end training verified on TPU for all three Gemma families
  • Numerical parity with shard_mode=auto verified
  • pyink and pylint clean on the changed files
  • No behaviour change under the default shard_mode=auto

@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 enables explicit sharding support for Gemma, Gemma2, and Gemma3 models under ZeRO-1 and gradient accumulation by explicitly propagating sharding layouts through attention, MLP, and normalization layers, and introducing get_grad_shardings to align gradient layouts with optimizer moments. Feedback points out that mlp_intermediate_sharding in the Gemma models hardcodes "activation_length" instead of using self.activation_axis_names[1], which will cause a ShardingTypeError under explicit sharding. Additionally, a potential sharding mismatch is identified in train.py when parameter_memory_host_offload is enabled alongside ZeRO-1 and explicit sharding.


mlp_lnx = self.mlp(attn_output, deterministic=deterministic)
mlp_lnx = nn.with_logical_constraint(mlp_lnx, self.activation_axis_names)
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The mlp_intermediate_sharding is hardcoded to use "activation_length" as the sequence/length dimension. However, the layer's activations (and self.activation_axis_names) use "activation_norm_length".

Under shard_mode=explicit, JAX enforces strict sharding type matching. If the intermediate sharding specifies "activation_length" while the input attn_output uses "activation_norm_length", JAX will either raise a ShardingTypeError or perform an unnecessary and expensive resharding (all-to-all) to align the dimensions. To ensure consistency and avoid sharding mismatches, mlp_intermediate_sharding should use self.activation_axis_names[1] instead of the hardcoded "activation_length".

Suggested change
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", self.activation_axis_names[1], "activation_mlp"))

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.

Thanks, but I don't think this one is right — the hardcoded activation_length is deliberate, and the suggested change would silently drop context parallelism from the MLP intermediate.

The intermediate is [batch, seq, mlp], and activation_mlp maps to ['tensor', 'tensor_sequence']. activation_norm_length maps to ['tensor_sequence', 'context', 'context_usp_ulysses'] — it shares tensor_sequence with activation_mlp, which is exactly the point: with tensor-sequence parallelism the normed activation shards its sequence dim because its embed dim is replicated, whereas the MLP intermediate already shards tensor_sequence on the mlp dim, so its sequence dim must not. activation_length (['context', 'context_usp_ulysses']) is the correct partner axis.

Resolving both against the rule table shows what the suggestion actually does:

current  : P(('data','fsdp','fsdp_transpose','expert'), ('context','context_usp_ulysses'), ('tensor','tensor_sequence'))
suggested: P(('data','fsdp','fsdp_transpose','expert'), None,                              ('tensor','tensor_sequence'))

The sequence dim goes from context-sharded to replicated, because tensor_sequence is already consumed by activation_mlp and flax then drops the whole rule entry. That is a real regression under context parallelism, not a fix.

This also matches the two models already onboarded to explicit sharding: llama2.py:192 and deepseek.py:83 use the same ("activation_batch", "activation_length", "activation_mlp") triple. Empirically there is no ShardingTypeError — the Gemma parity test (tests/unit/gemma_explicit_sharding_test.py) and the TPU integration test both pass as written, and gemma3-4b was verified end to end with ici_tensor_parallelism=2.

Leaving as is.

inputs = self._maybe_shard_with_logical(inputs, self.activation_axis_names)
inputs = checkpoint_name(inputs, "decoder_layer_input")
lnx_sharding = create_sharding(self.mesh, self.activation_axis_names)
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The mlp_intermediate_sharding is hardcoded to use "activation_length" as the sequence/length dimension. However, the layer's activations (and self.activation_axis_names) use "activation_norm_length" (or "prefill_activation_norm_length" in prefill mode).

Under shard_mode=explicit, JAX enforces strict sharding type matching. If the intermediate sharding specifies "activation_length" while the input attn_output uses "activation_norm_length" (or "prefill_activation_norm_length"), JAX will either raise a ShardingTypeError or perform an unnecessary and expensive resharding (all-to-all) to align the dimensions. To ensure consistency and avoid sharding mismatches, mlp_intermediate_sharding should use self.activation_axis_names[1] instead of the hardcoded "activation_length".

Suggested change
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", self.activation_axis_names[1], "activation_mlp"))

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.

Thanks, but I don't think this one is right — the hardcoded activation_length is deliberate, and the suggested change would silently drop context parallelism from the MLP intermediate.

The intermediate is [batch, seq, mlp], and activation_mlp maps to ['tensor', 'tensor_sequence']. activation_norm_length maps to ['tensor_sequence', 'context', 'context_usp_ulysses'] — it shares tensor_sequence with activation_mlp, which is exactly the point: with tensor-sequence parallelism the normed activation shards its sequence dim because its embed dim is replicated, whereas the MLP intermediate already shards tensor_sequence on the mlp dim, so its sequence dim must not. activation_length (['context', 'context_usp_ulysses']) is the correct partner axis.

Resolving both against the rule table shows what the suggestion actually does:

current  : P(('data','fsdp','fsdp_transpose','expert'), ('context','context_usp_ulysses'), ('tensor','tensor_sequence'))
suggested: P(('data','fsdp','fsdp_transpose','expert'), None,                              ('tensor','tensor_sequence'))

The sequence dim goes from context-sharded to replicated, because tensor_sequence is already consumed by activation_mlp and flax then drops the whole rule entry. That is a real regression under context parallelism, not a fix.

This also matches the two models already onboarded to explicit sharding: llama2.py:192 and deepseek.py:83 use the same ("activation_batch", "activation_length", "activation_mlp") triple. Empirically there is no ShardingTypeError — the Gemma parity test (tests/unit/gemma_explicit_sharding_test.py) and the TPU integration test both pass as written, and gemma3-4b was verified end to end with ici_tensor_parallelism=2.

Leaving as is.


# MLP block.
mlp_lnx = self.mlp(attn_output, deterministic=deterministic)
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The mlp_intermediate_sharding is hardcoded to use "activation_length" as the sequence/length dimension. However, the layer's activations (and self.activation_axis_names) use "activation_norm_length" (or "prefill_activation_norm_length" in prefill mode).

Under shard_mode=explicit, JAX enforces strict sharding type matching. If the intermediate sharding specifies "activation_length" while the input attn_output uses "activation_norm_length" (or "prefill_activation_norm_length"), JAX will either raise a ShardingTypeError or perform an unnecessary and expensive resharding (all-to-all) to align the dimensions. To ensure consistency and avoid sharding mismatches, mlp_intermediate_sharding should use self.activation_axis_names[1] instead of the hardcoded "activation_length".

Suggested change
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", "activation_length", "activation_mlp"))
mlp_intermediate_sharding = create_sharding(self.mesh, ("activation_batch", self.activation_axis_names[1], "activation_mlp"))

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.

Thanks, but I don't think this one is right — the hardcoded activation_length is deliberate, and the suggested change would silently drop context parallelism from the MLP intermediate.

The intermediate is [batch, seq, mlp], and activation_mlp maps to ['tensor', 'tensor_sequence']. activation_norm_length maps to ['tensor_sequence', 'context', 'context_usp_ulysses'] — it shares tensor_sequence with activation_mlp, which is exactly the point: with tensor-sequence parallelism the normed activation shards its sequence dim because its embed dim is replicated, whereas the MLP intermediate already shards tensor_sequence on the mlp dim, so its sequence dim must not. activation_length (['context', 'context_usp_ulysses']) is the correct partner axis.

Resolving both against the rule table shows what the suggestion actually does:

current  : P(('data','fsdp','fsdp_transpose','expert'), ('context','context_usp_ulysses'), ('tensor','tensor_sequence'))
suggested: P(('data','fsdp','fsdp_transpose','expert'), None,                              ('tensor','tensor_sequence'))

The sequence dim goes from context-sharded to replicated, because tensor_sequence is already consumed by activation_mlp and flax then drops the whole rule entry. That is a real regression under context parallelism, not a fix.

This also matches the two models already onboarded to explicit sharding: llama2.py:192 and deepseek.py:83 use the same ("activation_batch", "activation_length", "activation_mlp") triple. Empirically there is no ShardingTypeError — the Gemma parity test (tests/unit/gemma_explicit_sharding_test.py) and the TPU integration test both pass as written, and gemma3-4b was verified end to end with ici_tensor_parallelism=2.

Leaving as is.

Comment on lines +529 to +535
if grad_shardings is params_shardings:
nnx.update(state.model, curr_params)
# Otherwise (ZeRO-1 + explicit sharding) leave state.model in the ZeRO-1 layout
# it was donated in. curr_params above is all-gathered over "data" for the
# forward/backward pass only; writing it back would hand the optimizer params
# sharded P('fsdp') alongside gradients and moments sharded P(('data', 'fsdp')),
# which explicit sharding rejects instead of silently reconciling.

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

When shard_optimizer_over_data (ZeRO-1) and shard_mode=explicit are active, grad_shardings is not params_shardings is True. Skipping the update to state.model with the all-gathered curr_params on line 530 correctly prevents a layout mismatch with the optimizer.

However, if parameter_memory_host_offload is also enabled, state.model is unconditionally updated on line 511 with curr_params (which is in the pre-Zero-1 layout params_shardings):

nnx.update(state.model, curr_params)  # ensure state.model has device params for optimizer update

This update overwrites the ZeRO-1 layout of state.model with the pre-Zero-1 layout. Since the update on line 530 is skipped, state.model is left in the pre-Zero-1 layout, while the optimizer moments and gradients are in the ZeRO-1 layout. This will cause a sharding mismatch under explicit sharding during the optimizer update. Consider conditionally updating state.model on line 511 only if grad_shardings is params_shardings (or handle the ZeRO-1 layout update correctly).

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.

Good catch — the combination is genuinely broken, though the mechanism turned out to be one step further down. Fixed in f58857b.

Reproduced first:

python -m maxtext.trainers.pre_train.train src/maxtext/configs/base.yml \
  decoder_block=gemma gradient_accumulation_steps=4 shard_optimizer_over_data=True \
  shard_mode=explicit parameter_memory_host_offload=True param_scan_axis=0 \
  ici_data_parallelism=2 ici_fsdp_parallelism=-1 ...

jax._src.core.ShardingTypeError: add got incompatible shardings for broadcasting:
  ('fsdp', None), (('data', 'fsdp'), None)

But dumping the shardings at apply_gradients showed it is the gradients, not state.model, that arrive in the wrong layout — every leaf was in the pre-ZeRO-1 layout despite grad_shardings being correct (P(('data','fsdp'), None) for logits_dense.kernel, memory kind device). The culprit is the host-offload device_put that runs after the ZeRO-1 relayout:

if config.parameter_memory_host_offload:
    raw_grads = jax.device_put(raw_grads, max_utils.with_memory_kind(params_shardings, "device"))

Only the memory kind should change there, but params_shardings carries the pre-ZeRO-1 layout too, so the device_put undoes the relayout the GA path had just performed. Targeting grad_shardings fixes it, and is a no-op elsewhere since grad_shardings is params_shardings whenever ZeRO-1 + explicit sharding is not active.

On the specific line you flagged: the nnx.update(state.model, curr_params) write-back is fine as written. With only the device_put fixed, gemma runs 3 clean steps with host offload + ZeRO-1 + GA + explicit, and the losses match auto to 3 decimals (10.879 / 10.869 / 10.863–4). Guarding that write-back as well was tried and is not needed — state.model is re-read from the state's own layout at the optimizer update.

One unrelated pre-existing issue surfaced while testing: parameter_memory_host_offload=True with shard_optimizer_over_data=False fails with memory_space of all inputs passed to add must be the same. Got one operand with type: float32<host>[256] and another operand with type: float32[256] — identically under shard_mode=auto, so it is not caused by this PR and is out of scope here.

Added gemma-host-offload as a fourth subtest of test_tpu_gemma_zero1_gradient_accumulation_explicit; all 4 subtests pass on TPU v7-8 in 33 s.

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.78351% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/layers/attentions.py 75.00% 1 Missing and 2 partials ⚠️
src/maxtext/trainers/pre_train/train.py 60.00% 1 Missing and 1 partial ⚠️
src/maxtext/utils/sharding.py 90.90% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Add shard_mode=explicit support to the Gemma 1/2/3 decoder blocks and make
ZeRO-1 + gradient accumulation + explicit sharding work end to end.

Under auto sharding a missing annotation costs nothing because GSPMD infers a
layout; under explicit sharding JAX type-checks every operation, so each Gemma
layer has to name its activations and hand its sub-modules an out_sharding
(and intermediate_sharding for the MLP) instead of relying on inference.

ZeRO-1 additionally shards the optimizer moments over the "data" axis. Auto
reconciles the resulting mismatch silently; explicit refuses to add a gradient
sharded P('fsdp') to a moment sharded P(('data','fsdp')). get_grad_shardings()
returns the layout gradients must reach the optimizer in, and both the
gradient-accumulation path (via the new out_grad_shardings argument) and the
single-microbatch path now target it. This was broken for every model, not
just Gemma.

Also name the QK/V-norm output layout in attentions.py, so gemma3 with tensor
parallelism gathers the norm scale instead of failing to compile, and keep
unreduced/reduced annotations when truncating a PartitionSpec.

All of it is a no-op under the default shard_mode=auto.

Tested on TPU v7-8: losses match auto for gemma-2b, gemma2-2b, gemma2-9b,
gemma3-4b and gemma3-12b, with and without tensor parallelism, ZeRO-1 and
gradient accumulation. ZeRO-1 without gradient accumulation is 7.5% faster
than auto (0.331 s vs 0.358 s per step on gemma-2b).
Under parameter_memory_host_offload the gradients are moved back to device
memory before the optimizer update. That device_put targeted params_shardings,
which also relayouted them out of the ZeRO-1 layout the gradient-accumulation
path had just put them in, so explicit sharding rejected the moment update with

  add got incompatible shardings for broadcasting: ('fsdp', None), (('data', 'fsdp'), None)

Target grad_shardings instead, which equals params_shardings whenever ZeRO-1 +
explicit sharding is not active. Covered by a new host-offload subtest.
…convention

gemma_explicit_sharding_test.py was named after the feature rather than the code
under test. Fold it into tests/unit/gemma_layers_test.py, alongside the existing
gemma3_layers_test.py / gemma4_layers_test.py, so future Gemma layer tests have an
obvious home.
@NuojCheng
NuojCheng force-pushed the gemma-explicit-sharding branch from 0b0dca9 to 01aeda2 Compare August 25, 2026 17:13
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