Skip to content

feat: Auto-detect and convert Tunix post-training checkpoints on load - #4951

Draft
hsuan-lun-chiang wants to merge 1 commit into
mainfrom
feat/convert-post-training-tunix-onload
Draft

feat: Auto-detect and convert Tunix post-training checkpoints on load#4951
hsuan-lun-chiang wants to merge 1 commit into
mainfrom
feat/convert-post-training-tunix-onload

Conversation

@hsuan-lun-chiang

@hsuan-lun-chiang hsuan-lun-chiang commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Overview

This PR updates the checkpoint loader in MaxText to auto-detect and dynamically convert Tunix post-training checkpoints on load, allowing downstream MaxText pre-training runs to restore from Tunix DPO/SFT checkpoints seamlessly.

Key Changes

  • Auto-Detection: Identifies Tunix-formatted checkpoints by inspecting the checkpoint layout (presence of model_params collection).
  • On-the-fly PyTree Reshaping:
    • Dynamically unwraps Optax inject_hyperparams wrapper states (_drop_inject_hyperparams).
    • Strips LoRA adapter layers (_drop_adapter_level) when mapping from adapter states to base pre-training states, handling dicts, lists, tuples, and namedtuples.
    • Correctly repacks base adapter levels (_add_adapter_level) when querying metadata or loading checkpoints with has_base=True.
  • model_creation_utils.from_pretrained: Directs parameter inspection and single-item PyTree restore to model_params when loading Tunix checkpoints.
  • E2E Validation: Validated end-to-end matrix for both gemma3-4b and gemma4-31b (Scanned DPO and SFT) with zero shape or key mismatches across multi-chip TPU topologies (v6e-32).

Checklist

  • Tested loading Tunix checkpoints with MaxText pre-training
  • Verified on-the-fly PyTree reshaping
  • Ran unit tests and pre-commit linters

@google-cla

google-cla Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements on-the-fly detection and conversion of Tunix-formatted checkpoints during the pre-training load process, avoiding manual checkpoint conversion. The feedback highlights several critical issues: first, setting metadata to None on exception in model_creation_utils.py will cause an AttributeError when accessing metadata.item_metadata later; second, _drop_adapter_level must recurse into lists, tuples, and namedtuples to handle nested optimizer states; and third, an _add_adapter_level helper is required to correctly wrap optimizer state fields in base when restoring adapter checkpoints to prevent structure mismatches.

Comment on lines +956 to +962
try:
metadata = ckptr.metadata(config.load_parameters_path)
except Exception:
metadata = None

# Get the structure of checkpoint in `config.load_parameters_path`
metadata = ckptr.metadata(config.load_parameters_path)
if metadata is None or metadata.item_metadata is None:
# Only error out if the directory doesn't even exist, defer metadata checks to the loader.
if not epath.Path(config.load_parameters_path).exists():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

If config.load_parameters_path points to a Tunix checkpoint, ckptr.metadata will raise an exception (since Checkpointer cannot read metadata from a multi-item CheckpointManager directory directly), and metadata will be set to None.

However, subsequent lines in from_pretrained (such as line 1019: metadata.item_metadata.tree.keys(), and lines 1088, 1089) access metadata directly without checking if it is None. This will cause an immediate AttributeError: 'NoneType' object has no attribute 'item_metadata' crash.

To fix this and seamlessly support Tunix checkpoints in from_pretrained, we should detect if the path contains a model_params subdirectory, and if so, point to it. This allows Checkpointer to successfully read the metadata and restore the parameters.

Note: You should also update line 1125 (which is outside this diff hunk) to use load_params_path instead of config.load_parameters_path for the restore call:

      restored = ckptr.restore(
          epath.Path(load_params_path),
          ...
      )
      load_params_path = epath.Path(config.load_parameters_path)
      if (load_params_path / "model_params").exists():
        load_params_path = load_params_path / "model_params"

      try:
        metadata = ckptr.metadata(load_params_path)
      except Exception:
        metadata = None

      # Only error out if the directory doesn't even exist, defer metadata checks to the loader.
      if not load_params_path.exists():

Comment on lines +269 to +274
def _drop_adapter_level(tree):
if isinstance(tree, dict):
if set(tree) == {"base"}:
return _drop_adapter_level(tree["base"])
return {k: _drop_adapter_level(v) for k, v in tree.items()}
return tree

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of _drop_adapter_level only recurses into dictionaries. However, in Optax, the optimizer state (optimizer_state) is typically represented as a tuple of NamedTuples (e.g., ScaleByAdamState). Because _drop_adapter_level does not recurse into lists, tuples, or namedtuples, it will fail to drop the base adapter level from the optimizer state fields (like mu and nu), leading to a structure/shape mismatch when loading the checkpoint.

We should extend _drop_adapter_level to recurse into lists, tuples, and namedtuples so that it can correctly reach and unwrap the nested parameter-like states.

def _drop_adapter_level(tree):
  if isinstance(tree, dict):
    if set(tree) == {"base"}:
      return _drop_adapter_level(tree["base"])
    return {k: _drop_adapter_level(v) for k, v in tree.items()}
  elif isinstance(tree, (list, tuple)):
    if hasattr(tree, "_fields"):  # namedtuple
      return type(tree)(*[_drop_adapter_level(v) for v in tree])
    return type(tree)([_drop_adapter_level(v) for v in tree])
  return tree

Comment on lines +277 to +280
def _drop_inject_hyperparams(opt_state):
if isinstance(opt_state, dict) and {"count", "hyperparams", "hyperparams_states", "inner_state"}.issubset(opt_state.keys()):
return opt_state["inner_state"]
return opt_state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support wrapping the parameter-like fields of the optimizer state in base when has_base is True, we need an _add_adapter_level helper function. This function will recurse into lists, tuples, and namedtuples, and wrap any parameter-like dictionaries in {"base": ...}.

Suggested change
def _drop_inject_hyperparams(opt_state):
if isinstance(opt_state, dict) and {"count", "hyperparams", "hyperparams_states", "inner_state"}.issubset(opt_state.keys()):
return opt_state["inner_state"]
return opt_state
def _drop_inject_hyperparams(opt_state):
if isinstance(opt_state, dict) and {"count", "hyperparams", "hyperparams_states", "inner_state"}.issubset(opt_state.keys()):
return opt_state["inner_state"]
return opt_state
def _add_adapter_level(tree):
if isinstance(tree, dict):
return {"base": tree}
elif isinstance(tree, (list, tuple)):
if hasattr(tree, "_fields"): # namedtuple
return type(tree)(*[_add_adapter_level(v) for v in tree])
return type(tree)([_add_adapter_level(v) for v in tree])
return tree

Comment thread src/maxtext/common/checkpointing.py Outdated
Comment on lines +349 to +360
target_params = {"base": want_params} if has_base else want_params

if has_inject:
import jax.numpy as jnp
target_opt = {
"count": jnp.zeros((), dtype=jnp.int32),
"hyperparams": {},
"hyperparams_states": {},
"inner_state": want_opt
}
else:
target_opt = want_opt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When has_base is True (indicating a LoRA/adapter checkpoint), the parameter-like fields inside the optimizer state (such as mu and nu in Adam) are also wrapped in base on disk. However, target_opt is currently constructed using want_opt directly without wrapping its parameter-like fields in base. This will cause a structure mismatch and fail during the Orbax restore of optimizer_state.

To fix this, we should apply the _add_adapter_level helper to want_opt before wrapping it in inner_state (if has_inject is True).

  target_params = {"base": want_params} if has_base else want_params
  
  target_opt_base = _add_adapter_level(want_opt) if has_base else want_opt

  if has_inject:
    import jax.numpy as jnp
    target_opt = {
        "count": jnp.zeros((), dtype=jnp.int32), 
        "hyperparams": {}, 
        "hyperparams_states": {}, 
        "inner_state": target_opt_base
    }
  else:
    target_opt = target_opt_base

@hsuan-lun-chiang
hsuan-lun-chiang force-pushed the feat/convert-post-training-tunix-onload branch from 0494c94 to 90aea05 Compare August 20, 2026 07:21
@hsuan-lun-chiang
hsuan-lun-chiang force-pushed the feat/convert-post-training-tunix-onload branch 2 times, most recently from 82c484b to 41673bb Compare August 21, 2026 11:47
- Add auto-detection for Tunix-formatted checkpoints (checks for `model_params`)
- Implement on-the-fly conversion logic (`_load_tunix_full_state_from_path`) during load
- Seamlessly reshape Tunix `TrainState` in memory (strip `inject_hyperparams`, drop `base` adapter)
- Support both full state and parameters-only restores
@hsuan-lun-chiang
hsuan-lun-chiang force-pushed the feat/convert-post-training-tunix-onload branch from 41673bb to 0097bba Compare August 21, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant