main
py 311 lines 11.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import tempfile
5 from unittest.mock import Mock
6
7 import huggingface_hub.constants
8 import pytest
9 from huggingface_hub.utils import LocalEntryNotFoundError
10
11 from vllm.model_executor.model_loader.weight_utils import (
12 download_weights_from_hf,
13 drop_checkpoint_cache,
14 maybe_remap_kv_scale_name,
15 )
16
17
18 def test_download_weights_from_hf():
19 with tempfile.TemporaryDirectory() as tmpdir:
20 # assert LocalEntryNotFoundError error is thrown
21 # if offline is set and model is not cached
22 huggingface_hub.constants.HF_HUB_OFFLINE = True
23 with pytest.raises(LocalEntryNotFoundError):
24 download_weights_from_hf(
25 "facebook/opt-125m",
26 allow_patterns=["*.safetensors", "*.bin"],
27 cache_dir=tmpdir,
28 )
29
30 # download the model
31 huggingface_hub.constants.HF_HUB_OFFLINE = False
32 download_weights_from_hf(
33 "facebook/opt-125m",
34 allow_patterns=["*.safetensors", "*.bin"],
35 cache_dir=tmpdir,
36 )
37
38 # now it should work offline
39 huggingface_hub.constants.HF_HUB_OFFLINE = True
40 assert (
41 download_weights_from_hf(
42 "facebook/opt-125m",
43 allow_patterns=["*.safetensors", "*.bin"],
44 cache_dir=tmpdir,
45 )
46 is not None
47 )
48
49
50 class TestMaybeRemapKvScaleName:
51 """Tests for maybe_remap_kv_scale_name covering all checkpoint formats."""
52
53 PARAMS_DICT = {
54 "model.layers.0.self_attn.attn.k_scale": None,
55 "model.layers.0.self_attn.attn.v_scale": None,
56 "model.layers.0.self_attn.attn.q_scale": None,
57 "model.layers.0.self_attn.qkv_proj.weight": None,
58 }
59
60 def test_qkv_proj_k_scale(self):
61 """Qwen3-MoE / llm-compressor format: qkv_proj.k_scale -> attn.k_scale
62 Regression test for https://github.com/vllm-project/vllm/issues/25047"""
63 result = maybe_remap_kv_scale_name(
64 "model.layers.0.self_attn.qkv_proj.k_scale", self.PARAMS_DICT
65 )
66 assert result == "model.layers.0.self_attn.attn.k_scale"
67
68 def test_qkv_proj_v_scale(self):
69 """Qwen3-MoE / llm-compressor format: qkv_proj.v_scale -> attn.v_scale
70 Regression test for https://github.com/vllm-project/vllm/issues/25047"""
71 result = maybe_remap_kv_scale_name(
72 "model.layers.0.self_attn.qkv_proj.v_scale", self.PARAMS_DICT
73 )
74 assert result == "model.layers.0.self_attn.attn.v_scale"
75
76 def test_modelopt_k_proj_k_scale(self):
77 """ModelOpt format: k_proj.k_scale -> attn.k_scale."""
78 result = maybe_remap_kv_scale_name(
79 "model.layers.0.self_attn.k_proj.k_scale", self.PARAMS_DICT
80 )
81 assert result == "model.layers.0.self_attn.attn.k_scale"
82
83 def test_modelopt_v_proj_v_scale(self):
84 """ModelOpt format: v_proj.v_scale -> attn.v_scale."""
85 result = maybe_remap_kv_scale_name(
86 "model.layers.0.self_attn.v_proj.v_scale", self.PARAMS_DICT
87 )
88 assert result == "model.layers.0.self_attn.attn.v_scale"
89
90 def test_deprecated_kv_scale(self):
91 """Old format: kv_scale -> attn.k_scale (deprecated)."""
92 result = maybe_remap_kv_scale_name(
93 "model.layers.0.self_attn.kv_scale", self.PARAMS_DICT
94 )
95 assert result == "model.layers.0.self_attn.attn.k_scale"
96
97 def test_default_bare_k_scale(self):
98 """Default format: .k_scale -> .attn.k_scale."""
99 result = maybe_remap_kv_scale_name(
100 "model.layers.0.self_attn.k_scale", self.PARAMS_DICT
101 )
102 assert result == "model.layers.0.self_attn.attn.k_scale"
103
104 def test_non_scale_name_unchanged(self):
105 """Non-scale names should be returned unchanged."""
106 name = "model.layers.0.self_attn.qkv_proj.weight"
107 result = maybe_remap_kv_scale_name(name, self.PARAMS_DICT)
108 assert result == name
109
110 def test_nvfp4_modelopt_k_proj_k_scale(self):
111 """ModelOpt NVFP4 format (e.g. nvidia/Qwen3-30B-A3B-NVFP4):
112 k_proj.k_scale -> attn.k_scale.
113 Validates that NVFP4 checkpoints are not broken by this change."""
114 result = maybe_remap_kv_scale_name(
115 "model.layers.0.self_attn.k_proj.k_scale", self.PARAMS_DICT
116 )
117 assert result == "model.layers.0.self_attn.attn.k_scale"
118
119 def test_nvfp4_modelopt_v_proj_v_scale(self):
120 """ModelOpt NVFP4 format (e.g. nvidia/Qwen3-30B-A3B-NVFP4):
121 v_proj.v_scale -> attn.v_scale.
122 Validates that NVFP4 checkpoints are not broken by this change."""
123 result = maybe_remap_kv_scale_name(
124 "model.layers.0.self_attn.v_proj.v_scale", self.PARAMS_DICT
125 )
126 assert result == "model.layers.0.self_attn.attn.v_scale"
127
128 def test_qwen3_vl_moe_qkv_proj_k_scale(self):
129 """Qwen3-VL-MoE uses the same fused qkv_proj naming as Qwen3-MoE.
130 Regression test for qwen3_vl_moe.py fix (same bug as #25047)."""
131 result = maybe_remap_kv_scale_name(
132 "model.layers.0.self_attn.qkv_proj.k_scale", self.PARAMS_DICT
133 )
134 assert result == "model.layers.0.self_attn.attn.k_scale"
135
136 def test_qwen3_vl_moe_qkv_proj_v_scale(self):
137 """Qwen3-VL-MoE uses the same fused qkv_proj naming as Qwen3-MoE.
138 Regression test for qwen3_vl_moe.py fix (same bug as #25047)."""
139 result = maybe_remap_kv_scale_name(
140 "model.layers.0.self_attn.qkv_proj.v_scale", self.PARAMS_DICT
141 )
142 assert result == "model.layers.0.self_attn.attn.v_scale"
143
144 def test_nvfp4_weight_scale_not_remapped(self):
145 """NVFP4 weight_scale should not be touched by remap (not a kv scale)."""
146 name = "model.layers.0.self_attn.k_proj.weight_scale"
147 result = maybe_remap_kv_scale_name(name, self.PARAMS_DICT)
148 assert result == name
149
150 def test_nvfp4_input_scale_not_remapped(self):
151 """NVFP4 input_scale should not be touched by remap (not a kv scale)."""
152 name = "model.layers.0.self_attn.k_proj.input_scale"
153 result = maybe_remap_kv_scale_name(name, self.PARAMS_DICT)
154 assert result == name
155
156 def test_missing_target_returns_none(self):
157 """If remapped name not in params_dict, return None."""
158 empty_params: dict[str, None] = {}
159 result = maybe_remap_kv_scale_name(
160 "model.layers.0.self_attn.qkv_proj.k_scale", empty_params
161 )
162 assert result is None
163
164
165 def test_checkpoint_weight_mapper_discards_serialized_g_idx():
166 from vllm.model_executor.layers.quantization.base_config import (
167 QuantizationConfig,
168 )
169
170 mapper = QuantizationConfig.get_checkpoint_weight_mapper()
171
172 assert mapper._map_name("model.layers.0.g_idx") is None
173 assert mapper._map_name("model.layers.0.qweight") == "model.layers.0.qweight"
174
175
176 class TestKvCacheScaleMapper:
177 """The `WeightsMapper` returned by `get_cache_scale_mapper` replaces the
178 per-model `maybe_remap_kv_scale_name` calls. It must remap the same set of
179 checkpoint formats (the non-`params_dict`-dependent ones) and be idempotent
180 so it composes safely with a model's own qkv/gate_up `hf_to_vllm_mapper`."""
181
182 def _mapper(self):
183 # `get_cache_scale_mapper` does not use `self`; call it on the base
184 # class to get the default (non-config-specific) mapper.
185 from vllm.model_executor.layers.quantization.base_config import (
186 QuantizationConfig,
187 )
188
189 return QuantizationConfig.get_cache_scale_mapper()
190
191 def _map(self, name: str) -> str | None:
192 return self._mapper()._map_name(name)
193
194 @pytest.mark.parametrize(
195 "name,expected",
196 [
197 # Qwen3-MoE / llm-compressor fused qkv_proj
198 (
199 "model.layers.0.self_attn.qkv_proj.k_scale",
200 "model.layers.0.self_attn.attn.k_scale",
201 ),
202 (
203 "model.layers.0.self_attn.qkv_proj.v_scale",
204 "model.layers.0.self_attn.attn.v_scale",
205 ),
206 # ModelOpt / NVFP4 k_proj/v_proj
207 (
208 "model.layers.0.self_attn.k_proj.k_scale",
209 "model.layers.0.self_attn.attn.k_scale",
210 ),
211 (
212 "model.layers.0.self_attn.v_proj.v_scale",
213 "model.layers.0.self_attn.attn.v_scale",
214 ),
215 # deprecated fused kv_scale and bare scales
216 (
217 "model.layers.0.self_attn.kv_scale",
218 "model.layers.0.self_attn.attn.k_scale",
219 ),
220 (
221 "model.layers.0.self_attn.k_scale",
222 "model.layers.0.self_attn.attn.k_scale",
223 ),
224 # NemotronH mixer
225 (
226 "model.layers.0.mixer.k_proj.k_scale",
227 "model.layers.0.mixer.attn.k_scale",
228 ),
229 # already in vLLM form -> unchanged (idempotent)
230 (
231 "model.layers.0.self_attn.attn.k_scale",
232 "model.layers.0.self_attn.attn.k_scale",
233 ),
234 # non-kv scales must not be touched
235 (
236 "model.layers.0.self_attn.k_proj.weight_scale",
237 "model.layers.0.self_attn.k_proj.weight_scale",
238 ),
239 (
240 "model.layers.0.self_attn.k_proj.input_scale",
241 "model.layers.0.self_attn.k_proj.input_scale",
242 ),
243 # regular weights untouched
244 (
245 "model.layers.0.self_attn.q_proj.weight",
246 "model.layers.0.self_attn.q_proj.weight",
247 ),
248 ],
249 )
250 def test_remap(self, name, expected):
251 assert self._map(name) == expected
252
253 @pytest.mark.parametrize(
254 "name",
255 [
256 "model.layers.0.self_attn.k_scale",
257 "model.layers.0.self_attn.k_proj.k_scale",
258 "model.layers.0.self_attn.qkv_proj.v_scale",
259 "model.layers.0.mixer.k_proj.k_scale",
260 ],
261 )
262 def test_idempotent(self, name):
263 once = self._map(name)
264 assert once is not None
265 assert self._map(once) == once
266
267 def test_composes_with_qkv_mapper(self):
268 """Applied together with a model's qkv/gate_up mapper, the regex scale
269 rules run before the substr rename, so scales are normalized to `.attn.`
270 and regular projections are still fused correctly."""
271 from vllm.model_executor.models.utils import WeightsMapper
272
273 model_mapper = WeightsMapper(
274 orig_to_new_substr={
275 ".q_proj": ".qkv_proj.q",
276 ".k_proj": ".qkv_proj.k",
277 ".v_proj": ".qkv_proj.v",
278 }
279 )
280 # AutoWeightsLoader does `mapper |= cache_scale_mapper`
281 combined = model_mapper | self._mapper()
282
283 assert (
284 combined._map_name("model.layers.0.self_attn.q_proj.weight")
285 == "model.layers.0.self_attn.qkv_proj.q.weight"
286 )
287 assert (
288 combined._map_name("model.layers.0.self_attn.k_proj.k_scale")
289 == "model.layers.0.self_attn.attn.k_scale"
290 )
291 assert (
292 combined._map_name("model.layers.0.self_attn.k_scale")
293 == "model.layers.0.self_attn.attn.k_scale"
294 )
295
296
297 @pytest.mark.parametrize("fails", [False, True])
298 def test_drop_checkpoint_cache_releases_local_weight_files(
299 tmp_path, monkeypatch, fails
300 ):
301 """Evict only weights; cache eviction errors must not prevent loading."""
302 for name in ("weights.safetensors", "config.json"):
303 (tmp_path / name).touch()
304 fadvise = Mock(side_effect=OSError("cache eviction failed") if fails else None)
305 monkeypatch.setattr("os.posix_fadvise", fadvise)
306 drop_checkpoint_cache(str(tmp_path))
307 fadvise.assert_called_once()
308
309
310 if __name__ == "__main__":
311 test_download_weights_from_hf()