|
| 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 | +"""Emergency-checkpoint tests for the pure_nnx layout. |
| 16 | +
|
| 17 | +Regression coverage for the EMC restore failure where the emergency manager was |
| 18 | +constructed with an NNX-native abstract (model/optimizer) while pure_nnx runs save |
| 19 | +in the Linen on-disk layout (params/opt_state/step). The manager bakes its abstract |
| 20 | +in and restores against it, so the two must agree -- otherwise Orbax raises |
| 21 | +"User-provided restore item and on-disk value metadata tree structures do not match". |
| 22 | +""" |
| 23 | + |
| 24 | +import os |
| 25 | +import tempfile |
| 26 | +import unittest |
| 27 | +from types import SimpleNamespace |
| 28 | +from unittest import mock |
| 29 | + |
| 30 | +from etils import epath |
| 31 | +from flax import nnx |
| 32 | +import jax |
| 33 | +import jax.numpy as jnp |
| 34 | +from maxtext.common import checkpointing |
| 35 | +from maxtext.common import train_state_nnx |
| 36 | +import optax |
| 37 | + |
| 38 | + |
| 39 | +class _Model(nnx.Module): |
| 40 | + """Linear + dropout, so the NNX state carries model, optimizer, and rngs.""" |
| 41 | + |
| 42 | + def __init__(self, rngs: nnx.Rngs): |
| 43 | + self.linear = nnx.Linear(2, 3, rngs=rngs) |
| 44 | + self.dropout = nnx.Dropout(rate=0.5, rngs=rngs) |
| 45 | + |
| 46 | + def __call__(self, x, deterministic=False): |
| 47 | + return self.dropout(self.linear(x), deterministic=deterministic) |
| 48 | + |
| 49 | + |
| 50 | +_TX = optax.adam(1e-3) |
| 51 | + |
| 52 | + |
| 53 | +def _nnx_abstract_state(): |
| 54 | + """An abstract NNX state (model/optimizer), the shape pure_nnx hands the manager.""" |
| 55 | + |
| 56 | + def make(): |
| 57 | + model = _Model(nnx.Rngs(0)) |
| 58 | + return nnx.state(train_state_nnx.TrainStateNNX(model, nnx.Optimizer(model, _TX, wrt=nnx.Param))) |
| 59 | + |
| 60 | + return nnx.eval_shape(make) |
| 61 | + |
| 62 | + |
| 63 | +class TestEmergencyManagerAbstractLayout(unittest.TestCase): |
| 64 | + """The regression guard: the emergency manager must be built with a Linen abstract. |
| 65 | +
|
| 66 | + Fast and environment-independent -- mocks the Orbax manager, so it catches a |
| 67 | + reintroduction of the bug in CI without needing a real multi-host EMC run. |
| 68 | + """ |
| 69 | + |
| 70 | + def _create(self, abstract_state): |
| 71 | + """Runs create_orbax_emergency_checkpoint_manager with the Orbax manager mocked out.""" |
| 72 | + mesh = jax.sharding.Mesh(jax.devices(), ("x",)) |
| 73 | + with ( |
| 74 | + mock.patch.object(checkpointing, "EmergencyCheckpointManager") as manager_cls, |
| 75 | + mock.patch.object(checkpointing.gcs_utils, "mkdir_and_check_permissions", side_effect=lambda p: epath.Path(p)), |
| 76 | + ): |
| 77 | + with tempfile.TemporaryDirectory() as d: |
| 78 | + checkpointing.create_orbax_emergency_checkpoint_manager( |
| 79 | + os.path.join(d, "local"), |
| 80 | + os.path.join(d, "persist"), |
| 81 | + mesh, |
| 82 | + abstract_state, |
| 83 | + local_save_interval_steps=1, |
| 84 | + persistent_save_interval_steps=1, |
| 85 | + ) |
| 86 | + return manager_cls.call_args.kwargs["abstract_state"] |
| 87 | + |
| 88 | + def test_nnx_abstract_is_converted_to_linen_layout(self): |
| 89 | + """An NNX-native abstract (model/optimizer) is reshaped to the Linen on-disk layout.""" |
| 90 | + passed = self._create(_nnx_abstract_state()) |
| 91 | + self.assertNotIsInstance(passed, nnx.State) |
| 92 | + # Linen on-disk keys, not the NNX model/optimizer roots that caused the mismatch. |
| 93 | + self.assertEqual(set(passed.keys()), {"params", "opt_state", "step"}) |
| 94 | + self.assertNotIn("model", passed) |
| 95 | + self.assertNotIn("optimizer", passed) |
| 96 | + |
| 97 | + def test_non_nnx_abstract_is_passed_through_unchanged(self): |
| 98 | + """A Linen TrainState (not an nnx.State) is forwarded as-is, not double-converted.""" |
| 99 | + sentinel = SimpleNamespace(params={"a": 1}, opt_state=(), step=0) # stand-in Linen state |
| 100 | + passed = self._create(sentinel) |
| 101 | + self.assertIs(passed, sentinel) |
| 102 | + |
| 103 | + |
| 104 | +class TestEmergencySaveRestoreRoundTrip(unittest.TestCase): |
| 105 | + """End-to-end: save (Linen) then restore through the real emergency manager. |
| 106 | +
|
| 107 | + Reproduces the production DAG. Skips where a single-host EMC manager can't be |
| 108 | + constructed (some CI backends); runs fully on TPU/GPU/CPU that support it. |
| 109 | + """ |
| 110 | + |
| 111 | + def setUp(self): |
| 112 | + self._dir = tempfile.mkdtemp() |
| 113 | + |
| 114 | + def _config(self): |
| 115 | + return SimpleNamespace( |
| 116 | + pure_nnx=True, |
| 117 | + enable_diloco=False, |
| 118 | + enable_checkpointing=True, |
| 119 | + enable_continuous_checkpointing=False, |
| 120 | + enable_emergency_checkpoint=True, |
| 121 | + enable_autocheckpoint=False, |
| 122 | + enable_multi_tier_checkpointing=False, |
| 123 | + checkpoint_period=1, |
| 124 | + local_checkpoint_period=1, |
| 125 | + async_checkpointing=False, |
| 126 | + dataset_type="tfds", |
| 127 | + lora=None, |
| 128 | + checkpoint_storage_target_data_file_size_bytes=checkpointing.DEFAULT_OCDBT_TARGET_DATA_FILE_SIZE, |
| 129 | + elastic_enabled=False, |
| 130 | + ) |
| 131 | + |
| 132 | + def test_emergency_round_trip_reshapes_back_to_nnx(self): |
| 133 | + mesh = jax.sharding.Mesh(jax.devices(), ("x",)) |
| 134 | + sharding = jax.sharding.NamedSharding(mesh, jax.sharding.PartitionSpec()) |
| 135 | + abstract = jax.tree.map( |
| 136 | + lambda x: jax.ShapeDtypeStruct(x.shape, x.dtype, sharding=sharding) if hasattr(x, "shape") else x, |
| 137 | + _nnx_abstract_state(), |
| 138 | + ) |
| 139 | + |
| 140 | + try: |
| 141 | + manager = checkpointing.create_orbax_emergency_checkpoint_manager( |
| 142 | + os.path.join(self._dir, "local"), |
| 143 | + os.path.join(self._dir, "persist"), |
| 144 | + mesh, |
| 145 | + abstract, |
| 146 | + local_save_interval_steps=1, |
| 147 | + persistent_save_interval_steps=1, |
| 148 | + ) |
| 149 | + except Exception as e: # pylint: disable=broad-except |
| 150 | + raise unittest.SkipTest(f"EMC manager unavailable in this environment: {e}") |
| 151 | + |
| 152 | + # Train one step so weights/optimizer/step are non-trivial, then save (Linen layout). |
| 153 | + model = _Model(nnx.Rngs(0)) |
| 154 | + state = train_state_nnx.TrainStateNNX(model, nnx.Optimizer(model, _TX, wrt=nnx.Param)) |
| 155 | + grads = nnx.grad(lambda m: jnp.mean(m(jnp.ones((4, 2)), deterministic=False) ** 2))(state.model) |
| 156 | + state.apply_gradients(grads) |
| 157 | + saved_kernel = jnp.asarray(nnx.state(state).to_pure_dict()["model"]["linear"]["kernel"]) |
| 158 | + |
| 159 | + checkpointing.maybe_save_checkpoint(manager, nnx.state(state), self._config(), data_iterator=None, step=1) |
| 160 | + manager.wait_until_finished() |
| 161 | + |
| 162 | + # Restore: must not raise the structural-mismatch ValueError, and must come back as NNX. |
| 163 | + full, _ = checkpointing.load_state_if_possible( |
| 164 | + manager, |
| 165 | + data_iterator=None, |
| 166 | + load_parameters_from_path="", |
| 167 | + load_full_state_from_path="", |
| 168 | + checkpoint_storage_concurrent_gb=8, |
| 169 | + abstract_unboxed_pre_state=abstract, |
| 170 | + dataset_type="tfds", |
| 171 | + maxtext_config=self._config(), |
| 172 | + ) |
| 173 | + self.assertIn("model", full) |
| 174 | + self.assertIn("optimizer", full) |
| 175 | + self.assertNotIn("params", full) |
| 176 | + self.assertTrue(jnp.allclose(full["model"]["linear"]["kernel"], saved_kernel)) |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + unittest.main() |
0 commit comments