Skip to content

Commit a80c593

Browse files
committed
feat(mtp): add MTP model, converter, stitch CLI, and tests
Signed-off-by: Rahul-Tuli <rtuli@redhat.com>
1 parent e8d9da1 commit a80c593

18 files changed

Lines changed: 2062 additions & 0 deletions

scripts/stitch_mtp.py

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Stitch finetuned MTP weights back into a verifier checkpoint.
4+
5+
Takes a finetuned MTP speculator checkpoint (speculators format) and merges
6+
the trained weights back into the original verifier checkpoint, producing a
7+
self-contained checkpoint directory deployable on vLLM.
8+
9+
Frozen weights (embed_tokens, lm_head) are skipped -- only the MTP layer
10+
weights are replaced.
11+
12+
Usage:
13+
python scripts/stitch_mtp.py ./finetuned-mtp ./Qwen3-Next-80B-A3B-Instruct
14+
15+
# verifier can be a HuggingFace model ID:
16+
python scripts/stitch_mtp.py ./finetuned-mtp Qwen/Qwen3-Next-80B-A3B-Instruct
17+
18+
# custom output path (defaults to {verifier-name}-stitched):
19+
python scripts/stitch_mtp.py ./finetuned-mtp ./verifier --output-path ./out
20+
"""
21+
22+
import json
23+
import re
24+
import shutil
25+
from pathlib import Path
26+
from typing import Annotated
27+
28+
import torch
29+
import typer
30+
from huggingface_hub import snapshot_download
31+
from rich.console import Console
32+
from rich.panel import Panel
33+
from rich.progress import (
34+
BarColumn,
35+
Progress,
36+
SpinnerColumn,
37+
TextColumn,
38+
TimeElapsedColumn,
39+
)
40+
from safetensors import safe_open
41+
from safetensors.torch import save_file
42+
43+
from speculators.convert.mtp import MTP_EXACT_REMAP, MTP_PREFIX_REMAP
44+
45+
app = typer.Typer(rich_markup_mode="rich")
46+
console = Console()
47+
48+
INVERSE_MTP_EXACT_REMAP = {v: k for k, v in MTP_EXACT_REMAP.items()}
49+
INVERSE_MTP_PREFIX_REMAP = [(dst, src) for src, dst in MTP_PREFIX_REMAP]
50+
51+
_FROZEN_KEYS = {"embed_tokens.weight", "lm_head.weight"}
52+
53+
_FUSED_GATE_UP_PATTERN = re.compile(r"^(.+\.experts)\.gate_up_proj$")
54+
_FUSED_DOWN_PATTERN = re.compile(r"^(.+\.experts)\.down_proj$")
55+
56+
57+
def _spinner() -> Progress:
58+
return Progress(
59+
SpinnerColumn(),
60+
TextColumn("[progress.description]{task.description}"),
61+
TimeElapsedColumn(),
62+
console=console,
63+
)
64+
65+
66+
def _bar() -> Progress:
67+
return Progress(
68+
SpinnerColumn(),
69+
TextColumn("[progress.description]{task.description}"),
70+
BarColumn(),
71+
TextColumn("{task.completed}/{task.total} shards"),
72+
TimeElapsedColumn(),
73+
console=console,
74+
)
75+
76+
77+
def _remap_key(key: str) -> str:
78+
if key in INVERSE_MTP_EXACT_REMAP:
79+
return INVERSE_MTP_EXACT_REMAP[key]
80+
for src, dst in INVERSE_MTP_PREFIX_REMAP:
81+
if key.startswith(src):
82+
return dst + key[len(src) :]
83+
return key
84+
85+
86+
def _filter_frozen_keys(
87+
weights: dict[str, torch.Tensor],
88+
) -> dict[str, torch.Tensor]:
89+
return {k: v for k, v in weights.items() if k not in _FROZEN_KEYS}
90+
91+
92+
def _unfuse_moe_experts(
93+
weights: dict[str, torch.Tensor],
94+
) -> dict[str, torch.Tensor]:
95+
"""Unfuse packed 3D expert tensors back to per-expert format.
96+
97+
Inverse of ``MTPConverter._fuse_moe_experts``.
98+
"""
99+
result: dict[str, torch.Tensor] = {}
100+
gate_up_keys: dict[str, torch.Tensor] = {}
101+
down_keys: dict[str, torch.Tensor] = {}
102+
103+
for key, tensor in weights.items():
104+
m_gu = _FUSED_GATE_UP_PATTERN.match(key)
105+
m_d = _FUSED_DOWN_PATTERN.match(key)
106+
if m_gu:
107+
gate_up_keys[m_gu.group(1)] = tensor
108+
elif m_d:
109+
down_keys[m_d.group(1)] = tensor
110+
else:
111+
result[key] = tensor
112+
113+
if not gate_up_keys:
114+
return weights
115+
116+
for prefix, gate_up in gate_up_keys.items():
117+
if prefix not in down_keys:
118+
raise ValueError(f"Found gate_up_proj at '{prefix}' but missing down_proj")
119+
down = down_keys[prefix]
120+
121+
num_experts = gate_up.shape[0]
122+
half = gate_up.shape[1] // 2
123+
124+
for i in range(num_experts):
125+
result[f"{prefix}.{i}.gate_proj.weight"] = gate_up[i, :half].contiguous()
126+
result[f"{prefix}.{i}.up_proj.weight"] = gate_up[i, half:].contiguous()
127+
result[f"{prefix}.{i}.down_proj.weight"] = down[i].contiguous()
128+
129+
console.print(f" Unfused [cyan]{num_experts}[/] experts at [dim]{prefix}[/]")
130+
131+
return result
132+
133+
134+
def _resolve_verifier_path(verifier_path: Path) -> Path:
135+
"""Return a local directory, downloading from HF if needed."""
136+
if verifier_path.exists():
137+
return verifier_path
138+
139+
model_id = str(verifier_path)
140+
console.print(
141+
f"Verifier path [cyan]{model_id}[/] not found locally, "
142+
"downloading from HuggingFace..."
143+
)
144+
with _spinner() as progress:
145+
progress.add_task(f"Downloading {model_id}", total=None)
146+
local_path = snapshot_download(
147+
repo_id=model_id,
148+
allow_patterns=[
149+
"*.json",
150+
"*.safetensors",
151+
"*.bin",
152+
"*.index.json",
153+
],
154+
)
155+
console.print(f" Downloaded to [dim]{local_path}[/]")
156+
return Path(local_path)
157+
158+
159+
def _load_finetuned_weights(
160+
checkpoint_dir: Path,
161+
) -> dict[str, torch.Tensor]:
162+
weights: dict[str, torch.Tensor] = {}
163+
164+
index_path = checkpoint_dir / "model.safetensors.index.json"
165+
if index_path.exists():
166+
with index_path.open() as f:
167+
weight_map = json.load(f)["weight_map"]
168+
shards = set(weight_map.values())
169+
with _bar() as progress:
170+
task = progress.add_task("Loading finetuned weights", total=len(shards))
171+
for shard in shards:
172+
with safe_open(str(checkpoint_dir / shard), framework="pt") as f:
173+
for key in f.keys(): # noqa: SIM118
174+
weights[key] = f.get_tensor(key)
175+
progress.advance(task)
176+
return weights
177+
178+
single = checkpoint_dir / "model.safetensors"
179+
if single.exists():
180+
with safe_open(str(single), framework="pt") as f:
181+
for key in f.keys(): # noqa: SIM118
182+
weights[key] = f.get_tensor(key)
183+
return weights
184+
185+
raise FileNotFoundError(f"No safetensors found at {checkpoint_dir}")
186+
187+
188+
def _stitch_sharded(
189+
output_dir: Path,
190+
native_weights: dict[str, torch.Tensor],
191+
) -> None:
192+
index_path = output_dir / "model.safetensors.index.json"
193+
with index_path.open() as f:
194+
index_data = json.load(f)
195+
weight_map: dict[str, str] = index_data["weight_map"]
196+
197+
shard_to_new: dict[str, dict[str, torch.Tensor]] = {}
198+
for key, tensor in native_weights.items():
199+
shard = weight_map.get(key)
200+
if shard is None:
201+
raise ValueError(
202+
f"Finetuned key '{key}' not found in verifier weight "
203+
"map. The finetuned checkpoint may not match the "
204+
"verifier."
205+
)
206+
shard_to_new.setdefault(shard, {})[key] = tensor
207+
208+
with _bar() as progress:
209+
task = progress.add_task("Stitching shards", total=len(shard_to_new))
210+
for shard_filename, new_weights in shard_to_new.items():
211+
shard_path = output_dir / shard_filename
212+
existing: dict[str, torch.Tensor] = {}
213+
metadata = None
214+
with safe_open(str(shard_path), framework="pt") as f:
215+
metadata = f.metadata()
216+
for k in f.keys(): # noqa: SIM118
217+
existing[k] = f.get_tensor(k)
218+
existing.update(new_weights)
219+
save_file(existing, str(shard_path), metadata=metadata)
220+
progress.advance(task)
221+
222+
223+
def _stitch_single(
224+
safetensors_path: Path,
225+
native_weights: dict[str, torch.Tensor],
226+
) -> None:
227+
existing: dict[str, torch.Tensor] = {}
228+
metadata = None
229+
with safe_open(str(safetensors_path), framework="pt") as f:
230+
metadata = f.metadata()
231+
for k in f.keys(): # noqa: SIM118
232+
existing[k] = f.get_tensor(k)
233+
existing.update(native_weights)
234+
save_file(existing, str(safetensors_path), metadata=metadata)
235+
236+
237+
def stitch(
238+
finetuned_checkpoint: Path,
239+
verifier_path: Path,
240+
output_path: Path,
241+
) -> Path:
242+
"""Stitch finetuned MTP weights back into a verifier checkpoint."""
243+
console.print(
244+
Panel(
245+
f"[bold]Finetuned:[/] {finetuned_checkpoint}\n"
246+
f"[bold]Verifier:[/] {verifier_path}\n"
247+
f"[bold]Output:[/] {output_path}",
248+
title="[bold green]MTP Stitch[/]",
249+
border_style="green",
250+
)
251+
)
252+
253+
verifier_path = _resolve_verifier_path(verifier_path)
254+
255+
weights = _load_finetuned_weights(finetuned_checkpoint)
256+
console.print(f" Loaded [cyan]{len(weights)}[/] finetuned weight tensors")
257+
258+
frozen_count = sum(1 for k in weights if k in _FROZEN_KEYS)
259+
weights = _filter_frozen_keys(weights)
260+
if frozen_count:
261+
console.print(
262+
f" Skipped [yellow]{frozen_count}[/] frozen key(s) (embed_tokens, lm_head)"
263+
)
264+
265+
weights = _unfuse_moe_experts(weights)
266+
native_weights = {_remap_key(k): v for k, v in weights.items()}
267+
console.print(f" Remapped [cyan]{len(native_weights)}[/] keys to native format")
268+
269+
with _spinner() as progress:
270+
progress.add_task("Copying verifier checkpoint", total=None)
271+
if output_path.exists():
272+
shutil.rmtree(output_path)
273+
shutil.copytree(verifier_path, output_path)
274+
275+
index_path = output_path / "model.safetensors.index.json"
276+
if index_path.exists():
277+
_stitch_sharded(output_path, native_weights)
278+
else:
279+
single = output_path / "model.safetensors"
280+
if single.exists():
281+
_stitch_single(single, native_weights)
282+
else:
283+
raise FileNotFoundError(f"No safetensors checkpoint found at {output_path}")
284+
285+
console.print(
286+
Panel(
287+
f"[bold]{output_path}[/]",
288+
title="[bold green]Stitched checkpoint saved[/]",
289+
border_style="green",
290+
)
291+
)
292+
return output_path
293+
294+
295+
@app.command()
296+
def main(
297+
finetuned_checkpoint: Annotated[
298+
Path,
299+
typer.Argument(
300+
help="Path to the finetuned MTP speculator checkpoint.",
301+
),
302+
],
303+
verifier_path: Annotated[
304+
Path,
305+
typer.Argument(
306+
help=(
307+
"Path to the verifier checkpoint, or a HuggingFace "
308+
"model ID (e.g. Qwen/Qwen3-Next-80B-A3B-Instruct)."
309+
),
310+
),
311+
],
312+
output_path: Annotated[
313+
Path | None,
314+
typer.Option(
315+
help=(
316+
"Output directory for the stitched checkpoint. "
317+
"Defaults to {verifier-name}-stitched."
318+
),
319+
),
320+
] = None,
321+
) -> None:
322+
"""Stitch finetuned MTP weights back into a verifier checkpoint."""
323+
if output_path is None:
324+
output_path = Path.cwd() / f"{verifier_path.name}-stitched"
325+
326+
stitch(
327+
finetuned_checkpoint=finetuned_checkpoint,
328+
verifier_path=verifier_path,
329+
output_path=output_path,
330+
)
331+
332+
333+
if __name__ == "__main__":
334+
app()

src/speculators/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ def reload_schema(cls):
328328
for subcls in (cls.registry or {}).values():
329329
subcls.model_rebuild(force=True, _types_namespace={"torch": torch})
330330

331+
def __iter__(self):
332+
# Pydantic's __iter__ yields (key, value) tuples, but transformers
333+
# expects string keys. Delegate to PretrainedConfig's __iter__.
334+
return PretrainedConfig.__iter__(self)
335+
331336
# transformers >= 5.x adds validate() which conflicts with Pydantic v2's
332337
# validate() classmethod. Guard so we don't stub it on older transformers.
333338
if "validate" in vars(PretrainedConfig):
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""MTP checkpoint conversion utilities."""
2+
3+
from speculators.convert.mtp.converter import (
4+
MTP_EXACT_REMAP,
5+
MTP_PREFIX_REMAP,
6+
MTPConverter,
7+
)
8+
9+
__all__ = ["MTP_EXACT_REMAP", "MTP_PREFIX_REMAP", "MTPConverter"]

0 commit comments

Comments
 (0)