|
| 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