Skip to content

Commit 437d60b

Browse files
author
Eric Hartford
committed
Merge pull request #56 from QuixiAI/fix/qwen35-gguf-mtp
Load embedded Qwen GGUF MTP heads without mutating the target
2 parents 79ae42c + f8153d2 commit 437d60b

7 files changed

Lines changed: 352 additions & 8 deletions

File tree

‎perf/optimization_status.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34372,6 +34372,43 @@ than PyPI 1.3.0), and the FP8 GEMMs.
3437234372
equal the dequantized HF-grouped reference to 2.4e-14 at TP 1/2/4/8.
3437334373
Live model parity and throughput remain unqualified (no Metal or MI300X
3437434374
on the box); the per-layer gather is new decode work on qwen38-q2kxl-1.
34375+
34376+
34377+
## 2026-09-19 - Load embedded Qwen GGUF MTP heads without mutating the target
34378+
34379+
- Status: retained for review.
34380+
- Baseline: `fix/qwen35-gguf-gdn-layout`; forward-port from the B70 serving integration.
34381+
- Change: Map appended NextN tensors to the draft, preserve dense/MoE dispatch and detach cached GGUF config before draft overrides; retain main vision and GPTQ support.
34382+
- Correctness: Four embedded-MTP configuration/mapping tests plus eight inherited layout tests passed on CPU; hardware model restore and serving remain unqualified.
34383+
- Results: no new throughput measurement or hardware qualification claimed.
34384+
- Decision: submit as a focused PR; preserve current-main behavior outside this fix.
34385+
- Raw artifacts: local test output; no traffic captures or model data committed.
34386+
- Follow-up (2026-10-14), root cause of the duplicate MTP attention
34387+
registration: build_qwen35_config_from_gguf is @cache'd and an embedded
34388+
MTP draft parses the same file, so without the parser's deepcopy the
34389+
target and draft ModelConfig share one config object.
34390+
SpeculativeConfig.hf_config_override rewrites its model_type and
34391+
architectures in place, and get_model_architecture reads
34392+
hf_config.architectures at load time, so the worker built the TARGET as
34393+
Qwen3_5MTP (17 tensors, one Attention at mtp.layers.0.self_attn.attn);
34394+
the real draft then failed with "Duplicate layer name". Reproduced on a
34395+
synthetic 4+1-layer qwen35 GGUF (tiled GDN K=4/V=12, Q4_K ssm_out, one
34396+
NextN block) on the RTX 5090 by removing the deepcopy. With the deepcopy
34397+
the registration never repeats on either runner, so
34398+
`_clear_stale_mtp_attention_context` (which deleted the bogus target's
34399+
registrations and hid the wrong target model) is removed and
34400+
qwen3_5_mtp.py is back to main.
34401+
- Follow-up (2026-10-14), embedding/lm_head: the draft name map loaded
34402+
token_embd (dequantized to bf16, ~2.5 GB on Qwen3.8-27B) and output,
34403+
which both runners then discard because MTP drafts always take the
34404+
target's modules (V1 _maybe_share_*, V2 load_eagle_model; Qwen3_5MTP has
34405+
no has_own_* flags), the same as the HF embedded-MTP path. Both are now
34406+
unmapped: the draft reads 15 tensors and dequantizes 0 modules. Same
34407+
seeded V2 completion before and after.
34408+
- Correctness: synthetic GGUF serve smoke on V2 and V1 runners (MTP k=1,
34409+
prefix caching, seeded temperature 1.0 / top_p 0.95 / top_k 20) reaches
34410+
generation; random weights, so no quality or acceptance claim. No real
34411+
Qwen3.8 GGUF or throughput run. Raw (operator box): ~/.local/scratch/results/2026-10-14/qwen35-gguf-mtp/ (builder, runner, logs).
3437534412
## 2026-09-19 - Authenticate registered Hugging Face downloads without losing resume headers
3437634413

3437734414
- Status: retained for review.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""Focused contracts for the Qwen3.5 GGUF fused GDN projections."""
4+
5+
from types import SimpleNamespace
6+
from unittest import mock
7+
8+
from vllm.model_executor.model_loader.gguf_adapters.qwen35 import (
9+
Qwen35GGUFAdapter,
10+
)
11+
from vllm.model_executor.models.qwen3_5 import (
12+
_qwen35_mlp_kind,
13+
)
14+
from vllm.transformers_utils.configs.qwen3_5 import Qwen3_5TextConfig
15+
from vllm.transformers_utils.configs.qwen3_5_moe import Qwen3_5MoeTextConfig
16+
from vllm.transformers_utils.gguf_config_parser import GGUFConfigParser
17+
18+
19+
def test_qwen35_gguf_mtp_maps_only_the_embedded_nextn_block():
20+
text_config = SimpleNamespace(num_hidden_layers=64)
21+
hf_config = SimpleNamespace(
22+
model_type="qwen3_5_mtp",
23+
architectures=["Qwen3_5MTP"],
24+
get_text_config=lambda: text_config,
25+
)
26+
adapter = Qwen35GGUFAdapter(hf_config)
27+
name_map = adapter.build_name_map(SimpleNamespace(hf_config=hf_config))
28+
29+
# The draft shares the target's embedding and lm_head; it never loads
30+
# (or dequantizes) its own copies.
31+
assert "token_embd.weight" not in name_map
32+
assert "output.weight" not in name_map
33+
assert "output_norm.weight" not in name_map
34+
assert name_map["blk.64.nextn.eh_proj.weight"] == "mtp.fc.weight"
35+
assert name_map["blk.64.nextn.enorm.weight"] == ("mtp.pre_fc_norm_embedding.weight")
36+
assert name_map["blk.64.nextn.hnorm.weight"] == "mtp.pre_fc_norm_hidden.weight"
37+
assert name_map["blk.64.nextn.shared_head_norm.weight"] == "mtp.norm.weight"
38+
assert name_map["blk.64.attn_q.weight"] == ("mtp.layers.0.self_attn.q_proj.weight")
39+
assert name_map["blk.64.ffn_down.weight"] == ("mtp.layers.0.mlp.down_proj.weight")
40+
assert not any(name.startswith("blk.63.") for name in name_map)
41+
42+
43+
def test_qwen35_mtp_model_type_preserves_dense_and_moe_mlp_dispatch():
44+
dense = Qwen3_5TextConfig(num_hidden_layers=1)
45+
dense.model_type = "qwen3_5_mtp"
46+
dense.architectures = ["Qwen3_5MTP"]
47+
assert _qwen35_mlp_kind(dense) == "dense"
48+
49+
moe = Qwen3_5MoeTextConfig(num_hidden_layers=1)
50+
moe.model_type = "qwen3_5_mtp"
51+
moe.architectures = ["Qwen3_5MoeMTP"]
52+
assert _qwen35_mlp_kind(moe) == "moe"
53+
54+
55+
def test_gguf_parser_detaches_cached_config_for_embedded_mtp_override():
56+
cached = Qwen3_5TextConfig(num_hidden_layers=64)
57+
cached.architectures = ["Qwen3_5ForCausalLM"]
58+
parser = GGUFConfigParser()
59+
60+
with (
61+
mock.patch(
62+
"vllm.transformers_utils.gguf_config_parser.gguf_architecture",
63+
return_value="qwen35",
64+
),
65+
mock.patch(
66+
"vllm.transformers_utils.gguf_qwen35.build_qwen35_config_from_gguf",
67+
return_value=cached,
68+
),
69+
):
70+
_, target = parser.parse("model.gguf", trust_remote_code=False)
71+
_, draft = parser.parse("model.gguf", trust_remote_code=False)
72+
73+
draft.model_type = "qwen3_5_mtp"
74+
draft.architectures = ["Qwen3_5MTP"]
75+
76+
assert target is not draft
77+
assert target is not cached
78+
assert target.model_type == "qwen3_5_text"
79+
assert target.architectures == ["Qwen3_5ForCausalLM"]
80+
81+
82+
def test_qwen35_gguf_mtp_draft_runs_on_the_target_embedding_and_lm_head():
83+
"""The GGUF draft never loads token_embd/output, so it must share.
84+
85+
Qwen3_5MTP declares no has_own_embed_tokens / has_own_lm_head, which is
86+
what makes the V2 draft loader hand it the target's modules.
87+
"""
88+
import torch.nn as nn
89+
90+
from vllm.model_executor.models.qwen3_5_mtp import Qwen3_5MTP
91+
from vllm.v1.worker.gpu.spec_decode.eagle import utils as eagle_utils
92+
93+
draft = Qwen3_5MTP.__new__(Qwen3_5MTP)
94+
nn.Module.__init__(draft)
95+
draft.model = nn.Module()
96+
draft.model.embed_tokens = nn.Embedding(4, 2)
97+
draft.model.layers = nn.ModuleList([nn.Module()])
98+
draft.lm_head = nn.Linear(2, 4)
99+
target = nn.Module()
100+
target.model = nn.Module()
101+
target.model.embed_tokens = nn.Embedding(4, 2)
102+
target.lm_head = nn.Linear(2, 4)
103+
config = SimpleNamespace(
104+
speculative_config=SimpleNamespace(
105+
draft_model_config=object(), kv_cache_dtype=None
106+
)
107+
)
108+
109+
with (
110+
mock.patch.object(eagle_utils, "get_model", return_value=draft),
111+
mock.patch.object(
112+
eagle_utils,
113+
"get_pp_group",
114+
return_value=SimpleNamespace(world_size=1),
115+
),
116+
):
117+
loaded = eagle_utils.load_eagle_model(target, config)
118+
119+
assert loaded.model.embed_tokens is target.model.embed_tokens
120+
assert loaded.lm_head is target.lm_head
121+
122+
123+
def _mtp_name_map(pp_size: int) -> dict[str, str]:
124+
from vllm.config import VllmConfig, set_current_vllm_config
125+
126+
text_config = SimpleNamespace(num_hidden_layers=64)
127+
hf_config = SimpleNamespace(
128+
model_type="qwen3_5_mtp",
129+
architectures=["Qwen3_5MTP"],
130+
get_text_config=lambda: text_config,
131+
)
132+
vllm_config = VllmConfig()
133+
object.__setattr__(vllm_config.parallel_config, "pipeline_parallel_size", pp_size)
134+
adapter = Qwen35GGUFAdapter(hf_config)
135+
with set_current_vllm_config(vllm_config):
136+
return adapter.build_name_map(SimpleNamespace(hf_config=hf_config))
137+
138+
139+
def test_qwen35_gguf_mtp_loads_its_own_embedding_only_under_pp():
140+
"""PP=1: the runners share the target embedding, so token_embd stays
141+
unmapped. PP>1: neither runner shares it, so the draft must load it
142+
or embed through an uninitialised table. lm_head is shared either way
143+
(the target's lives on the draft's last rank)."""
144+
single = _mtp_name_map(1)
145+
assert "token_embd.weight" not in single
146+
assert "output.weight" not in single
147+
148+
pipelined = _mtp_name_map(2)
149+
assert pipelined["token_embd.weight"] == "model.embed_tokens.weight"
150+
assert "output.weight" not in pipelined
151+
assert pipelined["blk.64.nextn.eh_proj.weight"] == "mtp.fc.weight"
152+
153+
154+
def _toy_mtp_draft_and_target():
155+
import torch.nn as nn
156+
157+
from vllm.model_executor.models.qwen3_5_mtp import Qwen3_5MTP
158+
159+
draft = Qwen3_5MTP.__new__(Qwen3_5MTP)
160+
nn.Module.__init__(draft)
161+
draft.model = nn.Module()
162+
draft.model.embed_tokens = nn.Embedding(4, 2)
163+
draft.model.layers = nn.ModuleList([nn.Module()])
164+
draft.lm_head = nn.Linear(2, 4)
165+
target = nn.Module()
166+
target.model = nn.Module()
167+
target.model.embed_tokens = nn.Embedding(4, 2)
168+
target.lm_head = nn.Linear(2, 4)
169+
return draft, target
170+
171+
172+
def test_qwen35_gguf_mtp_v1_proposer_shares_target_embedding_and_lm_head():
173+
"""V1 mirror of the V2 load_eagle_model sharing contract."""
174+
from vllm.v1.spec_decode import llm_base_proposer
175+
176+
draft, target = _toy_mtp_draft_and_target()
177+
proposer = llm_base_proposer.SpecDecodeBaseProposer.__new__(
178+
llm_base_proposer.SpecDecodeBaseProposer
179+
)
180+
proposer.model = draft
181+
proposer.vllm_config = SimpleNamespace(speculative_config=None)
182+
proposer.use_local_argmax_reduction = False
183+
with mock.patch.object(
184+
llm_base_proposer,
185+
"get_pp_group",
186+
return_value=SimpleNamespace(world_size=1),
187+
):
188+
proposer._maybe_share_embeddings(target)
189+
proposer._maybe_share_lm_head(target)
190+
191+
assert draft.model.embed_tokens is target.model.embed_tokens
192+
assert draft.lm_head is target.lm_head
193+
194+
195+
def test_qwen35_gguf_mtp_v1_proposer_keeps_draft_embedding_under_pp():
196+
"""Under PP the V1 runner keeps the draft's own (loaded) embedding."""
197+
from vllm.v1.spec_decode import llm_base_proposer
198+
199+
draft, target = _toy_mtp_draft_and_target()
200+
own = draft.model.embed_tokens
201+
proposer = llm_base_proposer.SpecDecodeBaseProposer.__new__(
202+
llm_base_proposer.SpecDecodeBaseProposer
203+
)
204+
proposer.model = draft
205+
proposer.vllm_config = SimpleNamespace(speculative_config=None)
206+
proposer.use_local_argmax_reduction = False
207+
with mock.patch.object(
208+
llm_base_proposer,
209+
"get_pp_group",
210+
return_value=SimpleNamespace(world_size=2),
211+
):
212+
proposer._maybe_share_embeddings(target)
213+
proposer._maybe_share_lm_head(target)
214+
215+
assert draft.model.embed_tokens is own
216+
assert draft.lm_head is target.lm_head

‎vllm/config/speculative.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,8 +542,16 @@ def hf_config_override(hf_config: PretrainedConfig) -> PretrainedConfig:
542542
hf_config.update(
543543
{"n_predict": n_predict, "architectures": ["Exaone4_5_MTP"]}
544544
)
545-
if hf_config.model_type in ("qwen3_5", "qwen3_5_moe"):
546-
is_moe = hf_config.model_type == "qwen3_5_moe"
545+
if hf_config.model_type in (
546+
"qwen3_5",
547+
"qwen3_5_moe",
548+
"qwen3_5_text",
549+
"qwen3_5_moe_text",
550+
):
551+
# GGUF text-only checkpoints build the text config directly,
552+
# while HF multimodal checkpoints wrap it in qwen3_5. Both carry
553+
# the same embedded MTP layout.
554+
is_moe = hf_config.model_type in ("qwen3_5_moe", "qwen3_5_moe_text")
547555
hf_config.model_type = "qwen3_5_mtp"
548556
n_predict = getattr(hf_config, "mtp_num_hidden_layers", None)
549557
hf_config.update(

‎vllm/model_executor/model_loader/gguf_adapters/qwen35.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ def _vision_linear_modules(depth: int) -> list[str]:
145145
return names
146146

147147

148+
_MTP_EXTRA_RENAMES = {
149+
"nextn.eh_proj.weight": "fc.weight",
150+
"nextn.enorm.weight": "pre_fc_norm_embedding.weight",
151+
"nextn.hnorm.weight": "pre_fc_norm_hidden.weight",
152+
"nextn.shared_head_norm.weight": "norm.weight",
153+
}
154+
155+
148156
def _untile_v_head_runs(
149157
tensor: torch.Tensor, *, axis: int, repeats: int, runs: int, run_width: int
150158
) -> torch.Tensor:
@@ -244,18 +252,63 @@ def _untile_packed_v_head_columns(
244252
)
245253

246254

255+
def _pipeline_parallel_size() -> int:
256+
from vllm.config import get_current_vllm_config_or_none
257+
258+
vllm_config = get_current_vllm_config_or_none()
259+
if vllm_config is None:
260+
return 1
261+
return int(vllm_config.parallel_config.pipeline_parallel_size)
262+
263+
247264
class Qwen35GGUFAdapter(GGUFWeightsAdapter):
248265
"""The qwen35 hybrid target GGUF (dense Qwen3.5 family)."""
249266

250267
def patch_hf_config(self, model_path: str, hf_config):
251268
# The config was built from this same GGUF; nothing to patch.
252269
return hf_config
253270

271+
@staticmethod
272+
def _is_mtp_model(model_config: ModelConfig) -> bool:
273+
config = model_config.hf_config
274+
architectures = getattr(config, "architectures", None) or []
275+
return (
276+
getattr(config, "model_type", None) == "qwen3_5_mtp"
277+
or "Qwen3_5MTP" in architectures
278+
or "Qwen3_5MoeMTP" in architectures
279+
)
280+
254281
def build_name_map(self, model_config: ModelConfig) -> dict[str, str]:
255282
config = model_config.hf_config
256283
text_config = (
257284
config.get_text_config() if hasattr(config, "get_text_config") else config
258285
)
286+
if self._is_mtp_model(model_config):
287+
# The appended GGUF block is numbered after the target backbone,
288+
# while Qwen3_5MTP numbers its compact draft layers from zero.
289+
mtp_idx = int(text_config.num_hidden_layers)
290+
# Without pipeline parallelism token_embd / output stay unmapped:
291+
# both runners (V1 _maybe_share_embeddings/_maybe_share_lm_head,
292+
# V2 load_eagle_model) replace the draft's embed_tokens and
293+
# lm_head with the target's, so loading them would only
294+
# dequantize a second ~2.5 GB embedding table and a second
295+
# lm_head that are discarded right after.
296+
# With PP > 1 neither runner shares the embedding (the target's
297+
# embed_tokens lives on the first rank, the draft on the last),
298+
# so the draft must load its own or run on an uninitialised
299+
# table. lm_head is still shared under PP: both runners share it
300+
# unconditionally and the target's lm_head lives on the same
301+
# (last) rank as the draft, so output stays unmapped.
302+
name_map: dict[str, str] = {}
303+
if _pipeline_parallel_size() > 1:
304+
name_map["token_embd.weight"] = "model.embed_tokens.weight"
305+
per_layer = {**_COMMON_BLK_RENAMES, **_FULL_ATTN_BLK_RENAMES}
306+
for gguf_part, hf_part in per_layer.items():
307+
name_map[f"blk.{mtp_idx}.{gguf_part}"] = f"mtp.layers.0.{hf_part}"
308+
for gguf_part, hf_part in _MTP_EXTRA_RENAMES.items():
309+
name_map[f"blk.{mtp_idx}.{gguf_part}"] = f"mtp.{hf_part}"
310+
return name_map
311+
259312
# When the mmproj was found the config is the composite
260313
# Qwen3_5ForConditionalGeneration; the text half lives under
261314
# `language_model.` (Muse-Glimmer pattern).

0 commit comments

Comments
 (0)