Skip to content

feat: staged training from a published draft, plus two draft-loading fixes - #168

Merged
yubofredwang merged 5 commits into
mainfrom
export/m14-leftovers
Aug 10, 2026
Merged

feat: staged training from a published draft, plus two draft-loading fixes#168
yubofredwang merged 5 commits into
mainfrom
export/m14-leftovers

Conversation

@torchspec-bot

@torchspec-bot torchspec-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Three independent, small changes to draft-model loading. They are batched because each is a few
lines on its own; they can be reviewed commit by commit.

1. all_tied_weights_keys — drafts cannot be loaded at all on Transformers 5.x

Transformers 5.x reads model.all_tied_weights_keys while loading a checkpoint, to keep tied
parameters out of the missing-key set. The attribute is populated by post_init(), which no draft
model calls, so AutoEagle3DraftModel.from_pretrained currently fails for every draft family:

AttributeError: 'LlamaForCausalLMEagle3' object has no attribute 'all_tied_weights_keys'

Declared as empty on the two draft base classes (Eagle3DraftModel, DFlashDraftModel) rather than
per model, since the cause is shared. Empty is the correct value, not a placeholder: a draft has its
own — often pruned — draft vocabulary, so its LM head can never share storage with the input
embedding. The new test pins that invariant alongside the round trip.

2. DSparkDraftModel is not accepted as an architecture name

AutoEagle3DraftModel builds DSparkDraftModel for any DSparkConfig, but AutoDraftModelConfig
only recognised the serving-side names Qwen3DSparkModel and K3DSparkModel, so a config naming
the class this repo actually instantiates was rejected with ValueError: Architecture DSparkDraftModel not supported — despite the class being exported from the package root.

3. model.initial_draft_model_path — start training from a published draft

New config field. When set, rank 0 loads the named draft into the freshly constructed model before
the target embedding is applied, which is what makes staged or continual training off an
already-published draft possible. It is distinct from training.load_path: that resumes a training
checkpoint with its optimizer and scheduler state, this seeds weights from a serving artifact and
starts a new run.

model:
  draft_model_config: configs/draft_models/qwen3_8b_eagle3.json
  initial_draft_model_path: /drafts/qwen3-8b-eagle3-stage1   # or .../model.safetensors

Two details worth a reviewer's attention:

  • Key mapping. tools/convert_to_hf.py publishes drafts under serving key names
    (midlayer.layers.0., context_proj.fc., …), so a published draft does not load into a
    training-side model as-is. The keys are mapped back through a new keymap.to_internal_keys. The
    forward mapping is not injective across draft families — an Eagle3 draft's own norm.* is also
    DFlash's export name for final_norm.*, and the same holds for fc.* vs context_proj.* — so
    the reverse rename is applied per key and only where the key is not already a parameter of the
    model being loaded. That lets the model itself disambiguate instead of guessing from the config.
  • Strictness. Both the path check and the load are strict. A typo in the path, or a checkpoint
    that does not line up with the configured draft, would otherwise leave part of the model randomly
    initialised and cost a full training run to notice.

The helpers live in torchspec/training/checkpoint.py next to the other checkpoint loading, and the
path joins _ALWAYS_LOCAL_PATH_KEYS so it reaches Ray actors absolutized — they do not share the
launcher's working directory.

Only the Eagle3 trainer is wired up; DFlash and DSpark would each need the same three lines and are
left alone here.

Testing

ruff check . and ruff format --check . clean.

New coverage, 24 tests:

  • tests/test_draft_from_pretrained.pysave_pretrained/from_pretrained round trip across all
    four draft families, plus the non-tying invariant. Every one of these fails on main with the
    AttributeError above.
  • tests/test_initial_draft_model_path.py — path resolution (file, directory, ~, and the three
    rejection cases), loading published Eagle3 and DFlash drafts as well as a native-keyed one, both
    strictness directions, the reverse key mapping including the norm/final_norm collision, and
    the config plumbing.
  • tests/test_eagle3_trainer.pyinit_model end to end with FSDP2 and the optimizer stubbed,
    including that the target model's embedding is applied on top of the loaded checkpoint rather
    than under it.
  • tests/test_dspark.py — the new architecture name resolves and builds DSparkDraftModel.

Full-suite comparison against a pristine main worktree in the same sandbox: identical failure
sets (149 pre-existing failures and 5 collection errors, all from missing CUDA/mooncake/ray.dag
in the sandbox), with 24 more tests passing on this branch. tests/test_eagle3_trainer.py is one of
the modules that cannot be collected without a CUDA-linked mooncake, so its three new tests were run
with mooncake stubbed; they should be re-run on a real GPU box before merge, along with the rest
of the suite.

4. Swapping the vocabulary mapping under a seeded draft head

Found while reviewing (3), and it predates it. A vocab-pruned draft's lm_head row j holds the
j-th target token its mapping selected, and compute_target_p_padded prunes the target side with
target_lm_head_weight[t2d] in that same ascending-token-id order — the two are aligned only while
both use one mapping.

train_entry recomputes the mapping from the current dataset after the actors have initialised
and then calls set_vocab_buffers, which replaced the buffers and nothing else. So a draft seeded
from a published artifact kept its old head ordering while the target side moved to the new token
set: wrong rows, no error, no log line. This reaches training.load_path too, since DCP restores
the buffers with the rest of the state, so it is not new to this PR — the new field just makes it
easy to hit.

set_vocab_buffers now refuses to replace a mapping that came in with loaded weights unless the
new one selects the same tokens. A freshly built draft is unaffected: its t2d is still the
all-pass sentinel that has_vocab_pruning keys off. For the staged case where the new corpus
genuinely has different token statistics, model.keep_initial_vocab_mapping trains against the
mapping the weights were built with and skips the recompute; it is rejected at config load without
initial_draft_model_path or load_path. Remapping the head instead is not offered — tokens the
new mapping adds have no trained row to permute into place.

…rmers 5.x

Transformers 5.x reads `model.all_tied_weights_keys` while loading a
checkpoint, to keep tied parameters out of the missing-key set. The attribute
is populated by `post_init()`, which no draft model calls, so every draft --
Eagle3 Llama, Eagle3 DeepSeek MLA, DFlash and DSpark alike -- fails
`from_pretrained` with

    AttributeError: 'LlamaForCausalLMEagle3' object has no attribute
    'all_tied_weights_keys'

Declare the mapping as empty on the two draft base classes. Empty is the
correct value rather than a placeholder: a draft has its own (often pruned)
draft vocabulary, so its LM head can never share storage with the input
embedding.

Adds `tests/test_draft_from_pretrained.py`, which round-trips every draft
family through `save_pretrained`/`from_pretrained` and pins the non-tying
invariant the empty mapping depends on.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
`AutoEagle3DraftModel` builds `DSparkDraftModel` for any `DSparkConfig`, but
`AutoDraftModelConfig` only recognised the serving-side architecture names
`Qwen3DSparkModel` and `K3DSparkModel`. A DSpark draft config that names the
class this repo actually instantiates was rejected with

    ValueError: Architecture DSparkDraftModel not supported

even though it is exported from the package root and instantiable directly.
Register the name alongside the serving alias so both spellings reach the same
model.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
Adds `model.initial_draft_model_path`. When set, rank 0 loads the named draft
into the freshly constructed model before the target embedding is applied,
which is what makes staged or continual training off an already-published
draft possible. It is distinct from `training.load_path`: that resumes a
training checkpoint with its optimizer and scheduler state, this seeds weights
from a serving artifact and starts a new run.

The path may name either `model.safetensors` or the directory holding it, and
anything else is rejected rather than ignored -- a typo here would otherwise
start a full run from random weights. Loading is strict in both directions for
the same reason.

`tools/convert_to_hf.py` publishes drafts under serving key names
(`midlayer.` -> `layers.0.`, `context_proj.` -> `fc.`, ...), so the keys are
mapped back first, via a new `keymap.to_internal_keys`. The forward mapping is
not injective across draft families -- an Eagle3 draft's own `norm.*` is also
DFlash's export name for `final_norm.*` -- so the reverse rename is applied per
key and only where the key is not already a parameter of the model being
loaded, which lets the model itself disambiguate.

The path joins `_ALWAYS_LOCAL_PATH_KEYS` so it reaches Ray actors absolutized,
since they do not share the launcher's working directory.

Tests cover path resolution, loading published Eagle3 and DFlash drafts plus a
native-keyed one, both strictness directions, the reverse key mapping, the
config plumbing, and `init_model` end to end -- including that the target
model's embedding is applied on top of the loaded checkpoint rather than under
it.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae5f589e61

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +97 to +99
loaded_from = checkpoint.load_initial_draft_weights(
draft_model, initial_draft_model_path
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the published vocab map when seeding pruned drafts

When initial_draft_model_path points at a vocab-pruned Eagle3 draft (draft_vocab_size < vocab_size, e.g. configs/draft_models/qwen3_8b_eagle3.json), this load brings in both the pruned lm_head.weight and the checkpoint's saved d2t/t2d ordering. After actor initialization, however, train_entry.py still recomputes the mapping for the current dataset and calls set_vocab_buffers, which only overwrites the buffers and does not permute the already-loaded lm_head. If the staged run's token mapping differs from the published artifact, target probabilities are sliced with the new t2d while the draft head rows remain in the old order, silently corrupting the loss/logits; validate/reuse the artifact mapping or remap the head before training.

Useful? React with 👍 / 👎.

Most of them restated the code or the commit message. Kept only the ones a
reader cannot derive: why the empty tied-weight mapping is not dead code, why
the reverse key rename consults the model, and why load_embedding has to run
after the initial checkpoint.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6aec6cf9a0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@dataclass
class ModelConfig:
draft_model_config: Optional[str] = None
initial_draft_model_path: Optional[str] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject or wire initial drafts for DFlash

Because this option is added to the shared ModelConfig and flattened for every trainer, a DFlash or DSpark run can now accept model.initial_draft_model_path, but TrainerActor dispatches those configs to DFlashTrainer/DSparkTrainer and only Eagle3Trainer reads the field. In that scenario the run proceeds from freshly initialized draft weights even though the user requested a published draft warm-start, so either reject the option for non-Eagle3 configs or consume it in the DFlash/DSpark init path.

Useful? React with 👍 / 👎.

Comment on lines +93 to +97
initial_draft_model_path = getattr(self.args, "initial_draft_model_path", None)
if initial_draft_model_path:
loaded_from = checkpoint.load_initial_draft_weights(
draft_model, initial_draft_model_path
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate initial-draft load failures to all ranks

When the initial draft path is missing or the safetensors state dict mismatches in a multi-rank run, rank 0 raises from this load inside the enclosing dist.get_rank() == 0 block while the other ranks skip it and continue to the subsequent dist.barrier(group=get_gloo_group()). That turns the explicit bad-path failure into a distributed init hang until timeout; validate/propagate the failure to all ranks before the barrier or make every rank fail consistently.

Useful? React with 👍 / 👎.

A vocab-pruned draft's `lm_head` row j holds the j-th target token its mapping
selected, and `compute_target_p_padded` prunes the target side with
`target_lm_head_weight[t2d]` in that same ascending-token-id order. The two are
only aligned while both sides use one mapping.

`train_entry` recomputes the mapping from the current dataset after the actors
have initialised, then calls `set_vocab_buffers`, which replaced the buffers
and nothing else. A draft seeded from a published artifact -- by the new
`initial_draft_model_path`, or by `training.load_path` restoring buffers
through DCP -- therefore kept its old head ordering while the target side moved
to the new token set, corrupting the loss with no error and no log line.

`set_vocab_buffers` now refuses to replace a mapping that arrived with loaded
weights unless the new one selects the same tokens. A freshly built draft is
unaffected: its `t2d` is still the all-pass sentinel, which is what
`has_vocab_pruning` keys off.

For the staged case where the new corpus legitimately has different token
statistics, `model.keep_initial_vocab_mapping` trains against the mapping the
weights were built with and skips the recompute entirely. It is rejected at
config load without `initial_draft_model_path` or `load_path`, since there
would be no mapping to keep and the head would train against an all-pass t2d.

Remapping the head instead is not offered: tokens the new mapping adds have no
trained row to permute into place.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
@yubofredwang
yubofredwang merged commit 0d7db26 into main Aug 10, 2026
2 checks passed
@yubofredwang
yubofredwang deleted the export/m14-leftovers branch August 10, 2026 04:00

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8cdd36768

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return
if not (
OmegaConf.select(config, "model.initial_draft_model_path")
or OmegaConf.select(config, "training.load_path")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require a real checkpoint before keeping the vocab map

Here training.load_path is treated as proof that a saved t2d/d2t mapping will be restored, but checkpoint.load() later returns None after only logging when the path, tracker, model dir, or model load is missing/incompatible. In that scenario with model.keep_initial_vocab_mapping=true and a pruned Eagle3 draft, train_entry.py skips computing/sending a dataset mapping, so the draft keeps its initial all-true t2d while lm_head is still only draft_vocab_size, leading to a late loss/logit shape failure instead of a valid mapping. Please make this mode require an actually loadable model checkpoint, or only skip mapping after the mapping was restored.

Useful? React with 👍 / 👎.

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.

2 participants