Skip to content

Commit 82ef7bf

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 80646f7 commit 82ef7bf

5 files changed

Lines changed: 369 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: 86 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""Create an Orbax CheckpointManager with specified (Async or not) Checkpointer."""
1717

1818
import datetime
19+
import importlib
1920
import time
2021
from typing import Any
2122

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

163163

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

188214

189215
def _load_full_state_from_path(
@@ -226,6 +252,8 @@ def _load_full_state_from_path(
226252
with context:
227253
return ocp_v1.load_pytree(path, abstract_unboxed_pre_state)
228254
elif source_checkpoint_layout == "safetensors":
255+
# Resolved first, so a bad config fails before the weights are read.
256+
conversion_fn = _resolve_conversion_fn(checkpoint_conversion_fn)
229257
context = ocp_v1.Context(checkpoint_layout=ocp_v1.options.CheckpointLayout.SAFETENSORS)
230258
with context:
231259
metadata = ocp_v1.pytree_metadata(path)
@@ -237,7 +265,11 @@ def combine_sharding(sds, shardings):
237265

238266
sharded_abstract_state = jax.tree.map(combine_sharding, simple_abstract_state, shardings)
239267
pre_transformed_state = ocp_v1.load_pytree(path, sharded_abstract_state)
240-
state = checkpoint_conversion_fn(pre_transformed_state)
268+
state = conversion_fn(pre_transformed_state)
269+
# The conversion fn returns MaxText's on-disk (Linen) layout, which is what pure_nnx reads,
270+
# so NNX needs the same reshape as every other restore. An NNX state passes through.
271+
if isinstance(abstract_unboxed_pre_state, nnx.State) and not isinstance(state, nnx.State):
272+
state = _restored_linen_to_nnx(state, abstract_unboxed_pre_state)
241273
return state
242274
else:
243275
raise ocp_v1.errors.InvalidLayoutError(f"Unknown checkpoint layout: {source_checkpoint_layout}")
@@ -406,6 +438,11 @@ def load_state_if_possible(
406438
set.
407439
"""
408440

441+
# pure_nnx saves in the Linen on-disk layout, so every branch below loads the same tree Linen
442+
# does: the NNX abstract is converted to that layout going in, and what comes back is reshaped
443+
# into the NNX state on the way out.
444+
is_nnx = isinstance(abstract_unboxed_pre_state, nnx.State)
445+
409446
if checkpoint_manager is not None:
410447
max_logging.log("checkpoint manager exists so trying to load this run's existing checkpoint")
411448

@@ -437,49 +474,9 @@ def map_to_pspec(data):
437474
)
438475
ocp.type_handlers.register_type_handler(jax.Array, array_handler, override=True)
439476

440-
# pure_nnx saves in the Linen on-disk layout; restore that layout (weights +
441-
# opt_state + step + nnx_aux), restoring the grain iterator in place when
442-
# present, then reshape it back into the NNX state.
443-
# (Emergency managers use their own restore path below.)
444-
if isinstance(abstract_unboxed_pre_state, nnx.State) and not isinstance(
445-
checkpoint_manager,
446-
(EmergencyCheckpointManager, EmergencyReplicatorCheckpointManager),
447-
):
448-
linen_abstract = train_state_nnx.to_checkpoint_dict(abstract_unboxed_pre_state)
449-
restore_args = jax.tree_util.tree_map(map_to_pspec, linen_abstract)
450-
checkpoint_args = ocp.args.PyTreeRestore(item=linen_abstract, restore_args=restore_args, partial_restore=True)
451-
if (
452-
dataset_type == "grain"
453-
and data_iterator
454-
and not isinstance(data_iterator, PlaceHolderDataIterator)
455-
and (checkpoint_manager.directory / str(step) / "iter").exists()
456-
):
457-
restored, _ = grain_utility.restore_grain_iterator(
458-
checkpoint_manager, step, data_iterator, checkpoint_args, expansion_factor_real_data
459-
)
460-
else:
461-
restored = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args))
462-
_raise_on_weight_mismatch(*_expected_and_restored_params(abstract_unboxed_pre_state, restored["items"]))
463-
restored_nnx = _linen_items_to_nnx(restored["items"], abstract_unboxed_pre_state)
464-
return ({"items": restored_nnx}, None)
465-
466-
if isinstance(abstract_unboxed_pre_state, nnx.State) and isinstance(
467-
checkpoint_manager,
468-
(EmergencyCheckpointManager, EmergencyReplicatorCheckpointManager),
469-
):
470-
restored = _restore_emergency_linen_checkpoint_into_nnx(
471-
checkpoint_manager,
472-
step,
473-
abstract_unboxed_pre_state,
474-
map_to_pspec,
475-
)
476-
return (
477-
restored,
478-
None,
479-
)
480-
481-
# Only Linen TrainState reaches here; the NNX cases returned above.
482-
restore_target = abstract_unboxed_pre_state
477+
restore_target = (
478+
train_state_nnx.to_checkpoint_dict(abstract_unboxed_pre_state) if is_nnx else abstract_unboxed_pre_state
479+
)
483480
restore_args = jax.tree_util.tree_map(map_to_pspec, restore_target)
484481
checkpoint_args = ocp.args.PyTreeRestore(
485482
item=restore_target,
@@ -499,6 +496,8 @@ def map_to_pspec(data):
499496
),
500497
):
501498
restored = checkpoint_manager.restore(step, args=Composite(state=checkpoint_args)).state
499+
if is_nnx:
500+
restored = _restored_linen_to_nnx(restored, abstract_unboxed_pre_state)
502501
return (
503502
restored,
504503
None,
@@ -515,30 +514,43 @@ def map_to_pspec(data):
515514
and not isinstance(data_iterator, PlaceHolderDataIterator)
516515
and (checkpoint_manager.directory / str(step) / "iter").exists()
517516
):
518-
return grain_utility.restore_grain_iterator(
517+
restored, iterator = grain_utility.restore_grain_iterator(
519518
checkpoint_manager,
520519
step,
521520
data_iterator,
522521
checkpoint_args,
523522
expansion_factor_real_data,
524523
)
524+
if is_nnx:
525+
restored = {"items": _restored_linen_to_nnx(restored["items"], abstract_unboxed_pre_state)}
526+
return (restored, iterator)
525527
# Case 3: Default/Fallback case.
526528
# This case acts as a wildcard ('_') and matches if none of the preceding cases were met.
527529
case _:
528530
restored = checkpoint_manager.restore(step, args=Composite(items=checkpoint_args))
531+
if is_nnx:
532+
restored = {"items": _restored_linen_to_nnx(restored["items"], abstract_unboxed_pre_state)}
529533
return (restored, None)
530534

531535
if source_checkpoint_layout == "safetensors_dynamic":
532536
path = load_parameters_from_path or load_full_state_from_path
533537
max_logging.log(f"Dynamic On-the-Fly Formatting: Loading SafeTensors from {path}")
534538

535-
return load_safetensors_dynamic_state(path, abstract_unboxed_pre_state, maxtext_config)
539+
# Weights-only for both paths, so the loader gets the weights rather than the whole state:
540+
# the HF param mappings name weights, and an NNX state hides them under `model`.
541+
params = _abstract_params(abstract_unboxed_pre_state)
542+
restored, restored_params = load_safetensors_dynamic_state(path, params, maxtext_config)
543+
# A weight no HF mapping covered comes back unmaterialized and would reach the model as an
544+
# untrained init value. Same check the Orbax weights-only load makes.
545+
_raise_on_weight_mismatch(_bare_weights(params.to_pure_dict() if is_nnx else params), _bare_weights(restored_params))
546+
if is_nnx:
547+
# The loader returns the Linen `params` collection; NNX holds bare weights, so unwrap it
548+
# back into the params state, the shape load_params_from_path returns.
549+
nnx.replace_by_pure_dict(params, restored_params["params"])
550+
return restored, params
551+
return restored, restored_params
536552
elif load_parameters_from_path != "":
537-
if isinstance(abstract_unboxed_pre_state, nnx.State):
538-
_, params, _ = nnx.split(abstract_unboxed_pre_state.model, nnx.Param, ...)
539-
else:
540-
params = abstract_unboxed_pre_state.params
541-
553+
params = _abstract_params(abstract_unboxed_pre_state)
542554
restored_params = load_params_from_path(
543555
load_parameters_from_path,
544556
params,

0 commit comments

Comments
 (0)