Skip to content

Commit e533a50

Browse files
committed
Unify the NNX and Linen restore paths in load_state_if_possible
The NNX path had two early-return blocks that each duplicated the Linen restore flow, one for the standard manager and one for the emergency managers. Any change to restore logic had to be made in three places, and they had already drifted: the NNX grain branch dropped the iterator element that the Linen branch returns. pure_nnx saves in the Linen on-disk layout, so both paths restore the same tree. Convert the NNX abstract to that layout going in, run the existing match statement unchanged, and reshape what comes back on the way out. Linen behavior is unchanged. The weights-only branch's if/else folds into `_abstract_params`, which uses nnx.split_state rather than nnx.split -- the State-native API, without the unused GraphDef. That leaves the safetensors paths, which had the same duplication problem and three bugs behind it: The dynamic safetensors loader received the whole abstract state rather than the weights. For an NNX state that is fatal before any weight is read: the optimizer's opt_state is keyed by integer chain indices, and the loader flattens its target with a "." separator, so the load died in flatten_dict with `TypeError: sequence item 2: expected str instance, int found`. The loader now receives the weights, the way the weights-only Orbax load already does, and its result is unwrapped back into the NNX params state. A weight that no mapping covered came back as the unmaterialized leaf it went in as and reached the model as an untrained init value. Orbax does not flag this -- a shape conflict it rejects itself, but a weight the checkpoint simply does not carry it leaves as a placeholder. The weights-only Orbax load already makes that check; the dynamic load now makes it too, for both state types. `checkpoint_conversion_fn` arrives from config as a dotted string but was called directly, so it raised TypeError for any value that was set. It is now imported, and resolved before the checkpoint is read so a bad config fails immediately. Full-state safetensors also handles NNX now: the conversion fn produces MaxText's on-disk layout, which is what pure_nnx reads, so it goes through the same reshape as every other NNX restore.
1 parent 629fb1c commit e533a50

5 files changed

Lines changed: 368 additions & 95 deletions

File tree

src/maxtext/checkpoint_conversion/utils/load_dynamic.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,12 @@ def tensor_getter(key):
251251
return {"params": restored_params}
252252

253253

254-
def load_safetensors_dynamic_state(path, abstract_unboxed_pre_state, maxtext_config):
254+
def load_safetensors_dynamic_state(path, abstract_params, maxtext_config):
255255
"""Main entry point to dynamically build and load safetensors into MaxText format.
256256
257+
`abstract_params` is the weights of the target state -- Linen's `params` collection, or the
258+
NNX params state -- not the full train state; the HF param mappings name weights only.
259+
257260
Splits execution into:
258261
1. Deriving Mappings
259262
2. Loading Sharded arrays directly to TPUs
@@ -349,11 +352,7 @@ def load_safetensors_dynamic_state(path, abstract_unboxed_pre_state, maxtext_con
349352
param_map_mt_to_hf, hook_fn_map_mt = get_hf_config_and_mappings(maxtext_config)
350353
max_logging.log(f"[1/3] Mappings derived in {time.time() - t_total:.2f}s")
351354

352-
target_tree = (
353-
abstract_unboxed_pre_state.to_pure_dict()
354-
if isinstance(abstract_unboxed_pre_state, nnx.State)
355-
else abstract_unboxed_pre_state.params
356-
)
355+
target_tree = abstract_params.to_pure_dict() if isinstance(abstract_params, nnx.State) else abstract_params
357356

358357
t1 = time.time()
359358
hf_state = load_sharded_hf_state(path)

src/maxtext/common/checkpointing.py

Lines changed: 85 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
"""Create an Orbax CheckpointManager with specified (Async or not) Checkpointer."""
1717

18+
import importlib
1819
import time
1920
from typing import Any
2021

@@ -158,33 +159,58 @@ def _load_linen_checkpoint_into_nnx(
158159
restore_args = ocp.checkpoint_utils.construct_restore_args(linen_abstract)
159160
restored = ocp.args.PyTreeRestore(item=linen_abstract, restore_args=restore_args, partial_restore=True)
160161
restored = ckptr.restore(epath.Path(path), args=restored)
161-
_raise_on_weight_mismatch(*_expected_and_restored_params(abstract_nnx_state, restored))
162-
return _linen_items_to_nnx(restored, abstract_nnx_state)
162+
return _restored_linen_to_nnx(restored, abstract_nnx_state)
163163

164164

165-
def _restore_emergency_linen_checkpoint_into_nnx(
166-
checkpoint_manager,
167-
step,
168-
abstract_nnx_state,
169-
map_to_pspec,
170-
):
171-
"""Restores an emergency Linen-layout checkpoint into an NNX state.
165+
def _restored_linen_to_nnx(restored_linen, abstract_nnx_state):
166+
"""Reshapes a restored Linen-layout tree into the NNX state.
172167
173-
The `nnx_aux` subtree is stored inside `items`, so an emergency checkpoint
174-
carries it too; it's restored when present and otherwise kept at its fresh
175-
init value. A genuinely-missing weight raises.
168+
Raises if the checkpoint is missing a weight. Every NNX restore path ends here: the load
169+
itself is the Linen one, since pure_nnx reads and writes the Linen on-disk layout.
176170
"""
177-
max_logging.log(f"Restoring emergency Linen-layout checkpoint into NNX state at step {step}")
178-
linen_abstract = train_state_nnx.to_checkpoint_dict(abstract_nnx_state)
179-
restore_args = jax.tree_util.tree_map(map_to_pspec, linen_abstract)
180-
checkpoint_args = ocp.args.PyTreeRestore(
181-
item=linen_abstract,
182-
restore_args=restore_args,
183-
partial_restore=True,
184-
)
185-
restored = checkpoint_manager.restore(step, args=Composite(state=checkpoint_args)).state
186-
_raise_on_weight_mismatch(*_expected_and_restored_params(abstract_nnx_state, restored))
187-
return _linen_items_to_nnx(restored, abstract_nnx_state)
171+
_raise_on_weight_mismatch(*_expected_and_restored_params(abstract_nnx_state, restored_linen))
172+
return _linen_items_to_nnx(restored_linen, abstract_nnx_state)
173+
174+
175+
def _abstract_params(abstract_unboxed_pre_state):
176+
"""Returns the state's weights: the NNX Param subtree, or Linen's `params` collection."""
177+
if isinstance(abstract_unboxed_pre_state, nnx.State):
178+
return nnx.split_state(abstract_unboxed_pre_state.model, nnx.Param, ...)[0]
179+
return abstract_unboxed_pre_state.params
180+
181+
182+
def _bare_weights(tree):
183+
"""Strips the Flax `params` collection wrapper so weights compare at the same depth.
184+
185+
A Linen params tree is the collection, an NNX one the bare weights; the dynamic
186+
safetensors loader always returns the collection.
187+
"""
188+
return tree["params"] if isinstance(tree, dict) and set(tree) == {"params"} else tree
189+
190+
191+
def _resolve_conversion_fn(checkpoint_conversion_fn):
192+
"""Returns `checkpoint_conversion_fn` as a callable.
193+
194+
Config carries it as a dotted string ("my_pkg.my_module.my_fn"), so it has to be imported
195+
before it can be called. A callable is used as is.
196+
"""
197+
if checkpoint_conversion_fn is None:
198+
raise ValueError(
199+
"source_checkpoint_layout='safetensors' needs `checkpoint_conversion_fn` to map the "
200+
"checkpoint's weights onto the model's, e.g. checkpoint_conversion_fn=my_pkg.my_module.my_fn."
201+
)
202+
if callable(checkpoint_conversion_fn):
203+
return checkpoint_conversion_fn
204+
module_name, _, fn_name = str(checkpoint_conversion_fn).rpartition(".")
205+
if not module_name:
206+
raise ValueError(f"`checkpoint_conversion_fn` must be a dotted path to a function, got {checkpoint_conversion_fn!r}.")
207+
try:
208+
fn = getattr(importlib.import_module(module_name), fn_name, None)
209+
except ImportError as e:
210+
raise ValueError(f"Could not import `checkpoint_conversion_fn` {checkpoint_conversion_fn!r}: {e}") from e
211+
if not callable(fn):
212+
raise ValueError(f"`checkpoint_conversion_fn` {checkpoint_conversion_fn!r} is not a function.")
213+
return fn
188214

189215

190216
def _load_full_state_from_path(
@@ -227,6 +253,8 @@ def _load_full_state_from_path(
227253
with context:
228254
return ocp_v1.load_pytree(path, abstract_unboxed_pre_state)
229255
elif source_checkpoint_layout == "safetensors":
256+
# Resolved first, so a bad config fails before the weights are read.
257+
conversion_fn = _resolve_conversion_fn(checkpoint_conversion_fn)
230258
context = ocp_v1.Context(checkpoint_layout=ocp_v1.options.CheckpointLayout.SAFETENSORS)
231259
with context:
232260
metadata = ocp_v1.pytree_metadata(path)
@@ -238,7 +266,11 @@ def combine_sharding(sds, shardings):
238266

239267
sharded_abstract_state = jax.tree.map(combine_sharding, simple_abstract_state, shardings)
240268
pre_transformed_state = ocp_v1.load_pytree(path, sharded_abstract_state)
241-
state = checkpoint_conversion_fn(pre_transformed_state)
269+
state = conversion_fn(pre_transformed_state)
270+
# The conversion fn returns MaxText's on-disk (Linen) layout, which is what pure_nnx reads,
271+
# so NNX needs the same reshape as every other restore. An NNX state passes through.
272+
if isinstance(abstract_unboxed_pre_state, nnx.State) and not isinstance(state, nnx.State):
273+
state = _restored_linen_to_nnx(state, abstract_unboxed_pre_state)
242274
return state
243275
else:
244276
raise ocp_v1.errors.InvalidLayoutError(f"Unknown checkpoint layout: {source_checkpoint_layout}")
@@ -547,49 +579,12 @@ def map_to_pspec(data):
547579
)
548580
ocp.type_handlers.register_type_handler(jax.Array, array_handler, override=True)
549581

550-
# pure_nnx saves in the Linen on-disk layout; restore that layout (weights +
551-
# opt_state + step + nnx_aux), restoring the grain iterator in place when
552-
# present, then reshape it back into the NNX state.
553-
# (Emergency managers use their own restore path below.)
554-
if isinstance(abstract_unboxed_pre_state, nnx.State) and not isinstance(
555-
checkpoint_manager,
556-
(EmergencyCheckpointManager, EmergencyReplicatorCheckpointManager),
557-
):
558-
linen_abstract = train_state_nnx.to_checkpoint_dict(abstract_unboxed_pre_state)
559-
restore_args = jax.tree_util.tree_map(map_to_pspec, linen_abstract)
560-
checkpoint_args = ocp.args.PyTreeRestore(item=linen_abstract, restore_args=restore_args, partial_restore=True)
561-
if (
562-
dataset_type == "grain"
563-
and data_iterator
564-
and not isinstance(data_iterator, PlaceHolderDataIterator)
565-
and (checkpoint_manager.directory / str(step) / "iter").exists()
566-
):
567-
restored, _ = grain_utility.restore_grain_iterator(
568-
checkpoint_manager, step, data_iterator, checkpoint_args, expansion_factor_real_data
569-
)
570-
else:
571-
restored = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args))
572-
_raise_on_weight_mismatch(*_expected_and_restored_params(abstract_unboxed_pre_state, restored["items"]))
573-
restored_nnx = _linen_items_to_nnx(restored["items"], abstract_unboxed_pre_state)
574-
return ({"items": restored_nnx}, None)
575-
576-
if isinstance(abstract_unboxed_pre_state, nnx.State) and isinstance(
577-
checkpoint_manager,
578-
(EmergencyCheckpointManager, EmergencyReplicatorCheckpointManager),
579-
):
580-
restored = _restore_emergency_linen_checkpoint_into_nnx(
581-
checkpoint_manager,
582-
step,
583-
abstract_unboxed_pre_state,
584-
map_to_pspec,
585-
)
586-
return (
587-
restored,
588-
None,
589-
)
590-
591-
# Only Linen TrainState reaches here; the NNX cases returned above.
592-
restore_target = abstract_unboxed_pre_state
582+
# pure_nnx saves in the Linen on-disk layout, so both paths restore the same tree through
583+
# the same manager calls below: convert the NNX abstract going in, reshape it coming out.
584+
is_nnx = isinstance(abstract_unboxed_pre_state, nnx.State)
585+
restore_target = (
586+
train_state_nnx.to_checkpoint_dict(abstract_unboxed_pre_state) if is_nnx else abstract_unboxed_pre_state
587+
)
593588
restore_args = jax.tree_util.tree_map(map_to_pspec, restore_target)
594589
checkpoint_args = ocp.args.PyTreeRestore(
595590
item=restore_target,
@@ -609,6 +604,8 @@ def map_to_pspec(data):
609604
),
610605
):
611606
restored = checkpoint_manager.restore(step, args=Composite(state=checkpoint_args)).state
607+
if is_nnx:
608+
restored = _restored_linen_to_nnx(restored, abstract_unboxed_pre_state)
612609
return (
613610
restored,
614611
None,
@@ -625,30 +622,44 @@ def map_to_pspec(data):
625622
and not isinstance(data_iterator, PlaceHolderDataIterator)
626623
and (checkpoint_manager.directory / str(step) / "iter").exists()
627624
):
628-
return grain_utility.restore_grain_iterator(
625+
restored, iterator = grain_utility.restore_grain_iterator(
629626
checkpoint_manager,
630627
step,
631628
data_iterator,
632629
checkpoint_args,
633630
expansion_factor_real_data,
634631
)
632+
if is_nnx:
633+
restored = {"items": _restored_linen_to_nnx(restored["items"], abstract_unboxed_pre_state)}
634+
return (restored, iterator)
635635
# Case 3: Default/Fallback case.
636636
# This case acts as a wildcard ('_') and matches if none of the preceding cases were met.
637637
case _:
638638
restored = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args))
639+
if is_nnx:
640+
restored = {"items": _restored_linen_to_nnx(restored["items"], abstract_unboxed_pre_state)}
639641
return (restored, None)
640642

641643
if source_checkpoint_layout == "safetensors_dynamic":
642644
path = load_parameters_from_path or load_full_state_from_path
643645
max_logging.log(f"Dynamic On-the-Fly Formatting: Loading SafeTensors from {path}")
644646

645-
return load_safetensors_dynamic_state(path, abstract_unboxed_pre_state, maxtext_config)
647+
# Weights-only for both paths, so the loader gets the weights rather than the whole state:
648+
# the HF param mappings name weights, and an NNX state hides them under `model`.
649+
params = _abstract_params(abstract_unboxed_pre_state)
650+
restored, restored_params = load_safetensors_dynamic_state(path, params, maxtext_config)
651+
is_nnx = isinstance(params, nnx.State)
652+
# A weight no HF mapping covered comes back unmaterialized and would reach the model as an
653+
# untrained init value. Same check the Orbax weights-only load makes.
654+
_raise_on_weight_mismatch(_bare_weights(params.to_pure_dict() if is_nnx else params), _bare_weights(restored_params))
655+
if is_nnx:
656+
# The loader returns the Linen `params` collection; NNX holds bare weights, so unwrap it
657+
# back into the params state, the shape load_params_from_path returns.
658+
nnx.replace_by_pure_dict(params, restored_params["params"])
659+
return restored, params
660+
return restored, restored_params
646661
elif load_parameters_from_path != "":
647-
if isinstance(abstract_unboxed_pre_state, nnx.State):
648-
_, params, _ = nnx.split(abstract_unboxed_pre_state.model, nnx.Param, ...)
649-
else:
650-
params = abstract_unboxed_pre_state.params
651-
662+
params = _abstract_params(abstract_unboxed_pre_state)
652663
restored_params = load_params_from_path(
653664
load_parameters_from_path,
654665
params,

0 commit comments

Comments
 (0)