|
| 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 |
0 commit comments