Skip to content

Commit 6d78811

Browse files
committed
add nnx_scan util module
1 parent c5b135a commit 6d78811

3 files changed

Lines changed: 168 additions & 88 deletions

File tree

src/maxtext/layers/nnx_decoders.py

Lines changed: 14 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
ShardMode,
3838
)
3939
from maxtext.layers import initializers, linears, mhc, normalizations, quantizations
40-
from maxtext.layers import nnx_wrappers
40+
from maxtext.layers import nnx_scan, nnx_wrappers
4141
from maxtext.layers.attentions import Attention
4242
from maxtext.layers.embeddings import Embed, PositionalEmbedding, attend_on_embedding
4343
from maxtext.layers.normalizations import RMSNorm
@@ -839,94 +839,20 @@ def _create_scanned_layers(
839839
**layer_kwargs,
840840
):
841841
"""Creates a scanned stack of layers using jax.lax.scan for memory-efficient initialization."""
842-
if length == 0:
843-
return None
844-
scan_axis = self.config.param_scan_axis
845-
846-
# Fork rngs to get per-layer RNG states for scanning
847-
try:
848-
forked_rngs = rngs.fork(split=length)
849-
except: # pylint: disable=bare-except
850-
pass
851-
852-
rngs_graphdef, rngs_state = nnx.split(forked_rngs)
853-
854-
first_rng_state = jax.tree.map(lambda x: x[0], rngs_state)
855-
ref_rngs = nnx.merge(rngs_graphdef, first_rng_state)
856-
ref_layer = decoder_layer_class(
857-
config=self.config,
858-
mesh=self.mesh,
859-
quant=self.quant,
860-
model_mode=self.model_mode,
861-
rngs=ref_rngs,
862-
**layer_kwargs,
842+
return nnx_scan.create_scanned_layers(
843+
lambda layer_rngs: decoder_layer_class(
844+
config=self.config,
845+
mesh=self.mesh,
846+
model_mode=self.model_mode,
847+
quant=self.quant,
848+
rngs=layer_rngs,
849+
**layer_kwargs,
850+
),
851+
length=length,
852+
param_scan_axis=self.config.param_scan_axis,
853+
metadata_axis_name=metadata_axis_name,
854+
rngs=rngs,
863855
)
864-
layer_graphdef, _, _ = nnx.split(ref_layer, nnx.Param, ...)
865-
del ref_layer
866-
867-
def scan_body(carry, rng_state_slice):
868-
layer_rngs = nnx.merge(rngs_graphdef, rng_state_slice)
869-
layer = decoder_layer_class(
870-
config=self.config,
871-
mesh=self.mesh,
872-
quant=self.quant,
873-
model_mode=self.model_mode,
874-
rngs=layer_rngs,
875-
**layer_kwargs,
876-
)
877-
_, params, rest = nnx.split(layer, nnx.Param, ...)
878-
return carry, (params, rest)
879-
880-
_, (stacked_params, stacked_rest) = jax.lax.scan(scan_body, None, rngs_state)
881-
882-
if scan_axis != 0:
883-
stacked_params = jax.tree.map(lambda x: jnp.moveaxis(x, scan_axis, 0), stacked_params)
884-
885-
def _add_scan_metadata(state, axis):
886-
def _update_leaf(leaf):
887-
if hasattr(leaf, "replace") and hasattr(leaf, "value"):
888-
replace_kwargs = {}
889-
if hasattr(leaf, "get_metadata"):
890-
replace_kwargs.update(leaf.get_metadata())
891-
892-
replace_kwargs[nnx.PARTITION_NAME] = metadata_axis_name
893-
replace_kwargs["param_scan_axis"] = axis
894-
895-
for key in [
896-
"sharding",
897-
"out_sharding",
898-
"kernel_axes",
899-
"sharding_names",
900-
]:
901-
val = getattr(leaf, key, None)
902-
if val is None and key in replace_kwargs:
903-
val = replace_kwargs[key]
904-
905-
if val is not None:
906-
if isinstance(val, str):
907-
val = (val,)
908-
if isinstance(val, tuple):
909-
l = list(val)
910-
# Safely insert the scan axis into the logical axes string
911-
if metadata_axis_name not in l:
912-
insert_idx = min(axis, len(l))
913-
l.insert(insert_idx, metadata_axis_name)
914-
replace_kwargs[key] = tuple(l)
915-
916-
return leaf.replace(**replace_kwargs)
917-
return leaf
918-
919-
# We must use a custom is_leaf to catch the VariableState instances
920-
return jax.tree.map(
921-
_update_leaf,
922-
state,
923-
is_leaf=lambda x: hasattr(x, "replace") and hasattr(x, "value"),
924-
)
925-
926-
stacked_params = _add_scan_metadata(stacked_params, scan_axis)
927-
stacked_rest = _add_scan_metadata(stacked_rest, 0)
928-
929-
return nnx.merge(layer_graphdef, stacked_params, stacked_rest)
930856

931857
def _apply_layer_with_remat(self, layer: nnx.Module, y: jax.Array, policy: Any, prevent_cse: bool, **kwargs):
932858
"""Helper to cleanly apply jax.checkpoint to a single unscanned layer or block."""

src/maxtext/layers/nnx_scan.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright 2023–2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Utilities for constructing stacks of scanned NNX layers."""
16+
17+
from collections.abc import Callable
18+
19+
from flax import nnx
20+
import jax
21+
import jax.numpy as jnp
22+
23+
24+
def create_scanned_layers(
25+
layer_factory: Callable[[nnx.Rngs], nnx.Module],
26+
*,
27+
length: int,
28+
param_scan_axis: int,
29+
metadata_axis_name: str,
30+
rngs: nnx.Rngs,
31+
) -> nnx.Module | None:
32+
"""Constructs an NNX layer whose variables are stacked for a layer scan."""
33+
if length == 0:
34+
return None
35+
36+
forked_rngs = rngs.fork(split=length)
37+
rngs_graphdef, rngs_state = nnx.split(forked_rngs)
38+
39+
first_rng_state = jax.tree.map(lambda x: x[0], rngs_state)
40+
reference_layer = layer_factory(nnx.merge(rngs_graphdef, first_rng_state))
41+
layer_graphdef, _, _ = nnx.split(reference_layer, nnx.Param, ...)
42+
del reference_layer
43+
44+
def scan_body(carry, rng_state_slice):
45+
layer = layer_factory(nnx.merge(rngs_graphdef, rng_state_slice))
46+
_, params, rest = nnx.split(layer, nnx.Param, ...)
47+
return carry, (params, rest)
48+
49+
_, (stacked_params, stacked_rest) = jax.lax.scan(scan_body, None, rngs_state)
50+
51+
if param_scan_axis != 0:
52+
stacked_params = jax.tree.map(lambda x: jnp.moveaxis(x, 0, param_scan_axis), stacked_params)
53+
54+
def add_scan_metadata(state, axis):
55+
def update_leaf(leaf):
56+
if hasattr(leaf, "replace") and hasattr(leaf, "value"):
57+
replace_kwargs = {}
58+
if hasattr(leaf, "get_metadata"):
59+
replace_kwargs.update(leaf.get_metadata())
60+
61+
replace_kwargs[nnx.PARTITION_NAME] = metadata_axis_name
62+
replace_kwargs["param_scan_axis"] = axis
63+
64+
for key in ["sharding", "out_sharding", "kernel_axes", "sharding_names"]:
65+
value = getattr(leaf, key, None)
66+
if value is None and key in replace_kwargs:
67+
value = replace_kwargs[key]
68+
69+
if value is not None:
70+
if isinstance(value, str):
71+
value = (value,)
72+
if isinstance(value, tuple):
73+
logical_axes = list(value)
74+
if metadata_axis_name not in logical_axes:
75+
logical_axes.insert(min(axis, len(logical_axes)), metadata_axis_name)
76+
replace_kwargs[key] = tuple(logical_axes)
77+
78+
return leaf.replace(**replace_kwargs)
79+
return leaf
80+
81+
return jax.tree.map(
82+
update_leaf,
83+
state,
84+
is_leaf=lambda x: hasattr(x, "replace") and hasattr(x, "value"),
85+
)
86+
87+
stacked_params = add_scan_metadata(stacked_params, param_scan_axis)
88+
stacked_rest = add_scan_metadata(stacked_rest, 0)
89+
return nnx.merge(layer_graphdef, stacked_params, stacked_rest)

tests/unit/nnx_scan_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for NNX scan utilities."""
16+
17+
import unittest
18+
19+
from flax import nnx
20+
import jax
21+
import pytest
22+
23+
from maxtext.layers import nnx_scan
24+
25+
26+
class _LinearLayer(nnx.Module):
27+
28+
def __init__(self, rngs: nnx.Rngs):
29+
self.kernel = nnx.Param(jax.random.normal(rngs.params(), (2, 2)))
30+
31+
def __call__(self, inputs):
32+
return inputs @ self.kernel.value
33+
34+
35+
@pytest.mark.cpu_only
36+
class TestCreateScannedLayers(unittest.TestCase):
37+
"""Tests for nnx_scan.create_scanned_layers."""
38+
39+
def test_create_stacks_params_at_param_scan_axis(self):
40+
"""Per-layer params are stacked along param_scan_axis."""
41+
length = 3
42+
for axis, expected_shape in ((0, (length, 2, 2)), (1, (2, length, 2))):
43+
layers = nnx_scan.create_scanned_layers(
44+
_LinearLayer,
45+
length=length,
46+
param_scan_axis=axis,
47+
metadata_axis_name="layers",
48+
rngs=nnx.Rngs(0),
49+
)
50+
self.assertEqual(layers.kernel.value.shape, expected_shape)
51+
52+
def test_create_zero_length_returns_none(self):
53+
"""A zero-length stack short-circuits to None."""
54+
layers = nnx_scan.create_scanned_layers(
55+
_LinearLayer,
56+
length=0,
57+
param_scan_axis=0,
58+
metadata_axis_name="layers",
59+
rngs=nnx.Rngs(0),
60+
)
61+
self.assertIsNone(layers)
62+
63+
64+
if __name__ == "__main__":
65+
unittest.main()

0 commit comments

Comments
 (0)