Skip to content

Commit 99d7adc

Browse files
committed
Consolidate and align NNX sharding helpers with Flax Linen
Pure NNX training runs previously used custom logical sharding resolution helpers which diverged from the standard Flax Linen path, causing logical axis fallback mismatch and DuplicateSpecErrors when multiple logical dimensions mapped to a single physical axis. This change aligns the NNX path with Flax Linen and consolidates utilities: 1. Replaced the custom rules resolution logic with standard Flax Linen `logical_to_mesh_axes` to ensure identical behavior for rules mapping. 2. Added the `remove_size_one_mesh_axis` reduction step inside the NNX variable resolver to strip size-1 axes from the PartitionSpec, preventing JAX from raising DuplicateSpecError on models with overlapping axis mappings. 3. Aligned the variable wrappers and extraction lifecycle: - `sharding.nnx_construct_named_sharding` and `sharding.get_nnx_var_named_sharding_with_scan_axis` retain standard Flax NNX `Variable` / `Param` wrappers to maintain structural type compatibility during multi-tree maps in trainer setup. - `maxtext_utils_nnx.nnx_extract_named_sharding` extracts clean JAX-native `NamedSharding` trees for compilation and device dispatch. 4. Cleaned up comments and unit tests (in `sharding_nnx_test.py` and `maxtext_utils_nnx_test.py`) to verify behavior on local meshes and support CPU-only testing environments by avoiding host offloading during JIT.
1 parent fd5bf7c commit 99d7adc

8 files changed

Lines changed: 384 additions & 258 deletions

File tree

src/maxtext/inference/maxengine/maxengine.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from maxtext.utils import max_logging
4848
from maxtext.utils import max_utils
4949
from maxtext.utils import maxtext_utils
50+
from maxtext.utils import sharding
5051
from maxtext.utils import maxtext_utils_nnx
5152
from maxtext.utils import model_creation_utils
5253
from maxtext.common.gcloud_stub import jetstream, is_decoupled
@@ -398,15 +399,15 @@ def _load_params_nnx(self, params, rng):
398399
# axis metadata but no physical .sharding. Resolve logical to physical here so
399400
# device_put actually reshards instead of being a no-op.
400401
with nn_partitioning.axis_rules(self.config.logical_axis_rules):
401-
target_shardings = maxtext_utils.get_nnx_named_sharding_with_scan_axis(params_abs, self._mesh)
402+
target_shardings = sharding.nnx_construct_named_sharding(params_abs, self._mesh)
402403
params_state = jax.device_put(params, target_shardings)
403404
# We only need a concrete `rest` (RNG vars) for nnx.merge. create_nnx_sharded_model
404405
# builds the model with a jitted out_shardings so params are produced already
405406
# sharded, avoiding a single-device allocation of the full model (an OOM risk for
406407
# large models). self.model is abstract with no .sharding, so pass an explicit one.
407408
_, full_abs = nnx.split(self.model)
408409
with nn_partitioning.axis_rules(self.config.logical_axis_rules):
409-
full_sharding = maxtext_utils.get_nnx_named_sharding_with_scan_axis(full_abs, self._mesh)
410+
full_sharding = sharding.nnx_construct_named_sharding(full_abs, self._mesh)
410411
concrete_model = maxtext_utils_nnx.create_nnx_sharded_model(
411412
self.model, self._create_model_fn, mesh=self._mesh, named_sharding=full_sharding
412413
)

src/maxtext/utils/maxtext_utils.py

Lines changed: 8 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,14 @@
2020
from typing import Sequence
2121

2222
from flax import nnx, linen as nn
23-
from flax.core.spmd import composite_rules, from_sharding_rules, get_logical_axis_rules
2423
from flax.linen import partitioning as nn_partitioning
2524
from flax.training.train_state import TrainState
2625

2726
import numpy as np
2827

2928
import jax
3029
import jax.numpy as jnp
31-
from jax.sharding import AxisType, Mesh, NamedSharding, PartitionSpec
30+
from jax.sharding import AxisType, Mesh, NamedSharding
3231
from jax.experimental import mesh_utils
3332
from jax.experimental.serialize_executable import deserialize_and_load
3433

@@ -1612,88 +1611,6 @@ def move(path, x):
16121611
)
16131612

16141613

1615-
def get_nnx_named_sharding_with_scan_axis(abs_var_state: nnx.State, mesh) -> nnx.State:
1616-
"""Compute NamedSharding for each NNX variable, correctly handling the scan (stacked layers) axis.
1617-
1618-
Unlike flax.nnx.spmd.get_var_pspec (used inside nnx.get_abstract_model), this function also
1619-
inserts the partition_name axis at the correct scan_axis position for parameters created by
1620-
_create_scanned_layers. Without this, scanned parameters get a 2D partition spec applied to a
1621-
3D tensor, placing sharding on the stacked-layers dimension instead of the embedding dimension.
1622-
1623-
Args:
1624-
abs_var_state: NNX abstract variable state from nnx.split(nnx.eval_shape(...)).
1625-
mesh: JAX physical mesh.
1626-
1627-
Returns:
1628-
Same tree structure as abs_var_state but each Variable's value replaced with NamedSharding.
1629-
"""
1630-
1631-
def _make_named_sharding(v):
1632-
val = v.get_value()
1633-
if not hasattr(val, "shape"):
1634-
# `val` is either truly leafless (e.g. optax MaskedNode) or a composite
1635-
# pytree of tensors (e.g. AQT QTensor on serve-mode quantized variables —
1636-
# a `qvalue` int8 array + a list of `scale` bf16 arrays). For the latter
1637-
# we must emit a parallel tree of NamedSharding leaves so the downstream
1638-
# `jax.tree.map(lambda a, s: ShapeDtypeStruct(..., sharding=s), abs, names)`
1639-
# finds a real Sharding at every position. Replicated sharding is a safe
1640-
# default — AQT serve-mode QTensors are normally small (per-channel scale
1641-
# factors and packed int8 weights) and don't need axis-aware sharding.
1642-
if jax.tree_util.tree_leaves(val):
1643-
replicated = NamedSharding(mesh, PartitionSpec())
1644-
return v.replace(jax.tree.map(lambda _: replicated, val))
1645-
return v
1646-
metadata = v.get_metadata()
1647-
out_sharding = metadata.get("out_sharding") or metadata.get("sharding_names") or metadata.get("sharding")
1648-
if not out_sharding:
1649-
pspec = PartitionSpec()
1650-
else:
1651-
# Insert the scan axis for parameters created by _create_scanned_layers.
1652-
# _add_scan_metadata stores the axis name in nnx.PARTITION_NAME and the
1653-
# axis index in "param_scan_axis". flax.nnx.spmd.get_var_pspec ignores these.
1654-
if nnx.PARTITION_NAME in metadata:
1655-
partition_name = metadata[nnx.PARTITION_NAME]
1656-
# Always use param_scan_axis from metadata. OptVariable (optimizer state) inherits
1657-
# param_scan_axis=1 from the model Param via to_opt_state(), so we must not hardcode
1658-
# scan_axis=0 for non-Param types. stacked_rest non-Param variables have
1659-
# param_scan_axis=0 set explicitly by _add_scan_metadata, so this is always correct.
1660-
scan_axis = metadata.get("param_scan_axis", 0)
1661-
out_sharding = [out_sharding] if isinstance(out_sharding, str) else list(out_sharding)
1662-
# Guard against double-insertion: Flax 0.12.6 _remap_sharding_metadata renames
1663-
# 'sharding' -> 'out_sharding', so _add_scan_metadata may have already inserted
1664-
# the scan axis. Only insert if not already present.
1665-
if partition_name not in out_sharding:
1666-
out_sharding.insert(scan_axis, partition_name)
1667-
out_sharding = tuple(out_sharding)
1668-
# Convert logical axis names to physical mesh axes using current context rules.
1669-
context_rules = get_logical_axis_rules()
1670-
local_rules = metadata.get("sharding_rules", ())
1671-
if context_rules or local_rules:
1672-
rules = composite_rules(context_rules, local_rules)
1673-
raw_sharding = from_sharding_rules(out_sharding, rules)
1674-
mesh_axis_names = mesh.axis_names if mesh is not None else ()
1675-
1676-
# from_sharding_rules leaves a logical name with no matching rule unchanged, so a
1677-
# name missing from logical_axis_rules (e.g. concat_embed on the MTP kernel)
1678-
# reaches NamedSharding and is rejected as an unknown mesh axis. Map any such
1679-
# leftover name to None (replicated), matching Linen, whose logical_to_mesh_axes
1680-
# replicates unmatched names.
1681-
def _sanitize(x):
1682-
if isinstance(x, list):
1683-
x = tuple(x)
1684-
if x is None or (isinstance(x, str) and x in mesh_axis_names) or isinstance(x, tuple):
1685-
return x
1686-
return None
1687-
1688-
sanitized_sharding = [_sanitize(x) for x in raw_sharding]
1689-
pspec = PartitionSpec(*sanitized_sharding)
1690-
else:
1691-
pspec = PartitionSpec(*out_sharding)
1692-
return v.replace(NamedSharding(mesh, pspec))
1693-
1694-
return jax.tree.map(_make_named_sharding, abs_var_state, is_leaf=lambda x: isinstance(x, nnx.Variable))
1695-
1696-
16971614
def get_abstract_state_nnx(config, mesh, nnx_init_trainstate_fn, is_training=True):
16981615
"""Calculates the abstract sharded state and memory placement for an NNX TrainState.
16991616
@@ -1724,26 +1641,25 @@ def get_abstract_state_nnx(config, mesh, nnx_init_trainstate_fn, is_training=Tru
17241641

17251642
with nn_partitioning.axis_rules(config.logical_axis_rules):
17261643
# Use nnx.eval_shape + nnx.split instead of nnx.get_abstract_model, so we can apply
1727-
# get_nnx_named_sharding_with_scan_axis which correctly inserts the stacked-layers
1644+
# nnx_construct_named_sharding which correctly inserts the stacked-layers
17281645
# axis into the partition spec. nnx.get_abstract_model uses get_var_pspec internally
17291646
# which ignores nnx.PARTITION_NAME / param_scan_axis metadata set by _create_scanned_layers,
17301647
# causing the 2D partition spec to be misapplied to the 3D stacked parameter tensor.
17311648
# Do NOT wrap nnx.eval_shape in jax.set_mesh: Flax 0.12.6's _to_variable calls
17321649
# var.shape for every variable when a global mesh is active, but masked optimizer
17331650
# state variables (e.g. from trainable_parameters_mask) have value=MaskedNode()
1734-
# which has no .shape and would raise AttributeError. We handle sharding
1735-
# ourselves via get_nnx_named_sharding_with_scan_axis, so auto-assignment is not
1736-
# needed here.
1651+
# which has no .shape and would raise AttributeError. We handle sharding
1652+
# ourselves via nnx_construct_named_sharding, so auto-assignment is not needed here.
17371653
abs_model = nnx.eval_shape(nnx_init_trainstate_fn)
17381654
_, abs_var_state = nnx.split(abs_model)
1739-
named_sharding_state = get_nnx_named_sharding_with_scan_axis(abs_var_state, mesh)
1655+
named_sharding_state = sharding.nnx_construct_named_sharding(abs_var_state, mesh)
17401656
abstract_state = jax.tree.map(
17411657
lambda a, s: jax.ShapeDtypeStruct(a.shape, a.dtype, sharding=s),
17421658
abs_var_state,
17431659
named_sharding_state,
17441660
)
17451661

1746-
state_mesh_shardings = maxtext_utils_nnx.get_named_sharding_nnx(abstract_state)
1662+
state_mesh_shardings = maxtext_utils_nnx.nnx_extract_named_sharding(abstract_state)
17471663

17481664
if is_training and config.shard_optimizer_over_data:
17491665
# Add data to sharding for optimizer state
@@ -1849,10 +1765,10 @@ def _nnx_cache_partition_specs(abstract_model, config, mesh):
18491765
way it does for the Linen helpers below.
18501766
"""
18511767
_, cache_state, _ = nnx.split(abstract_model, nnx.Cache, ...)
1852-
# get_nnx_named_sharding_with_scan_axis reads logical axis rules from the
1768+
# nnx_construct_named_sharding reads logical axis rules from the
18531769
# active flax partitioning context, so wrap.
18541770
with nn_partitioning.axis_rules(config.logical_axis_rules):
1855-
named_state = get_nnx_named_sharding_with_scan_axis(cache_state, mesh)
1771+
named_state = sharding.nnx_construct_named_sharding(cache_state, mesh)
18561772
return jax.tree.map(lambda s: s.spec, named_state.to_pure_dict())
18571773

18581774

src/maxtext/utils/maxtext_utils_nnx.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
""" Utils for MaxText NNX. """
14+
"""Utils for MaxText NNX."""
1515

1616
from functools import partial
1717
from typing import Callable
@@ -51,14 +51,17 @@ def create_nnx_rngs(
5151
return nnx.Rngs(params=rng_key) # disable dropout RNG and aqt for inference
5252

5353

54-
def get_named_sharding_nnx(abstract_state: nnx.State) -> nnx.State:
54+
def nnx_extract_named_sharding(abstract_state: nnx.State) -> nnx.State:
5555
"""Get named sharding from NNX abstract state.
5656
5757
Args:
5858
abstract_state: NNX model abstract state created from nnx.get_abstract_model.
5959
6060
Returns:
61-
named sharding structure
61+
A tree of raw NamedSharding objects (stripping out any nnx.Variable / Param
62+
wrappers). This clean structure is expected by JAX compiler APIs (like JIT
63+
out_shardings). Contrast with sharding.nnx_construct_named_sharding, which
64+
retains wrappers for abstract tree zipping compatibility.
6265
"""
6366
# Don't use nnx.get_named_sharding() because it constructs new shardings. Instead, we
6467
# get the existing sharding from the abstract_state.
@@ -156,7 +159,7 @@ def create_nnx_sharded_model(
156159
if named_sharding is None:
157160
# The state leaf is of type jax.ShapeDtypeStruct(shape, dtype, sharding)
158161
# we get the sharding directly from it.
159-
named_sharding = get_named_sharding_nnx(abstract_state)
162+
named_sharding = nnx_extract_named_sharding(abstract_state)
160163

161164
if mesh is None:
162165
mesh = abstract_model.mesh

src/maxtext/utils/model_creation_utils.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from maxtext.layers import quantizations
5252
from maxtext.models import models
5353
from maxtext.utils import max_logging
54-
from maxtext.utils import max_utils, maxtext_utils, maxtext_utils_nnx
54+
from maxtext.utils import max_utils, maxtext_utils, maxtext_utils_nnx, sharding
5555
import numpy as np
5656
from orbax import checkpoint as ocp
5757

@@ -112,13 +112,13 @@ def _zero_pad_axis(arr, axis, extra):
112112
if extra == 0:
113113
return arr
114114

115-
sharding = getattr(arr, "sharding", None)
115+
arr_sharding = getattr(arr, "sharding", None)
116116
pad_width = [(0, 0)] * arr.ndim
117117

118-
if isinstance(sharding, jax.sharding.NamedSharding):
119-
spec = sharding.spec
118+
if isinstance(arr_sharding, jax.sharding.NamedSharding):
119+
spec = arr_sharding.spec
120120
partition = spec[axis] if axis < len(spec) else None
121-
shards_along_axis = _partition_size(partition, sharding.mesh)
121+
shards_along_axis = _partition_size(partition, arr_sharding.mesh)
122122
if shards_along_axis > 1:
123123
if extra % shards_along_axis != 0:
124124
raise ValueError(
@@ -131,7 +131,7 @@ def _zero_pad_axis(arr, axis, extra):
131131
def _pad_local(x):
132132
return jnp.pad(x, pad_width)
133133

134-
return jax.shard_map(_pad_local, mesh=sharding.mesh, in_specs=spec, out_specs=spec, check_vma=False)(arr)
134+
return jax.shard_map(_pad_local, mesh=arr_sharding.mesh, in_specs=spec, out_specs=spec, check_vma=False)(arr)
135135

136136
pad_width[axis] = (0, extra)
137137
return jnp.pad(arr, pad_width)
@@ -257,11 +257,11 @@ def _maybe_fuse(path, ckpt_node):
257257

258258
# Determine the number of shards (TP degree) along the concatenated axis
259259
n_shards = 1
260-
sharding = getattr(wi_model, "sharding", None)
261-
if isinstance(sharding, jax.sharding.NamedSharding):
262-
spec = sharding.spec
260+
wi_sharding = getattr(wi_model, "sharding", None)
261+
if isinstance(wi_sharding, jax.sharding.NamedSharding):
262+
spec = wi_sharding.spec
263263
partition = spec[axis] if axis < len(spec) else None
264-
n_shards = _partition_size(partition, sharding.mesh)
264+
n_shards = _partition_size(partition, wi_sharding.mesh)
265265

266266
# Target size for a single half (wi_0 or wi_1) AFTER padding
267267
target_half_dim = wi_model.shape[-1] // 2
@@ -325,13 +325,13 @@ def _stored_shape_evenly_shardable(restore_arg, stored_shape):
325325
(each device receives only its local slice), avoiding the multi-GB replicated
326326
fanout that fully-replicated loading produces for large MoE weights.
327327
"""
328-
sharding = restore_arg.sharding
329-
if not isinstance(sharding, jax.sharding.NamedSharding):
328+
restore_sharding = restore_arg.sharding
329+
if not isinstance(restore_sharding, jax.sharding.NamedSharding):
330330
return False
331-
spec = sharding.spec
331+
spec = restore_sharding.spec
332332
for axis_idx, dim in enumerate(stored_shape):
333333
partition = spec[axis_idx] if axis_idx < len(spec) else None
334-
if dim % _partition_size(partition, sharding.mesh) != 0:
334+
if dim % _partition_size(partition, restore_sharding.mesh) != 0:
335335
return False
336336
return True
337337

@@ -581,7 +581,7 @@ def create_nnx_abstract_model(
581581
# wrap is unnecessary here.
582582
abs_model = nnx.eval_shape(_create_model)
583583
graphdef, abs_var_state = nnx.split(abs_model)
584-
named_sharding_state = maxtext_utils.get_nnx_named_sharding_with_scan_axis(abs_var_state, mesh)
584+
named_sharding_state = sharding.nnx_construct_named_sharding(abs_var_state, mesh)
585585
abstract_state = jax.tree.map(
586586
lambda a, s: jax.ShapeDtypeStruct(a.shape, a.dtype, sharding=s),
587587
abs_var_state,

src/maxtext/utils/sharding.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
# limitations under the License.
1414

1515
# pylint: disable=line-too-long, disable=bare-except, consider-using-generator
16-
""" Utils that are only interesting to MaxText and sharding related. """
16+
"""Utils that are only interesting to MaxText and sharding related."""
1717

1818
from flax import linen as nn, nnx
19+
from flax.core.spmd import get_logical_axis_rules
1920

2021
from collections.abc import Iterable
2122

@@ -179,6 +180,70 @@ def remove_size_one_mesh_axis(spec, mesh):
179180
return P(*new_spec, unreduced=spec.unreduced, reduced=spec.reduced)
180181

181182

183+
def get_nnx_var_named_sharding_with_scan_axis(v: nnx.Variable, mesh) -> nnx.Variable:
184+
"""Compute NamedSharding for an NNX variable, correctly handling the scan axis."""
185+
val = v.get_value()
186+
if not hasattr(val, "shape"):
187+
# `val` is either truly leafless (e.g. optax MaskedNode) or a composite
188+
# pytree of tensors (e.g. AQT QTensor on serve-mode quantized variables).
189+
# Replicated sharding is a safe default.
190+
if jax.tree_util.tree_leaves(val):
191+
replicated = NamedSharding(mesh, P())
192+
return v.replace(jax.tree.map(lambda _: replicated, val))
193+
return v
194+
metadata = v.get_metadata()
195+
out_sharding = metadata.get("out_sharding") or metadata.get("sharding_names") or metadata.get("sharding")
196+
if not out_sharding:
197+
pspec = P()
198+
else:
199+
# Insert the scan axis for parameters created by _create_scanned_layers.
200+
if nnx.PARTITION_NAME in metadata:
201+
partition_name = metadata[nnx.PARTITION_NAME]
202+
scan_axis = metadata.get("param_scan_axis", 0)
203+
out_sharding = [out_sharding] if isinstance(out_sharding, str) else list(out_sharding)
204+
if partition_name not in out_sharding:
205+
out_sharding.insert(scan_axis, partition_name)
206+
out_sharding = tuple(out_sharding)
207+
# Convert logical axis names to physical mesh axes using current context rules.
208+
context_rules = get_logical_axis_rules()
209+
local_rules = metadata.get("sharding_rules", ())
210+
if context_rules or local_rules:
211+
local_rules_list = list(local_rules) if local_rules is not None else []
212+
context_rules_list = list(context_rules) if context_rules is not None else []
213+
rules = local_rules_list + context_rules_list
214+
pspec = logical_to_mesh_axes(out_sharding, mesh, rules=rules)
215+
else:
216+
pspec = P(*out_sharding)
217+
if mesh is not None:
218+
pspec = remove_size_one_mesh_axis(pspec, mesh)
219+
return v.replace(NamedSharding(mesh, pspec))
220+
221+
222+
def nnx_construct_named_sharding(abs_var_state: nnx.State, mesh) -> nnx.State:
223+
"""Compute NamedSharding for each NNX variable, correctly handling the scan (stacked layers) axis.
224+
225+
Unlike flax.nnx.spmd.get_var_pspec (used inside nnx.get_abstract_model), this function also
226+
inserts the partition_name axis at the correct scan_axis position for parameters created by
227+
_create_scanned_layers. Without this, scanned parameters get a 2D partition spec applied to a
228+
3D tensor, placing sharding on the stacked-layers dimension instead of the embedding dimension.
229+
230+
Args:
231+
abs_var_state: NNX abstract variable state from nnx.split(nnx.eval_shape(...)).
232+
mesh: JAX physical mesh.
233+
234+
Returns:
235+
Same tree structure as abs_var_state with leaf values replaced with NamedSharding.
236+
Note that it preserves the original nnx.Variable / Param wrapper nodes to maintain
237+
type structure matching abs_var_state (necessary for multi-tree maps). Use
238+
maxtext_utils_nnx.nnx_extract_named_sharding to retrieve clean raw NamedShardings.
239+
"""
240+
return jax.tree.map(
241+
lambda x: get_nnx_var_named_sharding_with_scan_axis(x, mesh),
242+
abs_var_state,
243+
is_leaf=lambda x: isinstance(x, nnx.Variable),
244+
)
245+
246+
182247
def logical_to_mesh_axes(logical_names, mesh, rules=None):
183248
"""Remove size one mesh axes given logical names."""
184249
tensor_spec = nn.logical_to_mesh_axes(logical_names, rules=rules)

0 commit comments

Comments
 (0)