main
py 757 lines 29.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for FusedMoEFactory weight loading with padded hidden dimensions.
4
5 When using DeepEP backends or NIXL EP with models like nemotron_h,
6 hidden_size may be rounded up (e.g., 2688 -> 3072) for backend requirements.
7 Weight parameters are created with the padded size, but checkpoint weights
8 have the original unpadded size. These tests verify that weight loading
9 correctly handles this mismatch.
10 """
11
12 import pytest
13 import torch
14
15 from vllm.model_executor.layers.fused_moe.oracle.unquantized import (
16 UnquantizedMoeBackend,
17 )
18 from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts
19 from vllm.model_executor.layers.fused_moe.unquantized_fused_moe_method import (
20 UnquantizedFusedMoEMethod,
21 )
22
23 from .utils import make_dummy_moe_config
24
25
26 class TestGetHiddenDim:
27 """Unit tests for _get_hidden_dim."""
28
29 def test_2d_non_transposed_w2(self):
30 # w2: shard_dim=1 (intermediate), hidden=0
31 assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) == 0
32
33 def test_2d_non_transposed_w13(self):
34 # w1/w3: shard_dim=0 (intermediate), hidden=1
35 assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) == 1
36
37 def test_2d_transposed_w2(self):
38 # transposed w2: shard_dim=0, hidden=1
39 assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) == 1
40
41 def test_2d_transposed_w13(self):
42 # transposed w1/w3: shard_dim=1, hidden=0
43 assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) == 0
44
45 def test_3d_non_transposed_w2(self):
46 # 3D w2: shard_dim=2, hidden=1
47 assert RoutedExperts._get_hidden_dim(shard_dim=2, ndim=3) == 1
48
49 def test_3d_non_transposed_w13(self):
50 # 3D w1/w3: shard_dim=1, hidden=2
51 assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=3) == 2
52
53 def test_3d_transposed_w2(self):
54 # transposed 3D w2: shard_dim=1, hidden=2
55 assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=3) == 2
56
57 def test_3d_transposed_w13(self):
58 # transposed 3D w1/w3: shard_dim=2, hidden=1
59 assert RoutedExperts._get_hidden_dim(shard_dim=2, ndim=3) == 1
60
61 def test_1d_returns_zero(self):
62 # 1D per-channel scales: always returns 0
63 assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=1) == 0
64 assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=1) == 0
65
66 def test_invalid_shard_dim_raises(self):
67 # shard_dim outside the data dimensions should raise
68 with pytest.raises(ValueError, match="not a valid data dimension"):
69 RoutedExperts._get_hidden_dim(shard_dim=0, ndim=3)
70
71
72 class TestOrientFusedWeight:
73 """Unit tests for _orient_fused_weight.
74
75 E=8 experts, hidden=3072, intermediate=1024.
76 """
77
78 HIDDEN = 3072
79
80 def test_w13_standard_orientation_is_untouched(self):
81 weight = torch.randn(8, 2048, self.HIDDEN)
82 result = RoutedExperts._orient_fused_weight(weight, False)
83 assert result.shape == (8, 2048, self.HIDDEN)
84
85 def test_w13_transposed_checkpoint_is_normalised(self):
86 # e.g. Qwen3 VL MoE stores [experts, hidden, 2 * intermediate]
87 weight = torch.randn(8, self.HIDDEN, 2048)
88 result = RoutedExperts._orient_fused_weight(weight, True)
89 assert result.shape == (8, 2048, self.HIDDEN)
90
91 def test_w2_standard_orientation_is_untouched(self):
92 weight = torch.randn(8, self.HIDDEN, 1024)
93 result = RoutedExperts._orient_fused_weight(weight, False)
94 assert result.shape == (8, self.HIDDEN, 1024)
95
96 def test_w2_transposed_checkpoint_is_normalised(self):
97 weight = torch.randn(8, 1024, self.HIDDEN)
98 result = RoutedExperts._orient_fused_weight(weight, True)
99 assert result.shape == (8, self.HIDDEN, 1024)
100
101 def test_w13_per_channel_scale_is_untouched(self):
102 # A fused per-channel scale has no hidden dim, so transposing it would
103 # leave chunk()/TP sharding operating on the wrong axis.
104 scale = torch.randn(8, 2048, 1)
105 result = RoutedExperts._orient_fused_weight(scale, False)
106 assert result.shape == (8, 2048, 1)
107 assert result.chunk(2, dim=1)[0].shape == (8, 1024, 1)
108
109 def test_w2_per_channel_scale_is_untouched(self):
110 scale = torch.randn(8, self.HIDDEN, 1)
111 result = RoutedExperts._orient_fused_weight(scale, False)
112 assert result.shape == (8, self.HIDDEN, 1)
113
114 def test_block_scale_is_untouched(self):
115 # Block scales are [experts, 2 * intermediate / block, hidden / block]
116 scale = torch.randn(8, 16, 24)
117 result = RoutedExperts._orient_fused_weight(scale, False)
118 assert result.shape == (8, 16, 24)
119 assert result.data_ptr() == scale.data_ptr()
120
121 @pytest.mark.parametrize(
122 "checkpoint_shape",
123 [
124 # Qwen3-VL stores block scales in checkpoint weight orientation.
125 (8, 16, 12),
126 (8, 6, 16),
127 (8, 16, 16),
128 ],
129 )
130 def test_qwen3_vl_transposed_block_scale_uses_explicit_layout(
131 self,
132 checkpoint_shape: tuple[int, ...],
133 ):
134 scale = torch.arange(torch.tensor(checkpoint_shape).prod()).reshape(
135 checkpoint_shape
136 )
137 result = RoutedExperts._orient_fused_weight(scale, True)
138 torch.testing.assert_close(result, scale.transpose(-1, -2))
139
140
141 class TestNarrowExpertDataForPadding:
142 """Unit tests for _narrow_expert_data_for_padding."""
143
144 def test_no_narrowing_when_shapes_match(self):
145 expert_data = torch.zeros(1024, 1024)
146 loaded_weight = torch.randn(1024, 1024)
147 result = RoutedExperts._narrow_expert_data_for_padding(
148 expert_data, loaded_weight, hidden_dim=0
149 )
150 assert result.shape == loaded_weight.shape
151 assert result.data_ptr() == expert_data.data_ptr()
152
153 def test_narrow_w2_hidden_dim(self):
154 # w2: (hidden_size, intermediate_size) - hidden_size padded at dim 0
155 expert_data = torch.zeros(3072, 1024)
156 loaded_weight = torch.randn(2688, 1024)
157 result = RoutedExperts._narrow_expert_data_for_padding(
158 expert_data, loaded_weight, hidden_dim=0
159 )
160 assert result.shape == (2688, 1024)
161
162 def test_narrow_w13_hidden_dim(self):
163 # w1/w3: (intermediate_size, hidden_size) - hidden_size padded at dim 1
164 expert_data = torch.zeros(2048, 3072)
165 loaded_weight = torch.randn(2048, 2688)
166 result = RoutedExperts._narrow_expert_data_for_padding(
167 expert_data, loaded_weight, hidden_dim=1
168 )
169 assert result.shape == (2048, 2688)
170
171 def test_narrow_transposed_w2(self):
172 # transposed w2: (intermediate_size, hidden_size) - hidden at dim 1
173 expert_data = torch.zeros(1024, 3072)
174 loaded_weight = torch.randn(1024, 2688)
175 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2)
176 result = RoutedExperts._narrow_expert_data_for_padding(
177 expert_data, loaded_weight, hidden_dim=hidden_dim
178 )
179 assert result.shape == (1024, 2688)
180
181 def test_narrow_3d_full_load(self):
182 # 3D tensor for full_load path: w2 (num_experts, hidden_size, intermediate)
183 expert_data = torch.zeros(8, 3072, 1024)
184 loaded_weight = torch.randn(8, 2688, 1024)
185 result = RoutedExperts._narrow_expert_data_for_padding(
186 expert_data, loaded_weight, hidden_dim=1
187 )
188 assert result.shape == (8, 2688, 1024)
189
190 def test_narrow_1d_scale(self):
191 # 1D scale tensor: per-channel w2 scale (hidden_size,)
192 expert_data = torch.zeros(3072)
193 loaded_weight = torch.randn(2688)
194 result = RoutedExperts._narrow_expert_data_for_padding(
195 expert_data, loaded_weight, hidden_dim=0
196 )
197 assert result.shape == (2688,)
198
199 def test_scalar_weight_no_op(self):
200 # 0-dim tensor should be a no-op
201 expert_data = torch.zeros(3072)
202 loaded_weight = torch.tensor(1.0)
203 result = RoutedExperts._narrow_expert_data_for_padding(
204 expert_data, loaded_weight, hidden_dim=0
205 )
206 # ndim == 0, so no narrowing
207 assert result.shape == (3072,)
208
209 def test_no_narrowing_when_loaded_weight_larger(self):
210 # Guard: don't narrow if loaded_weight is larger than expert_data
211 expert_data = torch.zeros(2688, 1024)
212 loaded_weight = torch.randn(3072, 1024)
213 result = RoutedExperts._narrow_expert_data_for_padding(
214 expert_data, loaded_weight, hidden_dim=0
215 )
216 assert result.shape == (2688, 1024)
217 assert result.data_ptr() == expert_data.data_ptr()
218
219 def test_negative_hidden_dim_is_noop(self):
220 # Negative hidden_dim should be a safe no-op (0 <= check)
221 expert_data = torch.zeros(3072, 1024)
222 loaded_weight = torch.randn(2688, 1024)
223 result = RoutedExperts._narrow_expert_data_for_padding(
224 expert_data, loaded_weight, hidden_dim=-1
225 )
226 # -1 fails the 0 <= check, so no narrowing
227 assert result.shape == (3072, 1024)
228 assert result.data_ptr() == expert_data.data_ptr()
229
230 def test_only_narrows_hidden_dim(self):
231 # Verify that only the specified hidden_dim is narrowed,
232 # even when other dimensions also differ
233 expert_data = torch.zeros(3072, 2048)
234 loaded_weight = torch.randn(2688, 1024)
235 result = RoutedExperts._narrow_expert_data_for_padding(
236 expert_data, loaded_weight, hidden_dim=0
237 )
238 # Only dim 0 (hidden) should be narrowed; dim 1 stays at 2048
239 assert result.shape == (2688, 2048)
240
241 def test_narrowed_data_shares_storage(self):
242 # Verify narrowing returns a view (writes go to original tensor)
243 expert_data = torch.zeros(3072, 1024)
244 loaded_weight = torch.randn(2688, 1024)
245 result = RoutedExperts._narrow_expert_data_for_padding(
246 expert_data, loaded_weight, hidden_dim=0
247 )
248 result.copy_(loaded_weight)
249 # The first 2688 rows of expert_data should now have loaded_weight
250 assert torch.equal(expert_data[:2688, :], loaded_weight)
251 # Padded region should remain zero
252 assert torch.equal(expert_data[2688:, :], torch.zeros(3072 - 2688, 1024))
253
254
255 class TestWeightLoadingWithPaddedHiddenSize:
256 """Integration-style tests that simulate padded weight loading."""
257
258 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
259 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float32])
260 @pytest.mark.parametrize("tp_rank", [0, 1])
261 def test_load_w2_chunks_noncontiguous_cpu_source_to_padded_cuda(
262 self, dtype, tp_rank
263 ):
264 hidden = 4096
265 intermediate = 1024
266 loaded_weight = (
267 torch.arange(hidden * intermediate * 2)
268 .remainder(251)
269 .to(dtype)
270 .reshape(hidden, intermediate * 2)
271 )
272 tp_source = loaded_weight[
273 :, intermediate * tp_rank : intermediate * (tp_rank + 1)
274 ]
275 expert_data_full = torch.zeros(
276 hidden + 8, intermediate + 8, device="cuda", dtype=dtype
277 )
278 destination = expert_data_full[:hidden, :intermediate]
279
280 assert not tp_source.is_contiguous()
281 assert tp_source.nbytes > 1 << 20
282 assert not destination.is_contiguous()
283
284 experts = object.__new__(RoutedExperts)
285 torch.nn.Module.__init__(experts)
286 experts.moe_config = make_dummy_moe_config()
287 experts.moe_config.moe_parallel_config.tp_size = 2
288
289 torch.accelerator.synchronize()
290 allocated_before = torch.accelerator.memory_allocated()
291 torch.accelerator.reset_peak_memory_stats()
292 experts._load_w2(
293 expert_data=expert_data_full,
294 shard_dim=1,
295 loaded_weight=loaded_weight,
296 tp_rank=tp_rank,
297 )
298 torch.accelerator.synchronize()
299 peak_extra = torch.accelerator.max_memory_allocated() - allocated_before
300
301 # A single copy into the strided CUDA view allocates a full-shard
302 # temporary. Correct values alone would not catch that regression.
303 assert peak_extra < tp_source.nbytes
304
305 torch.testing.assert_close(destination.cpu(), tp_source)
306 assert torch.count_nonzero(expert_data_full[hidden:, :]) == 0
307 assert torch.count_nonzero(expert_data_full[:hidden, intermediate:]) == 0
308
309 def test_load_w2_with_padding(self):
310 """Simulate loading w2 weights when hidden_size is padded."""
311 padded_hidden = 3072
312 original_hidden = 2688
313 intermediate = 1024
314
315 expert_data_full = torch.zeros(padded_hidden, intermediate)
316 loaded_weight = torch.randn(original_hidden, intermediate)
317
318 # w2 non-transposed: shard_dim=1, hidden_dim=0
319 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2)
320 expert_data = RoutedExperts._narrow_expert_data_for_padding(
321 expert_data_full, loaded_weight, hidden_dim=hidden_dim
322 )
323 expert_data.copy_(loaded_weight)
324
325 assert torch.equal(expert_data_full[:original_hidden, :], loaded_weight)
326 assert torch.equal(
327 expert_data_full[original_hidden:, :],
328 torch.zeros(padded_hidden - original_hidden, intermediate),
329 )
330
331 def test_load_w13_with_padding(self):
332 """Simulate loading w1/w3 weights when hidden_size is padded."""
333 padded_hidden = 3072
334 original_hidden = 2688
335 intermediate = 1024
336
337 # w1/w3: (intermediate_size, hidden_size)
338 expert_data_full = torch.zeros(intermediate, padded_hidden)
339 loaded_weight = torch.randn(intermediate, original_hidden)
340
341 # w1 non-transposed: shard_dim=0, hidden_dim=1
342 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2)
343 expert_data = RoutedExperts._narrow_expert_data_for_padding(
344 expert_data_full, loaded_weight, hidden_dim=hidden_dim
345 )
346 expert_data.copy_(loaded_weight)
347
348 assert torch.equal(expert_data_full[:, :original_hidden], loaded_weight)
349 assert torch.equal(
350 expert_data_full[:, original_hidden:],
351 torch.zeros(intermediate, padded_hidden - original_hidden),
352 )
353
354 def test_load_transposed_w2_with_padding(self):
355 """Simulate loading transposed w2 (GPTQ) with padded hidden_size."""
356 padded_hidden = 3072
357 original_hidden = 2688
358 intermediate = 1024
359
360 # transposed w2: (intermediate_size, hidden_size), shard_dim=0
361 expert_data_full = torch.zeros(intermediate, padded_hidden)
362 loaded_weight = torch.randn(intermediate, original_hidden)
363
364 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2)
365 expert_data = RoutedExperts._narrow_expert_data_for_padding(
366 expert_data_full, loaded_weight, hidden_dim=hidden_dim
367 )
368 expert_data.copy_(loaded_weight)
369
370 assert torch.equal(expert_data_full[:, :original_hidden], loaded_weight)
371
372 def test_no_padding_is_noop(self):
373 """Verify that when sizes match, behavior is unchanged."""
374 hidden = 2048
375 intermediate = 1024
376
377 expert_data_full = torch.zeros(hidden, intermediate)
378 loaded_weight = torch.randn(hidden, intermediate)
379
380 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2)
381 expert_data = RoutedExperts._narrow_expert_data_for_padding(
382 expert_data_full, loaded_weight, hidden_dim=hidden_dim
383 )
384 expert_data.copy_(loaded_weight)
385
386 assert torch.equal(expert_data_full, loaded_weight)
387
388 def test_narrow_shard_dim(self):
389 """Simulate loading w2 when both hidden_size and intermediate_size
390 are padded.
391 """
392 padded_hidden = 3072
393 original_hidden = 2688
394 padded_intermediate = 1024
395 original_intermediate = 896
396
397 expert_data_full = torch.zeros(padded_hidden, padded_intermediate)
398 loaded_weight = torch.randn(original_hidden, original_intermediate)
399
400 shard_dim = 1
401 hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=shard_dim, ndim=2)
402 expert_data = RoutedExperts._narrow_expert_data_for_padding(
403 expert_data_full,
404 loaded_weight,
405 hidden_dim=hidden_dim,
406 shard_dim=shard_dim,
407 )
408 expert_data.copy_(loaded_weight)
409
410 assert torch.equal(
411 expert_data_full[:original_hidden, :original_intermediate],
412 loaded_weight,
413 )
414 assert torch.equal(
415 expert_data_full[original_hidden:, :],
416 torch.zeros(padded_hidden - original_hidden, padded_intermediate),
417 )
418 assert torch.equal(
419 expert_data_full[:original_hidden, original_intermediate:],
420 torch.zeros(original_hidden, padded_intermediate - original_intermediate),
421 )
422
423
424 class TestUnquantizedTrtLlmPrePadding:
425 @staticmethod
426 def _make_method(
427 intermediate: int,
428 backend: UnquantizedMoeBackend = UnquantizedMoeBackend.FLASHINFER_TRTLLM,
429 ) -> UnquantizedFusedMoEMethod:
430 moe_config = make_dummy_moe_config(
431 num_experts=2,
432 hidden_dim=64,
433 intermediate_size=intermediate,
434 )
435 method = object.__new__(UnquantizedFusedMoEMethod)
436 method.moe = moe_config
437 method.unquantized_backend = backend
438 method.moe_kernel = None
439 return method
440
441 @pytest.mark.parametrize(
442 "backend,original,expected",
443 [
444 (UnquantizedMoeBackend.FLASHINFER_TRTLLM, 1344, 1408),
445 (UnquantizedMoeBackend.FLASHINFER_TRTLLM, 1408, 1408),
446 (UnquantizedMoeBackend.TRITON, 1344, 1344),
447 ],
448 )
449 def test_rounds_intermediate_before_weight_allocation(
450 self,
451 backend: UnquantizedMoeBackend,
452 original: int,
453 expected: int,
454 ):
455 method = self._make_method(original, backend)
456
457 hidden, intermediate = method.maybe_roundup_sizes(
458 hidden_size=64,
459 intermediate_size_per_partition=original,
460 act_dtype=torch.bfloat16,
461 moe_parallel_config=method.moe.moe_parallel_config,
462 )
463
464 assert hidden == 64
465 assert intermediate == expected
466
467
468 class TestLoadWeightsExpertBias:
469 """Some quantized exports (e.g. GPTQ, llm-compressor NVFP4) materialize
470 all-zero per-expert `.bias` tensors for models whose experts have no bias
471 params. `RoutedExperts.load_weights` needs to ignore them like
472 `AutoWeightsLoader` does, instead of raising AttributeError for the
473 nonexistent `w13_bias`/`w2_bias` params.
474 """
475
476 NUM_EXPERTS = 2
477
478 def _make_experts(self, has_bias: bool) -> torch.nn.Module:
479 experts = torch.nn.Module()
480 experts.layer_name = "model.layers.0.mlp.experts"
481 experts.moe_config = make_dummy_moe_config(num_experts=self.NUM_EXPERTS)
482 mapping = RoutedExperts.build_expert_params_mapping(
483 "gate_proj",
484 "down_proj",
485 "up_proj",
486 num_experts=self.NUM_EXPERTS,
487 routed_experts_prefix="",
488 include_fused=True,
489 )
490 experts.get_expert_mapping = lambda **_: mapping
491
492 def weight_loader(**_):
493 return True
494
495 names = ["w13_weight", "w2_weight"]
496 if has_bias:
497 names += ["w13_bias", "w2_bias"]
498 for name in names:
499 param = torch.nn.Parameter(torch.zeros(1), requires_grad=False)
500 param.weight_loader = weight_loader
501 setattr(experts, name, param)
502 return experts
503
504 def _checkpoint_weights(self) -> list[tuple[str, torch.Tensor]]:
505 return [
506 (f"{expert_id}.{proj}.{suffix}", torch.zeros(1, 1))
507 for expert_id in range(self.NUM_EXPERTS)
508 for proj in ("gate_proj", "up_proj", "down_proj")
509 for suffix in ("weight", "bias")
510 ]
511
512 def test_bias_free_experts_ignore_checkpoint_biases(self):
513 experts = self._make_experts(has_bias=False)
514 loaded = list(RoutedExperts.load_weights(experts, self._checkpoint_weights()))
515 assert set(loaded) == {"w13_weight", "w2_weight"}
516
517 def test_experts_with_bias_params_load_checkpoint_biases(self):
518 experts = self._make_experts(has_bias=True)
519 loaded = list(RoutedExperts.load_weights(experts, self._checkpoint_weights()))
520 assert set(loaded) == {"w13_weight", "w2_weight", "w13_bias", "w2_bias"}
521
522 def test_missing_non_bias_param_names_the_weight(self):
523 experts = self._make_experts(has_bias=False)
524 weights = [("0.down_proj.new_scale", torch.zeros(1, 1))]
525 with pytest.raises(AttributeError, match="w2_new_scale"):
526 list(RoutedExperts.load_weights(experts, weights))
527
528 @pytest.mark.parametrize(
529 "expert_name,param_name",
530 [
531 # Pre-fused checkpoints name biases `<proj>_bias` (gpt-oss) or
532 # `<proj>.bias` (quark), neither of which rewrites to a real param.
533 # Skipping them would silently drop a bias the layer does have, so
534 # they must raise; models rename them via WeightsMapper instead.
535 ("down_proj_bias", "w2_weight_bias"),
536 ("gate_up_proj_bias", "w13_weight_bias"),
537 ("down_proj.bias", "w2_weight.bias"),
538 ],
539 )
540 def test_fused_bias_names_are_not_skipped(self, expert_name, param_name):
541 experts = self._make_experts(has_bias=True)
542 weights = [(expert_name, torch.zeros(self.NUM_EXPERTS, 1))]
543 with pytest.raises(AttributeError, match=param_name.replace(".", r"\.")):
544 list(RoutedExperts.load_weights(experts, weights))
545
546
547 class TestLoadWeightsExpertMapping:
548 @staticmethod
549 def _mapping(projs=("gate_proj", "down_proj", "up_proj"), **kwargs):
550 return RoutedExperts.build_expert_params_mapping(
551 *projs,
552 num_experts=16,
553 routed_experts_prefix="",
554 include_fused=True,
555 **kwargs,
556 )
557
558 @staticmethod
559 def _load(
560 mapping,
561 weights,
562 suffix="weight",
563 *,
564 transposed=False,
565 quant_method="tensor",
566 layer_name="model.layers.0.mlp.experts",
567 ):
568 experts = torch.nn.Module()
569 experts.layer_name = layer_name
570 experts.get_expert_mapping = lambda **_: mapping
571 experts.is_fused_checkpoint_transposed = transposed
572 experts._orient_fused_weight = RoutedExperts._orient_fused_weight
573 calls = []
574
575 def weight_loader(param, loaded_weight, shard_id, expert_id, **_):
576 calls.append((param.test_name, shard_id, expert_id, loaded_weight.clone()))
577 return True
578
579 for prefix in ("w13", "w2"):
580 param_name = f"{prefix}_{suffix}"
581 param = torch.nn.Parameter(torch.empty(0), requires_grad=False)
582 param.test_name = param_name
583 param.quant_method = quant_method
584 param.weight_loader = weight_loader
585 setattr(experts, param_name, param)
586 loaded = list(RoutedExperts.load_weights(experts, iter(weights)))
587 assert loaded == [call[0] for call in calls]
588 return calls
589
590 @pytest.mark.parametrize(
591 "projs,lora_prefix",
592 [
593 (("gate_proj", "down_proj", "up_proj"), ""),
594 (("w1", "w2", "w3"), ""),
595 (("up_proj", "down_proj", "up_proj"), ""),
596 (("up_proj", "down_proj", ""), ""),
597 (("gate_proj", "down_proj", "up_proj"), "base_layer."),
598 ],
599 )
600 @pytest.mark.parametrize(
601 "suffix,shape,dtype",
602 [
603 ("weight", (2, 3), torch.bfloat16),
604 ("input_scale", (), torch.float32),
605 ("g_idx", (2,), torch.int32),
606 ],
607 )
608 def test_per_expert_weights_keep_all_physical_destinations(
609 self, projs, lora_prefix, suffix, shape, dtype
610 ):
611 mapping = self._mapping(
612 projs, num_redundant_experts=4, lora_base_layer_prefix=lora_prefix
613 )
614 for logical_id, physical_ids in [(1, (1, 17)), (10, (10,))]:
615 for proj in dict.fromkeys(projs):
616 if not proj:
617 continue
618 weight = torch.full(shape, logical_id + 1, dtype=dtype)
619 name = f"{logical_id}.{proj}.{lora_prefix}{suffix}"
620 calls = self._load(mapping, [(name, weight)], suffix)
621 shards = [s for p, s in zip(projs, ("w1", "w2", "w3")) if p == proj]
622 expected = [
623 (f"{'w2' if shard == 'w2' else 'w13'}_{suffix}", shard, physical)
624 for physical in physical_ids
625 for shard in shards
626 ]
627 assert [call[:3] for call in calls] == expected
628 for call in calls:
629 torch.testing.assert_close(call[3], weight, rtol=0, atol=0)
630
631 @pytest.mark.parametrize(
632 "projs,fused_name,lora_prefix",
633 [
634 (("gate_proj", "down_proj", "up_proj"), "gate_up_proj", ""),
635 (("w1", "w2", "w3"), "w13", "base_layer."),
636 ],
637 )
638 def test_per_expert_fused_gate_up_keeps_both_halves(
639 self, projs, fused_name, lora_prefix
640 ):
641 mapping = self._mapping(
642 projs, num_redundant_experts=4, lora_base_layer_prefix=lora_prefix
643 )
644 weight = torch.arange(12).reshape(4, 3)
645 calls = self._load(mapping, [(f"1.{fused_name}.{lora_prefix}weight", weight)])
646 assert [call[:3] for call in calls] == [
647 ("w13_weight", shard, expert)
648 for expert in (1, 17)
649 for shard in ("w1", "w3")
650 ]
651 for call, expected in zip(calls, weight.chunk(2) * 2):
652 torch.testing.assert_close(call[3], expected, rtol=0, atol=0)
653
654 @pytest.mark.parametrize(
655 "suffix,quant_method,transposed",
656 [
657 ("weight", "tensor", False),
658 ("weight", "tensor", True),
659 ("weight_scale", "channel", True),
660 ("weight_scale", "block", True),
661 ],
662 )
663 def test_mixed_weights_reset_mapping_and_keep_fused_layout(
664 self, transposed, suffix, quant_method
665 ):
666 weight = torch.arange(192).reshape(16, 4, 3)
667 checkpoint = (
668 weight.transpose(-1, -2)
669 if (transposed and (suffix == "weight" or quant_method == "block"))
670 else weight
671 )
672 calls = self._load(
673 self._mapping(),
674 [
675 (f"1.gate_proj.{suffix}", weight[1, :2]),
676 ("gate_up_proj" + suffix[6:], checkpoint),
677 (f"10.down_proj.{suffix}", weight[10, :2]),
678 ],
679 suffix,
680 transposed=transposed,
681 quant_method=quant_method,
682 )
683 fused_calls = [
684 (f"w13_{suffix}", shard, expert)
685 for shard in ("w1", "w3")
686 for expert in range(16)
687 ]
688 assert [call[:3] for call in calls] == [
689 (f"w13_{suffix}", "w1", 1),
690 *fused_calls,
691 (f"w2_{suffix}", "w2", 10),
692 ]
693 expected = [weight[1, :2]]
694 expected += [expert for half in weight.chunk(2, dim=1) for expert in half]
695 expected += [weight[10, :2]]
696 for call, tensor in zip(calls, expected):
697 torch.testing.assert_close(call[3], tensor, rtol=0, atol=0)
698
699 def test_fused_weights_stop_after_first_matching_run(self):
700 mapping = self._mapping()
701 calls = self._load(
702 [mapping[0], mapping[2], mapping[1]],
703 [("gate_up_proj", torch.ones(2, 4, 3))],
704 )
705 assert [call[:3] for call in calls] == [
706 ("w13_weight", "w1", 0),
707 ("w13_weight", "w1", 1),
708 ]
709
710 def test_repeated_experts_prefix_does_not_hide_later_matches(self):
711 calls = self._load(
712 self._mapping(),
713 [("1.gate_proj.weight", torch.ones(2, 3))],
714 layer_name="model.experts.0.child.experts",
715 )
716 assert [call[:3] for call in calls] == [("w13_weight", "w1", 1)]
717
718 @pytest.mark.parametrize(
719 "name",
720 [
721 "01.gate_proj.weight",
722 "16.gate_proj.weight",
723 "gate.weight",
724 "non_expert.weight",
725 ],
726 )
727 def test_unmatched_names_do_not_load_experts(self, name):
728 assert self._load(self._mapping(), [(name, torch.ones(2, 3))]) == []
729
730 @pytest.mark.parametrize(
731 "weight_name", ["1.gate_proj.", "experts.", "experts.1", ""]
732 )
733 def test_unusual_matching_entries_are_not_silently_skipped(self, weight_name):
734 mapping = self._mapping() + [("missing", weight_name, 1, "w3")]
735 with pytest.raises(AttributeError, match="has no parameter"):
736 self._load(mapping, [("1.gate_proj.weight", torch.ones(2, 3))])
737
738
739 class TestPerTensorScaleCoercion:
740 """Regression test for shape-(1,) per-tensor scales (issue #43297).
741
742 llm-compressor NVFP4 emits per-tensor weight and input scales as
743 shape-(1,) tensors. `_to_scalar` collapses them to a 0-D scalar so the
744 scalar-slot assignments in the weight loader neither broadcast nor raise.
745 """
746
747 def test_collapses_to_scalar(self):
748 # shape-(1,) and 0-D both reduce to a 0-D scalar.
749 for loaded_weight in (torch.tensor([0.5]), torch.tensor(0.5)):
750 scalar = RoutedExperts._to_scalar(loaded_weight)
751 assert scalar.shape == ()
752 assert scalar.item() == pytest.approx(0.5)
753
754 def test_rejects_non_scalar(self):
755 # numel > 1 must fail loudly instead of silently picking an element.
756 with pytest.raises(RuntimeError):
757 RoutedExperts._to_scalar(torch.tensor([0.1, 0.2]))