Skip to content

Write post-training checkpoints in MaxText's on-disk layout - #4975

Draft
ecnal-cienet wants to merge 1 commit into
mainfrom
feat/post-train-writes-maxtext-layout
Draft

Write post-training checkpoints in MaxText's on-disk layout#4975
ecnal-cienet wants to merge 1 commit into
mainfrom
feat/post-train-writes-maxtext-layout

Conversation

@ecnal-cienet

Copy link
Copy Markdown
Collaborator

Description

Post-training writes Tunix's checkpoint shape:

<run>/checkpoints/<step>/model_params/       the whole nnx.state(model)
<run>/checkpoints/<step>/optimizer_state/    opt_state and step

Pre-training reads MaxText's:

<run>/checkpoints/<step>/items/
    params/params/decoder/...   weights, under Flax's params collection
    opt_state/                  optimizer, with mu and nu inside that collection
    step
    nnx_aux/model/...           rng counters and anything else that is not a weight

So a model trained by SFT, DPO, RL or distillation could not be handed back to pre-training without converting it by hand. This makes post-training write the layout pre-training already reads.

MaxTextLayoutCheckpointManager subclasses Tunix's manager and reshapes the tree on the way out. It restores the Linen collection level; strips the inject_hyperparams shell that optax wraps a scheduled optimizer in, without which mu and nu land a level short of where pre-training looks for them; and drops the adapter level a LoRA wrapper adds. install() swaps it in for the manager the base class built, closing that one so it does not leak its writer thread. Restoring still reads the old layout, so a run resumed from an existing checkpoint converts itself on its next save.

Gradient clipping moves out of optax.chain for the same reason: chaining a stateless clip in front of the optimizer nests its state under an extra level pre-training cannot read. add_gradient_clipping applies the clip while leaving the optimizer's state shape alone.

RL keeps its actor/ level, since GRPO checkpoints an actor and a reference separately. The end-to-end scripts, both demo notebooks and the eval README follow the path from model_params to items.

Three things had to work before that code was reachable, and they are in this PR because the change is not testable end to end without them:

  • Tokenizers. Post-training reached for transformers' AutoTokenizer directly, so a run had to name a HuggingFace repo even when the model ships a sentencepiece or tiktoken asset in this repo, and a gated repo needed a token for something the checkpoint already carried. The tokenizers build_tokenizer returns now serve post-training too, and the two that are ours gain a chat template renderer, since RL applies one and only HuggingFace tokenizers carried that method.
  • vLLM. Three call-site assumptions no longer hold, each of which stops RL before a single rollout: EngineArgs dropped swap_space, and passing a field it no longer takes is a TypeError rather than something vLLM ignores; tpu_inference mutates data_parallel_size and then deletes the sharding config before calling with_hf_config, leaving the rebuilt config to fail its own device-count assertion; is_init_field raises on the fields tpu_inference injects dynamically.
  • Distillation. Tunix stops on max_steps only when it owns the checkpoint manager, and installing ours sets is_managed_externally, so distillation ran past its step count until the input pipeline ran dry. The iterator now carries a batch budget of steps times gradient accumulation, minus whatever a resumed run already consumed, and raises StopIteration once spent.

post_train_skip_checkpointing is new, for runs that only want to load base weights and write nothing back.

Tests

tests/post_training/ — 285 passed, 94 skipped.

New unit coverage for the manager itself (post_train_checkpointing_test.py,
distillation_checkpointing_test.py), for the clipping wrapper
(optimizers_test.py::AddGradientClippingTest), and for the layout on the restore side
(checkpointing_test.py, checkpointing_nnx_load_test.py).

End to end, on a v6e-8: every trainer wrote a checkpoint and every other trainer, plus
pre-training, read it back. The written trees were checked rather than the exit codes — a run can
exit zero and leave nothing usable. A converted checkpoint holds

params.params.decoder.decoder_norm.scale
opt_state.0.mu.params.decoder.layers.mlp.wi_0.kernel
step
nnx_aux.model....

which is what pre-training expects, including mu and nu inside the params collection.

RL was run on qwen3-0.6b, qwen2.5-1.5b and gemma3-4b, scanned and unscanned, each writing
actor/1 and actor/2. llama3.1-8b and qwen3-8b run out of HBM on a single v6e-8 host at this
batch size, in the gradient update rather than anywhere near the checkpoint code.

Checklist

Before submitting this PR, please make sure (put X in square brackets):

  • I have performed a self-review of my code. For an optional AI review, add the gemini-review label.
  • I have necessary comments in my code, particularly in hard-to-understand areas.
  • I have run end-to-end tests tests and provided workload links above if applicable.
  • I have made or will make corresponding changes to the doc if needed, including adding new documentation pages to the relevant Table of Contents (toctree directive) as explained in our documentation.

Tunix writes its own checkpoint shape -- model_params/ and optimizer_state/ beside a
custom_metadata -- while pre-training expects items/ holding params under the Linen params
collection, opt_state, step and nnx_aux. A model trained by SFT, DPO, RL or distillation therefore
could not be handed back to pre-training without converting it by hand.

MaxTextLayoutCheckpointManager subclasses Tunix's manager and reshapes the tree on the way out:
it restores the Linen collection level, strips the inject_hyperparams shell that optax wraps a
scheduled optimizer in -- otherwise mu and nu land a level short of where pre-training looks for
them -- and drops the adapter level a LoRA wrapper adds. install() swaps it in for the manager the
base class built, closing that one so it does not leak its writer thread. Gradient clipping moves
out of optax.chain for the same reason: chaining a stateless clip in front of the optimizer nests
its state under an extra level that pre-training cannot read. RL keeps its actor/ level, since
GRPO checkpoints an actor and a reference separately. The end-to-end scripts, the demo notebooks
and the eval README follow the path from model_params to items.

Three things had to work before that code was reachable.

Post-training reached for transformers' AutoTokenizer directly, so a run had to name a
HuggingFace repo even when the model ships a sentencepiece or tiktoken asset in this repo. The
tokenizers built by build_tokenizer now serve post-training too, and the two that are ours gain a
chat template renderer, since RL applies one and only HuggingFace tokenizers carried that method.

The vLLM rollout had three call-site assumptions that no longer hold, each of which stops RL
before a single rollout: EngineArgs dropped swap_space, and passing a field it no longer takes is
a TypeError rather than something vLLM ignores; tpu_inference mutates data_parallel_size and then
deletes the sharding config before calling with_hf_config, leaving the rebuilt config to fail its
own device-count assertion; is_init_field raises on the fields tpu_inference injects dynamically.

Distillation then ran past its step count, because Tunix stops on max_steps only when it owns the
checkpoint manager and installing ours sets is_managed_externally. The iterator carries a batch
budget of steps times gradient accumulation, minus whatever a resumed run already consumed, and
raises StopIteration once spent.

post_train_skip_checkpointing exists for runs that only want to load base weights and write
nothing back.

@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 aligns post-training workflows (SFT, DPO, RL, and distillation) with MaxText's native on-disk checkpoint layout, ensuring compatibility with pre-training and inference. It introduces a post_train_skip_checkpointing option, adds Jinja2 chat template rendering to native tokenizers, and implements inline gradient clipping to preserve optimizer state structure. Feedback on these changes suggests refining the adapter-stripping logic to prevent accidental removal of legitimate 'base' keys, suppressing noisy warning logs when metadata is loaded from item-level paths, and using getattr for decode_context_parallel_size to improve robustness across different vLLM versions.

Comment on lines +64 to +101
def _drop_adapter_level(tree):
"""Removes the adapter level wherever it wraps a weight-shaped subtree.

The optimizer is built over the adapter, so its accumulators (mu, nu, acc_grads) are keyed by
the adapter's graph and carry the level even though the weights they shadow do not.

Args:
tree: A pure dict, typically the optimizer state.

Returns:
The same tree with every `{"base": subtree}` replaced by `subtree`.
"""
if isinstance(tree, dict):
if set(tree) == {_ADAPTER_CHILD}:
return _drop_adapter_level(tree[_ADAPTER_CHILD])
return {k: _drop_adapter_level(v) for k, v in tree.items()}
if isinstance(tree, list):
return [_drop_adapter_level(v) for v in tree]
return tree


def _add_adapter_level(tree, guide):
"""Inverse of `_drop_adapter_level`.

Args:
tree: A pure dict with the adapter level removed.
guide: The same tree before removal, giving the positions to restore.

Returns:
`tree` with the adapter level put back wherever `guide` carries it.
"""
if isinstance(guide, dict) and set(guide) == {_ADAPTER_CHILD}:
return {_ADAPTER_CHILD: _add_adapter_level(tree, guide[_ADAPTER_CHILD])}
if isinstance(guide, dict) and isinstance(tree, dict):
return {k: (_add_adapter_level(v, guide[k]) if k in guide else v) for k, v in tree.items()}
if isinstance(guide, list) and isinstance(tree, list) and len(guide) == len(tree):
return [_add_adapter_level(t, g) for t, g in zip(tree, guide)]
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 recursive implementation of _drop_adapter_level will strip any dictionary that has only the key "base" at any depth. If the model itself has a legitimate module or parameter named "base" as its sole child at some level, this function will incorrectly strip it, leading to structure mismatches during restore. Restricting the stripping of the "base" key to only the root of the parameter-structured subtrees (rather than recursively descending into the unwrapped subtree) avoids this issue.

def _drop_adapter_level(tree):
  """Removes the adapter level wherever it wraps a weight-shaped subtree.

  The optimizer is built over the adapter, so its accumulators (mu, nu, acc_grads) are keyed by
  the adapter's graph and carry the level even though the weights they shadow do not.

  Args:
    tree: A pure dict, typically the optimizer state.

  Returns:
    The same tree with every `{"base": subtree}` replaced by `subtree`.
  """
  if isinstance(tree, dict):
    if set(tree) == {_ADAPTER_CHILD}:
      return tree[_ADAPTER_CHILD]
    return {k: _drop_adapter_level(v) for k, v in tree.items()}
  if isinstance(tree, list):
    return [_drop_adapter_level(v) for v in tree]
  return tree


def _add_adapter_level(tree, guide):
  """Inverse of `_drop_adapter_level`.

  Args:
    tree: A pure dict with the adapter level removed.
    guide: The same tree before removal, giving the positions to restore.

  Returns:
    `tree` with the adapter level put back wherever `guide` carries it.
  """
  if isinstance(guide, dict) and set(guide) == {_ADAPTER_CHILD}:
    return {_ADAPTER_CHILD: tree}
  if isinstance(guide, dict) and isinstance(tree, dict):
    return {k: (_add_adapter_level(v, guide[k]) if k in guide else v) for k, v in tree.items()}
  if isinstance(guide, list) and isinstance(tree, list) and len(guide) == len(tree):
    return [_add_adapter_level(t, g) for t, g in zip(tree, guide)]
  return tree

Comment on lines +834 to +868
def _custom_metadata_at(checkpoint_dir: epath.Path) -> dict[str, Any]:
"""Reads the custom metadata stored at exactly this directory.

Args:
checkpoint_dir: Directory to read.

Returns:
The metadata dict, empty if there is none or the read fails.
"""
checkpoint_dir = epath.Path(checkpoint_dir_path)
try:
ckptr = ocp.StandardCheckpointer()
metadata = ckptr.metadata(checkpoint_dir)
metadata = ocp.StandardCheckpointer().metadata(checkpoint_dir)
return metadata.custom_metadata or {}
except Exception as e: # pylint: disable=broad-except
max_logging.log(f"Warning: Failed to load checkpoint metadata: {e}")
return {}


def load_checkpoint_metadata(checkpoint_dir_path: str) -> dict[str, Any]:
"""Loads custom metadata from an Orbax checkpoint.

The metadata belongs to the step, so it sits at `<step>/` and not at `<step>/items/`. Callers
pass `load_parameters_path`, which points at the item, so fall back to the parent directory.

Args:
checkpoint_dir_path: Path to the checkpoint directory, item level or step level.

Returns:
A dictionary containing custom metadata, or an empty dictionary if none is
present or loading fails.
"""
checkpoint_dir = epath.Path(checkpoint_dir_path)
metadata = _custom_metadata_at(checkpoint_dir)
if not metadata and checkpoint_dir.parent != checkpoint_dir:
metadata = _custom_metadata_at(checkpoint_dir.parent)
return metadata

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since load_checkpoint_metadata often receives an item-level path (e.g., ending in /items) where checkpoint metadata is not directly stored, the first call to _custom_metadata_at is expected to fail. Logging a warning on this first attempt creates misleading and noisy warning logs during normal operation. Consider adding a log_warning parameter to _custom_metadata_at to suppress logging on the initial attempt.

def _custom_metadata_at(checkpoint_dir: epath.Path, log_warning: bool = True) -> dict[str, Any]:
  """Reads the custom metadata stored at exactly this directory.

  Args:
    checkpoint_dir: Directory to read.
    log_warning: Whether to log a warning if the read fails.

  Returns:
    The metadata dict, empty if there is none or the read fails.
  """
  try:
    metadata = ocp.StandardCheckpointer().metadata(checkpoint_dir)
    return metadata.custom_metadata or {}
  except Exception as e:  # pylint: disable=broad-except
    if log_warning:
      max_logging.log(f"Warning: Failed to load checkpoint metadata: {e}")
    return {}


def load_checkpoint_metadata(checkpoint_dir_path: str) -> dict[str, Any]:
  """Loads custom metadata from an Orbax checkpoint.

  The metadata belongs to the step, so it sits at `<step>/` and not at `<step>/items/`. Callers
  pass `load_parameters_path`, which points at the item, so fall back to the parent directory.

  Args:
    checkpoint_dir_path: Path to the checkpoint directory, item level or step level.

  Returns:
    A dictionary containing custom metadata, or an empty dictionary if none is
    present or loading fails.
  """
  checkpoint_dir = epath.Path(checkpoint_dir_path)
  metadata = _custom_metadata_at(checkpoint_dir, log_warning=False)
  if not metadata and checkpoint_dir.parent != checkpoint_dir:
    metadata = _custom_metadata_at(checkpoint_dir.parent, log_warning=True)
  return metadata

sp = sharding_strategy.get("sequence_parallelism", 1)
attn_dp = sharding_strategy.get("attention_data_parallelism", 1)
attn_dp_ep = sharding_strategy.get("attention_data_expert_parallelism", 1)
dcp = pc.decode_context_parallel_size

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To ensure robustness across different vLLM versions where decode_context_parallel_size might be absent or named differently, it is safer to use getattr with a default fallback value.

Suggested change
dcp = pc.decode_context_parallel_size
dcp = getattr(pc, "decode_context_parallel_size", 1)

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