Skip to content

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

Draft
ecnal-cienet wants to merge 7 commits into
mainfrom
feat/post-train-checkpoint-maxtext-layout
Draft

Write post-training checkpoints in MaxText's on-disk layout#4700
ecnal-cienet wants to merge 7 commits into
mainfrom
feat/post-train-checkpoint-maxtext-layout

Conversation

@ecnal-cienet

@ecnal-cienet ecnal-cienet commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Post-training saved through Tunix's checkpoint manager, which stores nnx.state(model) verbatim under a model_params item. MaxText's on-disk layout is the Linen one: weights in params/params, the optimizer in opt_state and step, and NNX-only state such as rngs in nnx_aux. The two never matched, so a checkpoint from an SFT, DPO, RL or distillation run could not be loaded by pre-training, and loaders had started growing branches to read the post-training shape instead.

This adds MaxTextLayoutCheckpointManager, which converts in both directions and writes the same items tree everything else in MaxText reads. It lives under trainers/post_train rather than common/checkpointing because it subclasses Tunix's manager, and common/checkpointing is imported by pre-training and inference, which run without Tunix installed. Older checkpoints are still in the Tunix layout, so maybe_restore falls back to the base class for those, and load_params_from_path learns to read them by restoring into the NNX state itself.

The adapter level

DPO and RL train through TunixMaxTextAdapter, whose base level would otherwise reach the checkpoint. It is stripped from the weights and from the optimizer accumulators that mirror them, and put back on restore.

Optimizer differences that blocked a full-state resume

  • train_dpo passed gradient_accumulation_steps unconditionally, so Tunix wrapped the optimizer in optax.MultiSteps even at 1. It now passes None below 2, as train_sft already did.
  • Post-training chained optax.clip_by_global_norm into the optimizer, nesting its state a level deeper than pre-training, which clips raw gradients in its train step. add_gradient_clipping applies the same math inside the update and keeps the optimizer's own state tree.

A DPO checkpoint now resumes into pre-training with its weights, its optimizer state and its step counter.

Checkpoint metadata

A checkpoint also records what the run that wrote it was configured as: scan_layers, and the LoRA settings when there are any. verify_and_sync_scan_layers and lora_utils.sync_lora_metadata read it back to fill in a value the run left at its default, or to reject one that contradicts the checkpoint. Tunix passed none, so post-training checkpoints arrived with an empty dict and got neither behaviour. The manager now takes the run's config and records the same metadata pre-training does, through one shared builder (checkpoint_custom_metadata) so the two cannot drift apart. All four trainers pass their config, distillation the student's.

Reading it back was broken too, and not only for post-training. The metadata belongs to the step, so it sits at <step>/ and not at <step>/items/, while every caller passes load_parameters_path, which by convention points at the item. Both readers have been getting an empty dict from pre-training checkpoints as well and silently doing nothing with it. load_checkpoint_metadata now falls back to the parent directory, so both spellings work.

Demo notebooks

The RL and SFT demo notebooks read the checkpoint back to convert it to HuggingFace format, so the paths they hardcode are updated. Both now point at the items directory rather than model_params. RL also drops the actor level: Tunix appended it only when it owned checkpoint_root_directory, which is now None, so the actor trainer writes straight to checkpoint_dir as SFT and DPO do.

Tests

New unit tests, all runnable on CPU:

  • tests/post_training/unit/post_train_checkpointing_test.py — the bulk of the coverage. The saved tree is the MaxText layout and not the Tunix one; the adapter level is stripped from both weights and optimizer accumulators; weights and optimizer restore to what was saved; an absent checkpoint reports step 0; config-derived metadata is stamped the way pre-training does it, with a caller-supplied key winning over it; a missing config and a missing optimizer both still save; scanned and unscanned models each round-trip and are shown to differ on disk; a checkpoint in the old Tunix layout still restores through the base-class fallback; and install replaces the manager, forwards the run config, closes the one it replaced, and restores the step.
  • tests/post_training/unit/distillation_checkpointing_test.py — the student saves in MaxText layout and restores, its scan_layers is recorded, and learn_to_init mode leaves the optimizer out.
  • tests/unit/checkpointing_test.pyload_checkpoint_metadata falls back to the step directory and stops at the path it was given; checkpoint_custom_metadata records scan_layers either way, records LoRA only once there is a rank, and records nothing without a config.
  • tests/unit/optimizers_test.pyadd_gradient_clipping produces the same updates as the chained form, clips gradients over the threshold, keeps the unclipped optimizer's state tree, forwards extra args to the inner optimizer, and composes with skip-step-on-spikes.
  • tests/post_training/unit/train_{sft,dpo,rl,distill}_test.py — each trainer disables Tunix's own manager and installs this one with its config.

Run the post-training suite:

python3 -m pytest tests/post_training/unit/post_train_checkpointing_test.py tests/post_training/unit/distillation_checkpointing_test.py -v

and the pre-training side:

python3 -m pytest tests/unit/checkpointing_test.py tests/unit/checkpointing_nnx_load_test.py tests/unit/optimizers_test.py -v

The cpu-post-training-unit and cpu-unit CI jobs cover both. The notebook job exercises the changed RL and SFT demo paths end to end, including the checkpoint-to-HuggingFace conversion that reads the new layout back.

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.

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.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@ecnal-cienet
ecnal-cienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch from 7890810 to 35dd42f Compare August 3, 2026 02:39
@ecnal-cienet
ecnal-cienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch 7 times, most recently from f8539d7 to dd33305 Compare August 10, 2026 17:19
@ecnal-cienet
ecnal-cienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch from 1377f84 to f6454c7 Compare August 11, 2026 23:30
@mesakhcienet
mesakhcienet marked this pull request as ready for review August 17, 2026 03:36
@mesakhcienet
mesakhcienet marked this pull request as draft August 17, 2026 03:36
@ecnal-cienet
ecnal-cienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch 7 times, most recently from e445a06 to 5d20be1 Compare August 19, 2026 01:13
@mesakhcienet
mesakhcienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch 3 times, most recently from d793fb3 to e041558 Compare August 19, 2026 11:14
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.

post_train_skip_checkpointing exists for runs that only want to load base weights and write
nothing back.
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 gated repos
needed a token for something the checkpoint already carried.

The tokenizers built by build_tokenizer now serve post-training too. The two that are ours gain a
chat template renderer, since RL applies one and only HuggingFace tokenizers carried that method.
HFTokenizer forwards apply_chat_template explicitly rather than through __getattr__, so what it
offers is visible rather than whatever transformers happens to expose.
Three call-site assumptions no longer hold against current vLLM and tpu_inference, and each one
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, so the rollout asks the installed EngineArgs whether the field exists.
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, so the patch
recovers the size from device_indexes. is_init_field raises on the fields tpu_inference injects
dynamically, which the patch treats as "not an init field" instead of letting it abort.
Tunix stops on max_steps only when it owns the checkpoint manager, and installing ours sets
is_managed_externally, so distillation ran past the configured steps until the input pipeline ran
dry. A run asked for five steps and kept going.

The iterator now carries a batch budget of steps times gradient accumulation, minus whatever a
resumed run already consumed, and raises StopIteration once spent. That bounds the run whoever
owns the checkpoint manager.
llama3-8b appeared in HF_IDS but in none of the tables that actually convert weights, so it looked
supported and failed on a missing key. Its MaxText config is byte-identical to llama3.1-8b, so it
reuses that family's mapping, shapes and hooks; only the source repo differs.

The note beside it records why mistral-7b is not registered the same way: wiring it to the Llama
family converts cleanly, 291 of 291 arrays with every name and shape matching, and produces
numerically wrong weights -- KL 1.32e-01 mean against the 3e-3 the repo's own conversion tests
use. It needs its own mapping, not a borrowed one.

The logit checker lends eos as a pad token for the tokenizers that ship without one, which those
two do. Prompts are tokenized one at a time there, so nothing is padded and no result changes; it
only satisfies the check that refuses the call.
Four harnesses, none of them wired into CI, all driven by hand.

run_e2e_matrix.py trains a base checkpoint with every trainer and then reloads it from every other
one, so a checkpoint written by SFT is proven readable by pre-training and the rest.
run_vllm_matrix.py takes those checkpoints through vLLM decode. run_hf_convert_matrix.py rebuilds
the converted base checkpoints the other two consume, which nothing else regenerates, and encodes
which loader each model needs -- gemma3 has to be read through transformers because the param map
follows the names transformers gives multimodal weights, and reading it lazily fails on a missing
key that looks like a corrupt checkpoint. The XPK pair runs the same matrices on a cluster, for the
models that do not fit one host.

They check the tree that was written rather than the exit code, since a conversion or a training
run can exit zero and leave nothing usable behind.
Trainers restore the old layout already, so a run resumed from one rewrites itself on its next
save. Nothing does that for the checkpoints nobody is going to resume, and pre-training cannot
read them: it looks for weights in the Linen params collection, and the old layout keeps the
whole model state under model_params instead.

The split between params and nnx_aux is why this needs a model rather than a rename. Weights and
the rng counters that drive dropout sit mixed together under model_params, and only the model says
which is which. An abstract model supplies the classification, and the arrays move through as
numpy, so the conversion runs on CPU without materialising the model.

It refuses a model that does not match the checkpoint, by name and by shape. Both mistakes are
easy to make and neither announces itself: splitting by the wrong model routes the leaves it does
not recognise into nnx_aux, and a config that differs only in size has every name right and fails
much later, in the middle of a read.
@ecnal-cienet
ecnal-cienet force-pushed the feat/post-train-checkpoint-maxtext-layout branch from 2563efb to 3df853b Compare August 19, 2026 23:39
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