Repository navigation
Add general scan support for M3 #5875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)): | ||
| 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 | ||
|
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() | ||
|
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): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) | ||
|
hengtaoguo marked this conversation as resolved.
|
||
| meta = first.get_metadata() | ||
|
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.") | ||
|
hengtaoguo marked this conversation as resolved.
|
||
|
|
||
| stacked = nnx.merge( | ||
| graphdef, | ||
| _stack_states([nnx.state(l) for l in layers], param_scan_axis, mesh), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stacking |
||
| ) | ||
| # Adopt the stacked layer's attributes so parameter paths match a single layer's. | ||
| for name, value in vars(stacked).items(): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Copying attributes with plain
Could we copy every attribute, including |
||
| 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, ...) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only non- Conversely, a new non-
|
||
| new_rest = _map_sharding_metadata(new_rest, _insert_axis_in_spec, 0) | ||
| return out, new_rest | ||
|
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 | ||
|
hengtaoguo marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: The same default also turns the Suggestion: treat a missing |
||
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking (checkpoint compatibility): with this change, m3 models start honoring In
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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_insert_axis_in_specand_remove_axis_from_speconly handlePandtuple. If a Variable'sout_shardingis aNamedShardingor alist, it passes through unchanged. The stacked rank-(d+1) array then keeps a rank-d spec, and_stack_statesalso skips thedevice_put(it checksisinstance(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_axismay already cover these cases.