Skip to content

Commit a6320df

Browse files
Merge pull request #4372 from AI-Hypercomputer:fix/nnx-emc-linen-abstract
PiperOrigin-RevId: 947161886
2 parents d46eb63 + 9dc7704 commit a6320df

2 files changed

Lines changed: 299 additions & 0 deletions

File tree

src/maxtext/common/checkpointing.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,12 @@ def create_orbax_emergency_checkpoint_manager(
490490

491491
persistent_p = gcs_utils.mkdir_and_check_permissions(persistent_checkpoint_dir)
492492

493+
# pure_nnx saves via to_checkpoint_dict (Linen params/opt_state/step plus an nnx_aux
494+
# subtree), but the emergency manager restores against the abstract it is built with.
495+
# Convert it the same way so it matches what is on disk; restore reshapes back to NNX.
496+
if isinstance(abstract_state, nnx.State):
497+
abstract_state = train_state_nnx.to_checkpoint_dict(abstract_state)
498+
493499
manager = EmergencyCheckpointManager(
494500
local_checkpoint_dir,
495501
persistent_p,
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# Copyright 2025-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 restoring pure_nnx emergency checkpoints.
16+
17+
pure_nnx saves in the on-disk checkpoint layout: Linen params/opt_state/step, plus an nnx_aux
18+
subtree for the rngs and batch stats Linen has no place for. The emergency manager, unlike the
19+
regular one, bakes in the abstract it is built with and restores against that, ignoring the
20+
restore-time item. So that abstract has to be in the on-disk layout already, or Orbax compares
21+
it against the checkpoint and raises.
22+
23+
checkpointing_nnx_roundtrip_test covers the same ground for the regular manager. The emergency
24+
manager writes one state tree, and nnx_aux is part of it, so the same partition has to survive
25+
there too. These tests check that it does.
26+
"""
27+
28+
import os
29+
import shutil
30+
import tempfile
31+
import unittest
32+
from types import SimpleNamespace
33+
from unittest import mock
34+
35+
from etils import epath
36+
from flax import nnx
37+
import jax
38+
import jax.numpy as jnp
39+
from maxtext.common import checkpointing
40+
from maxtext.common import train_state_nnx
41+
import optax
42+
43+
44+
class _Model(nnx.Module):
45+
"""Linear + batch-norm + dropout: exercises weights, batch stats, and rng together."""
46+
47+
def __init__(self, rngs: nnx.Rngs):
48+
self.linear = nnx.Linear(2, 3, rngs=rngs)
49+
self.bn = nnx.BatchNorm(3, rngs=rngs)
50+
self.dropout = nnx.Dropout(rate=0.5, rngs=rngs)
51+
52+
def __call__(self, x, deterministic=False):
53+
x = self.bn(self.linear(x), use_running_average=deterministic)
54+
return self.dropout(x, deterministic=deterministic)
55+
56+
57+
class _CacheModel(nnx.Module):
58+
"""Linear + dropout + a cache, to check ephemeral variables are not checkpointed."""
59+
60+
def __init__(self, rngs: nnx.Rngs):
61+
self.linear = nnx.Linear(2, 3, rngs=rngs)
62+
self.dropout = nnx.Dropout(rate=0.5, rngs=rngs)
63+
self.cache = nnx.Cache(jnp.zeros((3,)))
64+
65+
def __call__(self, x, deterministic=False):
66+
return self.dropout(self.linear(x), deterministic=deterministic)
67+
68+
69+
_TX = optax.adamw(1e-3)
70+
71+
# Rows must differ: batch-norm centres identical rows to zero, which zeroes the gradients and leaves
72+
# the adam moments at zero, so a dropped-moment restore would go unnoticed.
73+
_TRAIN_X = jnp.arange(8, dtype=jnp.float32).reshape(4, 2)
74+
75+
76+
def _config():
77+
"""Minimal config with the fields save/restore reads for a pure_nnx emergency run."""
78+
return SimpleNamespace(
79+
pure_nnx=True,
80+
enable_diloco=False,
81+
enable_checkpointing=True,
82+
enable_continuous_checkpointing=False,
83+
enable_emergency_checkpoint=True,
84+
enable_multi_tier_checkpointing=False,
85+
enable_autocheckpoint=False,
86+
checkpoint_period=1,
87+
local_checkpoint_period=1,
88+
async_checkpointing=False,
89+
dataset_type="tfds",
90+
lora=None,
91+
checkpoint_storage_target_data_file_size_bytes=checkpointing.DEFAULT_OCDBT_TARGET_DATA_FILE_SIZE,
92+
elastic_enabled=False,
93+
)
94+
95+
96+
def _abstract_state(model_cls):
97+
"""An abstract (ShapeDtypeStruct) nnx.State for `model_cls`.
98+
99+
Mirrors what get_abstract_state hands the emergency manager: SDS leaves with a replicated
100+
sharding so Orbax can build restore args in single- or multi-host CI.
101+
"""
102+
mesh = jax.sharding.Mesh(jax.devices(), ("x",))
103+
sharding = jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec())
104+
105+
def make():
106+
model = model_cls(nnx.Rngs(9))
107+
return nnx.state(train_state_nnx.TrainStateNNX(model, nnx.Optimizer(model, _TX, wrt=nnx.Param)))
108+
109+
return jax.tree.map(
110+
lambda x: jax.ShapeDtypeStruct(x.shape, x.dtype, sharding=sharding) if hasattr(x, "shape") else x,
111+
nnx.eval_shape(make),
112+
)
113+
114+
115+
def _unrestored(x):
116+
"""Whether `x` is a leaf the checkpoint didn't carry.
117+
118+
Used as `is_leaf` when comparing the round trip. `jax.tree_map` treats None as an empty
119+
subtree, so without this a dropped leaf is skipped and reads as a match; and an
120+
unmaterialized ShapeDtypeStruct reaches `jnp.asarray` and dies without naming the path.
121+
"""
122+
return x is None or isinstance(x, jax.ShapeDtypeStruct)
123+
124+
125+
def _leaf_equal(a, b):
126+
"""Value equality for one leaf of the round trip, handling PRNG keys (compared by key_data).
127+
128+
A leaf the checkpoint didn't carry -- None, or an unmaterialized ShapeDtypeStruct -- is
129+
unequal, so it is reported with its path rather than skipped.
130+
"""
131+
if _unrestored(a) or _unrestored(b):
132+
return a is None and b is None
133+
a, b = jnp.asarray(a), jnp.asarray(b)
134+
if a.shape != b.shape or a.dtype != b.dtype:
135+
return False
136+
if jnp.issubdtype(a.dtype, jax.dtypes.prng_key):
137+
return bool(jnp.array_equal(jax.random.key_data(a), jax.random.key_data(b)))
138+
return bool(jnp.allclose(a, b))
139+
140+
141+
class TestEmergencyManagerAbstractLayout(unittest.TestCase):
142+
"""The manager is built with a checkpoint-layout abstract, not the NNX one.
143+
144+
Orbax is mocked, so this runs in normal CI without an emergency-checkpoint setup.
145+
"""
146+
147+
def _abstract_handed_to_manager(self, abstract_state):
148+
"""Calls the constructor with Orbax mocked, and returns the abstract it was handed."""
149+
mesh = jax.sharding.Mesh(jax.devices(), ("x",))
150+
with (
151+
mock.patch.object(checkpointing, "EmergencyCheckpointManager") as manager_cls,
152+
mock.patch.object(checkpointing.gcs_utils, "mkdir_and_check_permissions", side_effect=epath.Path),
153+
tempfile.TemporaryDirectory() as d,
154+
):
155+
checkpointing.create_orbax_emergency_checkpoint_manager(
156+
os.path.join(d, "local"),
157+
os.path.join(d, "persist"),
158+
mesh,
159+
abstract_state,
160+
local_save_interval_steps=1,
161+
persistent_save_interval_steps=1,
162+
)
163+
return manager_cls.call_args.kwargs["abstract_state"]
164+
165+
def test_nnx_abstract_is_converted_to_checkpoint_layout(self):
166+
"""An NNX abstract is reshaped into the same on-disk layout the save path writes."""
167+
passed = self._abstract_handed_to_manager(_abstract_state(_Model))
168+
self.assertNotIsInstance(passed, nnx.State)
169+
# The on-disk keys, not the NNX model/optimizer roots that caused the mismatch: the optimizer
170+
# maps to opt_state/step, and nnx_aux carries the dropout rng and batch stats.
171+
self.assertCountEqual(["params", "opt_state", "step", "nnx_aux"], passed.keys())
172+
173+
def test_non_nnx_abstract_is_passed_through_unchanged(self):
174+
"""A Linen state is already in the on-disk layout, so it is handed over as-is."""
175+
linen_like = SimpleNamespace(params={"a": 1}, opt_state=(), step=0)
176+
self.assertIs(self._abstract_handed_to_manager(linen_like), linen_like)
177+
178+
179+
class TestEmergencySaveRestoreRoundTrip(unittest.TestCase):
180+
"""Real save->restore cycles through create_orbax_emergency_checkpoint_manager."""
181+
182+
def setUp(self):
183+
self._dir = tempfile.mkdtemp()
184+
self.addCleanup(shutil.rmtree, self._dir, ignore_errors=True)
185+
186+
# --- helpers ---------------------------------------------------------------
187+
188+
def _manager(self, model_cls, name):
189+
"""Returns an emergency manager built from `model_cls`'s abstract, under its own subdirectory."""
190+
try:
191+
return checkpointing.create_orbax_emergency_checkpoint_manager(
192+
os.path.join(self._dir, name, "local"),
193+
os.path.join(self._dir, name, "persist"),
194+
jax.sharding.Mesh(jax.devices(), ("x",)),
195+
_abstract_state(model_cls),
196+
local_save_interval_steps=1,
197+
persistent_save_interval_steps=1,
198+
)
199+
except Exception as e: # pylint: disable=broad-except
200+
raise unittest.SkipTest(f"emergency manager unavailable in this environment: {e}")
201+
202+
def _trained(self, model):
203+
"""One training step: advances step + dropout rng + batch stats + optimizer moments."""
204+
state = train_state_nnx.TrainStateNNX(model, nnx.Optimizer(model, _TX, wrt=nnx.Param))
205+
grads = nnx.grad(lambda m: jnp.mean(m(_TRAIN_X, deterministic=False) ** 2))(state.model)
206+
state.apply_gradients(grads)
207+
return state
208+
209+
def _save(self, manager, state, step=1):
210+
checkpointing.maybe_save_checkpoint(manager, nnx.state(state), _config(), data_iterator=None, step=step)
211+
manager.wait_until_finished()
212+
213+
def _restore(self, manager, model_cls, seed=123):
214+
"""The trainer's restore: fill the leaves the checkpoint didn't carry from a fresh init."""
215+
full, _ = checkpointing.load_state_if_possible(
216+
manager,
217+
data_iterator=None,
218+
load_parameters_from_path="",
219+
load_full_state_from_path="",
220+
checkpoint_storage_concurrent_gb=8,
221+
abstract_unboxed_pre_state=_abstract_state(model_cls),
222+
dataset_type="tfds",
223+
maxtext_config=_config(),
224+
)
225+
# The emergency branch hands back the nnx.State itself, not under an "items" key.
226+
model = model_cls(nnx.Rngs(seed))
227+
init = nnx.state(train_state_nnx.TrainStateNNX(model, nnx.Optimizer(model, _TX, wrt=nnx.Param)))
228+
merged = jax.tree.map(
229+
lambda ckpt, i: i if isinstance(ckpt, jax.ShapeDtypeStruct) else ckpt,
230+
full.to_pure_dict(), # checkpoint values, with placeholders where it carried nothing
231+
init.to_pure_dict(),
232+
is_leaf=lambda x: isinstance(x, jax.ShapeDtypeStruct),
233+
)
234+
nnx.replace_by_pure_dict(init, merged) # missing leaves keep their init value
235+
return init.to_pure_dict()
236+
237+
# --- full-state fidelity ---------------------------------------------------
238+
239+
def test_full_state_round_trip_is_exact(self):
240+
"""Every leaf -- weights, optimizer, dropout rng, batch stats -- round-trips unchanged."""
241+
manager = self._manager(_Model, "exact")
242+
state = self._trained(_Model(nnx.Rngs(0)))
243+
saved = nnx.state(state).to_pure_dict()
244+
self._save(manager, state)
245+
restored = self._restore(manager, _Model)
246+
247+
self.assertEqual(jax.tree_util.tree_structure(saved), jax.tree_util.tree_structure(restored))
248+
matches = jax.tree_util.tree_map(_leaf_equal, saved, restored, is_leaf=_unrestored)
249+
mismatched = [jax.tree_util.keystr(p) for p, ok in jax.tree_util.tree_leaves_with_path(matches) if not ok]
250+
self.assertEqual(mismatched, [], f"leaves differ after round trip: {mismatched}")
251+
# Nothing was skipped: every leaf of the saved state was actually compared.
252+
self.assertEqual(len(jax.tree_util.tree_leaves(saved, is_leaf=_unrestored)), len(jax.tree_util.tree_leaves(matches)))
253+
254+
def test_nnx_aux_restored_not_reset_to_init(self):
255+
"""The dropout rng, batch stats and optimizer step come from the checkpoint, not a fresh init.
256+
257+
A reset would leave the rng count at 0 and the batch-norm mean at its init value, and both
258+
keep the tree structure intact. So this checks the values, not just the shape.
259+
"""
260+
manager = self._manager(_Model, "aux")
261+
state = self._trained(_Model(nnx.Rngs(0)))
262+
saved = nnx.state(state).to_pure_dict()
263+
saved_count = int(saved["model"]["dropout"]["rngs"]["count"])
264+
self.assertGreater(saved_count, 0, "fixture: training did not advance the dropout rng")
265+
self._save(manager, state)
266+
restored = self._restore(manager, _Model)
267+
268+
self.assertEqual(int(restored["model"]["dropout"]["rngs"]["count"]), saved_count)
269+
self.assertTrue(jnp.allclose(restored["model"]["bn"]["mean"], saved["model"]["bn"]["mean"]))
270+
self.assertEqual(int(restored["optimizer"]["step"]), 1)
271+
272+
# --- exclusion of ephemeral variables --------------------------------------
273+
274+
def test_caches_excluded_from_checkpoint(self):
275+
"""A cache is ephemeral, so it is absent from the abstract the manager bakes in.
276+
277+
On restore it keeps its init value instead of the saved one, while the dropout rng in the
278+
same model still comes back from nnx_aux.
279+
"""
280+
manager = self._manager(_CacheModel, "cache")
281+
model = _CacheModel(nnx.Rngs(0))
282+
model.cache.value = jnp.ones((3,)) # the value that would come back if caches were checkpointed
283+
state = self._trained(model)
284+
self._save(manager, state)
285+
restored = self._restore(manager, _CacheModel) # a fresh init cache is zeros
286+
287+
self.assertTrue(jnp.allclose(restored["model"]["cache"], jnp.zeros((3,))))
288+
saved_count = int(nnx.state(state).to_pure_dict()["model"]["dropout"]["rngs"]["count"])
289+
self.assertEqual(int(restored["model"]["dropout"]["rngs"]["count"]), saved_count)
290+
291+
292+
if __name__ == "__main__":
293+
unittest.main()

0 commit comments

Comments
 (0)