Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/maxtext/m3/infra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@

"""Post-construction transforms applied from training setup, never from models.

Implemented modules:
* `scan.py` — layer stacking via `jax.lax.scan` + `maybe_apply_scan`.

Planned modules:
* `remat.py` — path-transparent remat wrapper + `maybe_apply_remat`.
* `scan.py` — layer stacking via `jax.lax.scan` + `maybe_apply_scan`.
* `quantization.py` — qwix int8/fp8 rules + `maybe_quantize`.
* `offload.py` — `to_host` for optimizer/parameter offloading.

Expand Down
169 changes: 169 additions & 0 deletions src/maxtext/m3/infra/scan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Copyright 2023–2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Post-construction scanning of repeated decoder layers or blocks.

Scan units must share an NNX graph definition and matching corresponding
variable shapes; their weight values are independent. A unit may be a single
layer or a model-specific block containing different layer types.

Models opt in through ``decoder.layer_names`` and define the scan units.
Keep this module free of model-specific branches: architecture, cyclic patterns,
and grouping belong to model definitions, not to the scan transform.
"""

from collections.abc import Sequence
from typing import Any

from flax import nnx
import jax
import jax.numpy as jnp
from jax.sharding import NamedSharding
from jax.sharding import PartitionSpec as P

_SHARDING_KEYS = ("out_sharding", "sharding", "sharding_names")


def _is_variable(x: Any) -> bool:
return isinstance(x, nnx.Variable)


def _insert_axis_in_spec(spec: Any, axis: int) -> Any:
"""Inserts a replicated (None) entry for the new layer axis into a sharding spec."""
if isinstance(spec, (P, tuple)):

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.

_insert_axis_in_spec and _remove_axis_from_spec only handle P and tuple. If a Variable's out_sharding is a NamedSharding or a list, it passes through unchanged. The stacked rank-(d+1) array then keeps a rank-d spec, and _stack_states also skips the device_put (it checks isinstance(spec, P)). Downstream sharded creation (get_nnx_var_named_sharding_with_scan_axis / create_nnx_sharded_model) would then fail on the rank mismatch. nnx.spmd.add_axis / remove_axis may already cover these cases.

entries = list(spec)
entries.extend([None] * max(0, axis - len(entries)))
entries.insert(axis, None)
return P(*entries) if isinstance(spec, P) else tuple(entries)
return spec


def _remove_axis_from_spec(spec: Any, axis: int) -> Any:
"""Removes the scan-axis entry from a sharding spec when present."""
if isinstance(spec, (P, tuple)) and len(spec) > axis:
entries = list(spec)
del entries[axis]
return P(*entries) if isinstance(spec, P) else tuple(entries)
return spec
Comment thread
hengtaoguo marked this conversation as resolved.


def _map_sharding_metadata(state: nnx.State, fn, axis: int) -> nnx.State:
"""Applies `fn(spec, axis)` to supported sharding metadata in `state`."""

def update(var):
if not _is_variable(var):
return var
meta = var.get_metadata()
Comment thread
hengtaoguo marked this conversation as resolved.
effective_axis = axis if var.get_value().ndim >= axis else 0
changes = {k: fn(meta[k], effective_axis) for k in _SHARDING_KEYS if meta.get(k) is not None}
return var.replace(**changes) if changes else var

return jax.tree.map(update, state, is_leaf=_is_variable)


def _stack_states(states: Sequence[nnx.State], param_axis: int, mesh) -> nnx.State:
"""Stacks Params on `param_axis` when rank permits; other variables use axis 0."""

def stack(*variables):
first = variables[0]
if not _is_variable(first):

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.

stack returns layer 0's value for any non-Variable leaf. NNX graph flattening keeps raw jax.Array / np.ndarray attributes as bare state leaves, so a unit that stores a constant buffer (e.g. self.inv_freq = jnp.arange(64)) ends up with a single unstacked copy. That leaf then lands in rest, and lax.scan slices its leading axis as if it were the layer axis: a length mismatch if dim 0 != L, or silently one row per layer if it happens to equal L. Could we stack these leaves as well, or reject them explicitly?

return first
axis = param_axis if isinstance(first, nnx.Param) and first.get_value().ndim >= param_axis else 0
value = jnp.stack([v.get_value() for v in variables], axis=axis)
Comment thread
hengtaoguo marked this conversation as resolved.
meta = first.get_metadata()
Comment thread
hengtaoguo marked this conversation as resolved.
changes = {k: _insert_axis_in_spec(meta[k], axis) for k in _SHARDING_KEYS if meta.get(k) is not None}
spec = changes.get("out_sharding", changes.get("sharding"))
if mesh is not None and isinstance(spec, P):
value = jax.device_put(value, NamedSharding(mesh, spec))
return first.replace(value=value, **changes)

return jax.tree.map(stack, *states, is_leaf=_is_variable)


class ScannedLayers(nnx.Module):
"""Scan compatible layers or blocks with independent weights.

Units must have equal NNX graph definitions and matching corresponding variable
shapes. Calls take an array carry and return an array of the same shape and
dtype; additional arguments are shared across all iterations. Parameters must
remain unchanged during calls; updated non-Param state is written back.
"""

def __init__(self, layers: Sequence[nnx.Module], *, param_scan_axis: int = 1, mesh=None):
if len(layers) < 2:
raise ValueError(f"ScannedLayers needs at least two layers, got {len(layers)}.")
graphdef = nnx.graphdef(layers[0])
for i, layer in enumerate(layers[1:], start=1):
if nnx.graphdef(layer) != graphdef:
raise ValueError(f"Layer {i} is not structurally identical to layer 0; cannot scan them together.")
Comment thread
hengtaoguo marked this conversation as resolved.

stacked = nnx.merge(
graphdef,
_stack_states([nnx.state(l) for l in layers], param_scan_axis, mesh),

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.

Stacking nnx.state(l) per layer turns a Variable or submodule that is shared across layers, or shared with a module outside the stack, into L independent copies. Weight tying is then silently broken: gradients no longer accumulate into the shared weight, the outside copy stops seeing in-layer updates, and the parameter count grows. Could ScannedLayers.__init__ detect shared references across layers (e.g. by object identity) and raise?

)
# Adopt the stacked layer's attributes so parameter paths match a single layer's.
for name, value in vars(stacked).items():

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.

Copying attributes with plain setattr skips names starting with _ and loses explicit nnx.data / nnx.static status:

  • A unit with a private submodule or Variable (e.g. self._norm = nnx.RMSNorm(...)) loses it from ScannedLayers' state, and nnx.merge(self._layer_graphdef, ...) in the body then fails on the missing leaves.
  • A unit holding nnx.data([...]) can hit flax's "Found data on value ... assigned to static attribute" error here.

Could we copy every attribute, including _-prefixed ones, and preserve its data/static status?

if not name.startswith("_"):
setattr(self, name, value)
self._layer_graphdef = graphdef
self._param_scan_axis = param_scan_axis
self.num_layers = len(layers)

def __call__(self, y: jax.Array, *args, **kwargs) -> jax.Array:
axis = self._param_scan_axis
_, params, rest = nnx.split(self, nnx.Param, ...)
params = jax.tree.map(lambda x: jnp.moveaxis(x, axis, 0) if x.ndim > axis else x, params)

def body(carry, xs):
layer_params, layer_rest = xs
layer_params = _map_sharding_metadata(layer_params, _remove_axis_from_spec, axis)
layer_rest = _map_sharding_metadata(layer_rest, _remove_axis_from_spec, 0)
layer = nnx.merge(self._layer_graphdef, layer_params, layer_rest)
out = layer(carry, *args, **kwargs)
# Params come back unchanged; only emit the (possibly mutated) non-Param state.
_, _, new_rest = nnx.split(layer, nnx.Param, ...)

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.

Only non-Param state is emitted from the body, so any nnx.Param created during the call is silently dropped. With the documented order construct → scan → quantize, Qwix LoRA / quantized layers create their params lazily inside this call (LoRA adapters are Param subclasses), so they would be discarded on every step and never trained.

Conversely, a new non-Param variable created in the call (sow, quantization stats) is stacked and added by nnx.update. That changes the model graphdef after the first step, which makes nnx.cached_partial raise "graph structure ... was mutated".

maxtext.layers.nnx_scan.apply_scanned_layers already handles this case. Could we reuse it, or mirror its handling here?

new_rest = _map_sharding_metadata(new_rest, _insert_axis_in_spec, 0)
return out, new_rest
Comment thread
hengtaoguo marked this conversation as resolved.

y, new_rest = jax.lax.scan(body, y, (params, rest))
if jax.tree.leaves(new_rest):
nnx.update(self, new_rest)
return y


def maybe_apply_scan(model: nnx.Module, config, mesh=None) -> nnx.Module:
"""Scan an opted-in decoder stack in place when enabled; no remat is applied.

Decoders opt in by executing compatible layers or blocks through ordered
``layer_names``. Missing or None means unsupported; fewer than two is a no-op.
The transform replaces these units with one module named ``layers``.
"""
if not config.scan_layers:
return model
decoder = model.decoder
Comment thread
hengtaoguo marked this conversation as resolved.

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.

Blocking: model.decoder is read unconditionally, so any m3 model without a decoder attribute now fails whenever scan_layers=true, which is the base.yml default. Glm5Model (registered as glm5.3-flash, with use_m3_model: true in its yml) has embed_tokens / prefix_layers / super_block / suffix_layers and no decoder. As a result, create_model, from_pretrained and the m3 loop all raise AttributeError: 'Glm5Model' object has no attribute 'decoder'. Setting scan_layers=false avoids it, but it also disables GLM's own scan (glm5_scan reads config.scan_layers).

The same default also turns the layer_names is None case (e.g. Qwen3-VL) into a hard ValueError for runs that previously ignored scan_layers.

Suggestion: treat a missing decoder / layer_names, or a model that scans internally, as a no-op rather than an error (e.g. decoder = getattr(model, "decoder", None) and return early). Models would then opt in, instead of every m3 model going through this path.

layer_names = getattr(decoder, "layer_names", None)
if layer_names is None:
raise ValueError(
f"{type(decoder).__name__} does not expose scan units through "
"`layer_names`; m3 layer scanning is currently unsupported for this model."
)
if len(layer_names) < 2:

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.

With scan_layers=true, a decoder with fewer than two layers is left unscanned (decoder/layers_0), while the checkpoint metadata still records scan_layers=True. The legacy path produces a stacked layers tree with L=1 in that case, so a 1-layer scanned checkpoint can't move between the two paths. lax.scan handles length 1 fine, so the < 2 guard (here and in ScannedLayers.__init__) could be dropped.

return model
layers = [getattr(decoder, name) for name in layer_names]
scanned = ScannedLayers(layers, param_scan_axis=config.param_scan_axis, mesh=mesh)
for name in layer_names:
delattr(decoder, name)
decoder.layers = scanned
decoder.layer_names = ("layers",)
return model
6 changes: 4 additions & 2 deletions src/maxtext/m3/models/qwen3/modeling_qwen3.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ def __init__(
rngs=rngs,
)
setattr(self, f"layers_{lyr}", layer)
# The scan transform replaces these entries with one scanned module.
self.layer_names = tuple(f"layers_{lyr}" for lyr in range(self.num_layers))

self.decoder_norm = nnx.RMSNorm(
num_features=config.emb_dim,
Expand Down Expand Up @@ -399,8 +401,8 @@ def __call__(
"""Iterates through all decoder layers and projects to logits via tied embeddings."""
y = shared_embedding(decoder_input_tokens)

for lyr in range(self.num_layers):
layer = getattr(self, f"layers_{lyr}")
for name in self.layer_names:
layer = getattr(self, name)
y = layer(
y,
decoder_segment_ids=decoder_segment_ids,
Expand Down
2 changes: 2 additions & 0 deletions src/maxtext/m3/models/qwen3_vl/modeling_qwen3_vl.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,8 @@ def __init__(
rngs: nnx.Rngs,
):
super().__init__(config, mesh, rngs=rngs, layer_cls=Qwen3VLDecoderLayer)
# Scan does not yet support inter-layer DeepStack injections.
self.layer_names = None

def __call__(
self,
Expand Down
11 changes: 8 additions & 3 deletions src/maxtext/m3/train/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
model constructors take today. The fields read here are `steps`,
`per_device_batch_size`, `max_target_length`, `learning_rate`, the optimizer
fields consumed by `maxtext.optimizers.optimizers.get_optimizer`,
`scan_layers` and `param_scan_axis` consumed by the scan transform,
`gradient_clipping_threshold`, `init_weights_seed`, and the profiler fields
consumed by `maxtext.common.profiler.Profiler` (`profiler`,
`skip_first_n_steps_for_profiler`, `profiler_steps`, ...). The trace is
Expand Down Expand Up @@ -56,6 +57,7 @@
from maxtext.configs import pyconfig
from maxtext.m3 import models as m3_models
from maxtext.m3.core.sharding import create_mesh
from maxtext.m3.infra import scan as m3_scan
from maxtext.optimizers import optimizers
from maxtext.utils import max_logging

Expand Down Expand Up @@ -148,6 +150,8 @@ def train_loop(config) -> float:

with jax.set_mesh(mesh):
model = m3_models.create_model(config, mesh, rngs=nnx.Rngs(config.init_weights_seed))
# TODO: Apply remat_policy to the scan body.
model = m3_scan.maybe_apply_scan(model, config, mesh=mesh)
optimizer = nnx.Optimizer(model, make_optimizer(config), wrt=nnx.Param)

num_params = sum(x.size for x in jax.tree.leaves(nnx.state(model, nnx.Param)))
Expand All @@ -160,6 +164,7 @@ def train_loop(config) -> float:
prof = profiler.Profiler(config, offset_step=0)
data_key = jax.random.key(config.init_weights_seed)
loss = float("nan")
step_fn = nnx.cached_partial(train_step, model, optimizer)
# Steps are logged one iteration late. Reading a loss blocks until its step
# finishes, so doing it right after dispatch would leave the device idle
# while the host prepares the next step. Reading step N-1's loss after
Expand All @@ -169,13 +174,13 @@ def train_loop(config) -> float:
last_done = time.perf_counter()
for step in range(config.steps):
batch = make_synthetic_batch(config, mesh, jax.random.fold_in(data_key, step))
prof.maybe_activate_profiler(step, nnx.state(model))
prof.maybe_activate_profiler(step, pending)
with jax.profiler.StepTraceAnnotation("train", step_num=step):
step_loss = train_step(model, optimizer, batch)
step_loss = step_fn(batch)
if pending is not None:
loss, last_done = _log_step(*pending, last_done, tokens_per_step)
pending = (step, step_loss)
prof.maybe_deactivate_profiler(step, nnx.state(model))
prof.maybe_deactivate_profiler(step, pending)
if pending is not None:
loss, last_done = _log_step(*pending, last_done, tokens_per_step)

Expand Down
5 changes: 4 additions & 1 deletion src/maxtext/utils/model_creation_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,11 @@ def create_model(
"""Instantiates and returns the model object, sharded across the mesh."""
if config.use_m3_model:
from maxtext.m3 import models as m3_models # pylint: disable=import-outside-toplevel
from maxtext.m3.infra import scan as m3_scan # pylint: disable=import-outside-toplevel

return m3_models.create_model(config, mesh, rngs=rngs, quant_mode_str=quant_mode_str)
model = m3_models.create_model(config, mesh, rngs=rngs, quant_mode_str=quant_mode_str)
model = m3_scan.maybe_apply_scan(model, config, mesh=mesh)

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.

Blocking (checkpoint compatibility): with this change, m3 models start honoring scan_layers (default true). However, m3 checkpoints saved before this PR wrote custom_metadata["scan_layers"] = config.scan_layers (checkpointing.py:1613), i.e. True, while their trees hold unscanned decoder/layers_N.

In from_pretrained, verify_and_sync_scan_layers then either:

  • keeps True (default), so the model is built with a stacked decoder/layers and restore fails on the tree mismatch; or
  • raises "Configuration mismatch" when the user passes scan_layers=false explicitly.

So those checkpoints can't be loaded with any setting. Could we add a migration path, e.g. an m3-specific metadata key, or accepting unscanned trees for checkpoints that predate this change?

return model

# Model definition
quant = quantizations.configure_quantization(config, quant_mode_str=quant_mode_str)
Expand Down
Loading
Loading