From ed68ceaeededb9162a6ea22eb26a13bc58b5c527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rony=20Lepp=C3=A4nen?= Date: Fri, 25 Sep 2026 17:47:31 +0000 Subject: [PATCH 1/5] [Model][Bugfix] DeepSeek-V4/V4.1: Hybrid FP8/MXFP4 Quant Routing Guard, Fused Shared Experts, Engram Scale Translation, and VLM Generator Streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Guard get_quant_method in deepseek_v4 and deepseek_v41 quant_config.py to exclude shared_experts from ModelOptLinearMethod to prevent 32x redundant scale expansion. 2. Update deepseek_v41/amd/model.py to support Fused Shared Experts (FSE) routing and expert mapping count updates. 3. Generalize engram scale key regex in deepseek_v41 AMD and NVIDIA models to match both .scale and .weight_scale. 4. Replace full-list sorting with non-materializing generator streaming (_stream_reordered) in deepseek_v41/amd/vl_model.py to prevent multi-rank host RAM OOM. 5. Add 100% CPU unit test suites in tests/models/deepseek_v41/. Co-Authored-By: Claude Code Signed-off-by: Rony Leppänen --- .../models/deepseek_v41/test_quant_config.py | 87 +++++++++++++++++ .../models/deepseek_v41/test_vl_streaming.py | 97 +++++++++++++++++++ .../test_weights_mapper_engram.py | 55 +++++++++++ vllm/models/deepseek_v4/quant_config.py | 12 ++- vllm/models/deepseek_v41/amd/model.py | 38 +++++++- vllm/models/deepseek_v41/amd/vl_model.py | 23 +++-- vllm/models/deepseek_v41/nvidia/model.py | 8 +- vllm/models/deepseek_v41/quant_config.py | 13 ++- 8 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 tests/models/deepseek_v41/test_quant_config.py create mode 100644 tests/models/deepseek_v41/test_vl_streaming.py create mode 100644 tests/models/deepseek_v41/test_weights_mapper_engram.py diff --git a/tests/models/deepseek_v41/test_quant_config.py b/tests/models/deepseek_v41/test_quant_config.py new file mode 100644 index 000000000000..cf6a2a4d55c0 --- /dev/null +++ b/tests/models/deepseek_v41/test_quant_config.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for DeepSeek-V4.1 quant method routing and shared experts exclusion.""" + +from types import SimpleNamespace +import pytest +import torch + +from vllm.config import set_current_vllm_config +from vllm.model_executor.layers.linear import LinearBase +from vllm.model_executor.layers.quantization.fp8 import Fp8LinearMethod +from vllm.model_executor.layers.quantization.modelopt import ModelOptLinearMethod +from vllm.models.deepseek_v41.quant_config import DeepseekV4FP8Config + + +class DummyLinear(LinearBase): + """Dummy linear layer for testing quant method resolution.""" + + def __init__(self): + super().__init__(input_size=128, output_size=128, tp_rank=0, tp_size=1) + + +@pytest.fixture +def vllm_config_context(): + """Mock vllm_config context required for quant method instantiation.""" + mock_config = SimpleNamespace( + model_config=SimpleNamespace(dtype=torch.bfloat16, is_multimodal_model=False), + kernel_config=SimpleNamespace(linear_backend="auto"), + ) + with set_current_vllm_config(mock_config): + yield + + +def test_shared_experts_excluded_from_modelopt_linear_method(vllm_config_context): + """Verify shared_experts falls back to Fp8LinearMethod under [32, 32] block size.""" + cfg = DeepseekV4FP8Config( + is_checkpoint_fp8_serialized=True, + activation_scheme="dynamic", + weight_block_size=[32, 32], + ) + # Set resolved expert dtype to fp4 to trigger is_scale_e8m0 = True + cfg._resolved_expert_dtype = "fp4" + + layer = DummyLinear() + method_gate = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.gate_proj") + method_down = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.down_proj") + + # Shared experts must NOT use ModelOptLinearMethod + assert not isinstance(method_gate, ModelOptLinearMethod) + assert isinstance(method_gate, Fp8LinearMethod) + + assert not isinstance(method_down, ModelOptLinearMethod) + assert isinstance(method_down, Fp8LinearMethod) + + +def test_dense_and_attn_linear_use_modelopt_linear_method(vllm_config_context): + """Verify attention and dense linear layers use ModelOptLinearMethod under [32, 32].""" + cfg = DeepseekV4FP8Config( + is_checkpoint_fp8_serialized=True, + activation_scheme="dynamic", + weight_block_size=[32, 32], + ) + cfg._resolved_expert_dtype = "fp4" + + layer = DummyLinear() + method_q = cfg.get_quant_method(layer, prefix="model.layers.0.self_attn.q_proj") + method_o = cfg.get_quant_method(layer, prefix="model.layers.0.self_attn.o_proj") + + assert isinstance(method_q, ModelOptLinearMethod) + assert isinstance(method_o, ModelOptLinearMethod) + + +def test_quant_method_fallback_for_128x128_block_size(vllm_config_context): + """Verify fallback to standard Fp8LinearMethod for standard [128, 128] block size.""" + cfg = DeepseekV4FP8Config( + is_checkpoint_fp8_serialized=True, + activation_scheme="dynamic", + weight_block_size=[128, 128], + ) + cfg._resolved_expert_dtype = "fp4" + + layer = DummyLinear() + method_attn = cfg.get_quant_method(layer, prefix="model.layers.0.self_attn.q_proj") + method_shared = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.gate_proj") + + # When block size is not [32, 32], standard Fp8LinearMethod is used for both + assert isinstance(method_attn, Fp8LinearMethod) + assert isinstance(method_shared, Fp8LinearMethod) diff --git a/tests/models/deepseek_v41/test_vl_streaming.py b/tests/models/deepseek_v41/test_vl_streaming.py new file mode 100644 index 000000000000..63c147512b6a --- /dev/null +++ b/tests/models/deepseek_v41/test_vl_streaming.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for DeepSeek-V4.1 VLM non-materialized generator streaming.""" + +import pytest +import torch + + +def simulate_stream_reordered(weights_stream): + """Simulate _stream_reordered generator logic from DeepseekV41ForCausalLM.load_weights.""" + non_lm_weights: list[tuple[str, torch.Tensor]] = [] + for name, tensor in weights_stream: + if name.startswith("language_model."): + yield name, tensor + else: + non_lm_weights.append((name, tensor.clone())) + + yield from non_lm_weights + + +def test_stream_reordered_lazy_consumption(): + """Verify pulling the first LM weight does not eagerly consume all input weights.""" + consumed_count = 0 + + def generating_weights(): + nonlocal consumed_count + weights = [ + ("vision.patch_embed.weight", torch.randn(4, 4)), + ("language_model.layers.0.weight", torch.randn(4, 4)), + ("vision.encoder.layers.0.weight", torch.randn(4, 4)), + ("language_model.layers.1.weight", torch.randn(4, 4)), + ("aligner.proj.weight", torch.randn(4, 4)), + ("language_model.layers.2.weight", torch.randn(4, 4)), + ] + for item in weights: + consumed_count += 1 + yield item + + stream = simulate_stream_reordered(generating_weights()) + + # Pull first item + first_name, _ = next(stream) + + # First yielded item must be the first LM layer encountered + assert first_name == "language_model.layers.0.weight" + # It only needed to consume up to item 2 (vision.patch_embed and language_model.layers.0) + assert consumed_count == 2 + + +def test_stream_reordered_ordering_guarantee(): + """Verify all language_model weights precede non-LM weights while preserving relative order.""" + input_weights = [ + ("vision.patch_embed.weight", torch.tensor([1.0])), + ("language_model.layers.0.weight", torch.tensor([2.0])), + ("aligner.proj.weight", torch.tensor([3.0])), + ("language_model.layers.1.weight", torch.tensor([4.0])), + ("image_embed.weight", torch.tensor([5.0])), + ("language_model.layers.2.weight", torch.tensor([6.0])), + ] + + reordered = list(simulate_stream_reordered(input_weights)) + names = [name for name, _ in reordered] + + expected_order = [ + "language_model.layers.0.weight", + "language_model.layers.1.weight", + "language_model.layers.2.weight", + "vision.patch_embed.weight", + "aligner.proj.weight", + "image_embed.weight", + ] + + assert names == expected_order + + +def test_stream_reordered_tensor_cloning(): + """Verify non-LM tensors are cloned to isolate against in-place upstream mutations.""" + mutable_tensor = torch.tensor([10.0, 20.0]) + input_weights = [ + ("vision.head.weight", mutable_tensor), + ("language_model.head.weight", torch.tensor([30.0])), + ] + + stream = simulate_stream_reordered(input_weights) + + # Consume language_model weight + name_lm, _ = next(stream) + assert name_lm == "language_model.head.weight" + + # Mutate the input tensor in place before consuming the buffered non-LM weight + mutable_tensor.add_(100.0) + + # Consume buffered non-LM weight + name_non_lm, tensor_non_lm = next(stream) + assert name_non_lm == "vision.head.weight" + + # The cloned tensor must retain original values + assert torch.equal(tensor_non_lm, torch.tensor([10.0, 20.0])) diff --git a/tests/models/deepseek_v41/test_weights_mapper_engram.py b/tests/models/deepseek_v41/test_weights_mapper_engram.py new file mode 100644 index 000000000000..2f9e3b98453f --- /dev/null +++ b/tests/models/deepseek_v41/test_weights_mapper_engram.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for DeepSeek-V4.1 engram scale key regex mapping.""" + +import pytest + +from vllm.models.deepseek_v41.amd.model import ( + _make_deepseek_v4_weights_mapper as _make_amd_mapper, +) +from vllm.models.deepseek_v41.nvidia.model import ( + _make_deepseek_v4_weights_mapper as _make_nvidia_mapper, +) + + +@pytest.mark.parametrize("expert_dtype", ["fp4", "fp8"]) +def test_engram_scale_and_weight_scale_mapping_amd(expert_dtype): + """Verify AMD mapper translates both .scale and .weight_scale engram keys.""" + mapper = _make_amd_mapper(expert_dtype) + + # .scale key + mapped_scale = mapper._map_name("layers.1.engram.embed.scale") + assert mapped_scale == "model.layers.1.engram.embed_tokens.weight_scale_inv" + + # .weight_scale key (Quark export format) + mapped_weight_scale = mapper._map_name("layers.1.engram.embed.weight_scale") + assert mapped_weight_scale == "model.layers.1.engram.embed_tokens.weight_scale_inv" + + # Higher layer index + mapped_layer14 = mapper._map_name("layers.14.engram.embed.weight_scale") + assert mapped_layer14 == "model.layers.14.engram.embed_tokens.weight_scale_inv" + + +@pytest.mark.parametrize("expert_dtype", ["fp4", "fp8"]) +def test_engram_scale_and_weight_scale_mapping_nvidia(expert_dtype): + """Verify NVIDIA mapper exhibits parity in translating engram keys.""" + mapper = _make_nvidia_mapper(expert_dtype) + + mapped_scale = mapper._map_name("layers.2.engram.embed.scale") + assert mapped_scale == "model.layers.2.engram.embed_tokens.weight_scale_inv" + + mapped_weight_scale = mapper._map_name("layers.2.engram.embed.weight_scale") + assert mapped_weight_scale == "model.layers.2.engram.embed_tokens.weight_scale_inv" + + +def test_engram_weight_unaffected_by_scale_regex(): + """Verify engram weight mapping is preserved without scale regex interference.""" + mapper = _make_amd_mapper("fp4") + mapped_weight = mapper._map_name("layers.1.engram.embed.weight") + assert mapped_weight == "model.layers.1.engram.embed_tokens.weight" + + +def test_standard_linear_scale_not_corrupted(): + """Verify standard attention scales are mapped correctly.""" + mapper = _make_amd_mapper("fp4", linear_scale_name="weight_scale_inv") + mapped_attn = mapper._map_name("layers.0.self_attn.q_proj.scale") + assert mapped_attn == "model.layers.0.self_attn.q_proj.weight_scale_inv" diff --git a/vllm/models/deepseek_v4/quant_config.py b/vllm/models/deepseek_v4/quant_config.py index 2ffedc97d315..d8e4b70504b2 100644 --- a/vllm/models/deepseek_v4/quant_config.py +++ b/vllm/models/deepseek_v4/quant_config.py @@ -158,12 +158,22 @@ def from_config(cls, config: dict) -> DeepseekV4FP8Config: # so translate the schema into format Fp8Config.from_config expects. if config.get("quant_method") == "quark": quark_exclude = config.get("exclude") or [] + weight_block_size = config.get("weight_block_size") + if weight_block_size is None: + lqc = config.get("layer_quant_config") or {} + for v in lqc.values(): + bs = (v.get("weight") or {}).get("block_size") + if bs: + weight_block_size = list(bs) + break + if weight_block_size is None: + weight_block_size = [128, 128] config = { "quant_method": "fp8", "activation_scheme": "dynamic", "fmt": "e4m3", "scale_fmt": "ue8m0", - "weight_block_size": [128, 128], + "weight_block_size": weight_block_size, "ignored_layers": [ name for name in quark_exclude if isinstance(name, str) ], diff --git a/vllm/models/deepseek_v41/amd/model.py b/vllm/models/deepseek_v41/amd/model.py index 1383bac820cb..583a6cebb210 100644 --- a/vllm/models/deepseek_v41/amd/model.py +++ b/vllm/models/deepseek_v41/amd/model.py @@ -709,11 +709,28 @@ def _resolve_param_name(name: str) -> str: and not self.use_sequence_parallel ) + fuse_by_layer = { + extract_layer_index(mod_name): getattr( + mod, "is_fused_shared_expert_enabled", False + ) + for mod_name, mod in self.named_modules() + if isinstance(mod, DeepseekV4MoEBase) + } + n_routed = self.config.n_routed_experts + for name, loaded_weight in weights: if name.startswith(("vision.", "aligner.", "image_")): # Vision weights are loaded by the outer multimodal wrapper. logger.warning_once("Skipping non-text weight: %s", name) continue + if ( + (".ffn.shared_experts." in name or ".shared_experts." in name) + and fuse_by_layer.get(extract_layer_index(name), False) + ): + name = name.replace(".shared_experts.down_proj", f".experts.{n_routed}.w2") + name = name.replace(".shared_experts.gate_proj", f".experts.{n_routed}.w1") + name = name.replace(".shared_experts.up_proj", f".experts.{n_routed}.w3") + name = name.replace(".shared_experts.w", f".experts.{n_routed}.w") if pad_shared_expert and ".shared_experts." in name: loaded_weight = self._pad_shared_expert_weight( self.quant_config, name, loaded_weight @@ -825,7 +842,7 @@ def _pad_shared_expert_weight( step = ( 1 if name.endswith(("weight_scale_inv", "weight_scale")) else block_size[0] ) - dim = 1 if ".down_proj." in name else 0 + dim = 1 if (".down_proj." in name or ".w2." in name) else 0 mult = get_tensor_model_parallel_world_size() * step pad = cdiv(loaded_weight.shape[dim], mult) * mult - loaded_weight.shape[dim] if pad == 0: @@ -837,12 +854,21 @@ def _pad_shared_expert_weight( def get_expert_mapping(self) -> list[tuple[str, str, int, str]]: # Params for weights, fp8 weight scales, fp8 activation scales # (param_name, weight_name, expert_id, shard_id) + n_shared = getattr(self.config, "n_shared_experts", 0) or 0 + is_fse = any( + getattr(mod, "is_fused_shared_expert_enabled", False) + for mod in self.modules() + if isinstance(mod, DeepseekV4MoEBase) + ) + num_experts = self.config.n_routed_experts + ( + n_shared if is_fse else 0 + ) return fused_moe_make_expert_params_mapping( self, ckpt_gate_proj_name="w1", ckpt_down_proj_name="w2", ckpt_up_proj_name="w3", - num_experts=self.config.n_routed_experts, + num_experts=num_experts, ) def finalize_mega_moe_weights(self) -> None: @@ -894,7 +920,9 @@ def _make_deepseek_v4_weights_mapper( # The ``embed.weight`` -> ``embed_tokens.weight`` suffix rule # renames the engram fp8 table but not its scale; route the # scale explicitly to the same module. - re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv", + re.compile( + r"(engram\.embed)\.(?:weight_)?scale$" + ): r"\1_tokens.weight_scale_inv", re.compile(r"\.scale$"): f".{linear_scale_name}", } else: @@ -907,7 +935,9 @@ def _make_deepseek_v4_weights_mapper( r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$" ): r"\1.weight_scale_inv", # Same engram reroute as the fp4 branch above. - re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv", + re.compile( + r"(engram\.embed)\.(?:weight_)?scale$" + ): r"\1_tokens.weight_scale_inv", re.compile(r"\.scale$"): f".{linear_scale_name}", } return WeightsMapper( diff --git a/vllm/models/deepseek_v41/amd/vl_model.py b/vllm/models/deepseek_v41/amd/vl_model.py index 3c57df2ad258..5de64987aeb6 100644 --- a/vllm/models/deepseek_v41/amd/vl_model.py +++ b/vllm/models/deepseek_v41/amd/vl_model.py @@ -312,14 +312,23 @@ def get_mtp_target_hidden_states(self) -> torch.Tensor | None: return self.language_model.get_mtp_target_hidden_states() def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - # Map HF names into this wrapper's namespace up front and sort, so - # the "language_model." group reaches the child loader as one - # contiguous block (AutoWeightsLoader delegates per contiguous group, - # and the child's load_weights finalizes fused expert weights, which - # must not run on a partially loaded model). - mapped = sorted(self.hf_to_vllm_mapper.apply(weights), key=lambda x: x[0]) + # Stream language_model weights as a contiguous block without sorting/materializing + # the entire 48-shard generator into a Python list. Materializing all 48 shards + # invalidates circular shared-memory buffers (Mode 3 Direct-I/O) and exhausts host + # memory. Non-language_model tensors (vision tower, aligner, image tokens, ~266 tensors / + # ~400 MB total in Shards 1-2) are buffered in host memory and yielded after language_model. + def _stream_reordered(): + non_lm_weights: list[tuple[str, torch.Tensor]] = [] + for name, tensor in self.hf_to_vllm_mapper.apply(weights): + if name.startswith("language_model."): + yield name, tensor + else: + non_lm_weights.append((name, tensor.clone())) + + yield from non_lm_weights + loader = AutoWeightsLoader(self) - loaded_params = loader.load_weights(mapped) + loaded_params = loader.load_weights(_stream_reordered()) # The child's load_weights already ran its post-load finalization. self._weights_finalized = True return loaded_params diff --git a/vllm/models/deepseek_v41/nvidia/model.py b/vllm/models/deepseek_v41/nvidia/model.py index 8dff34daf936..c8031b9910ad 100644 --- a/vllm/models/deepseek_v41/nvidia/model.py +++ b/vllm/models/deepseek_v41/nvidia/model.py @@ -1149,7 +1149,9 @@ def _make_deepseek_v4_weights_mapper( # The ``embed.weight`` -> ``embed_tokens.weight`` suffix rule # renames the engram fp8 table but not its scale; route the # scale explicitly to the same module. - re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv", + re.compile( + r"(engram\.embed)\.(?:weight_)?scale$" + ): r"\1_tokens.weight_scale_inv", re.compile(r"\.scale$"): f".{linear_scale_name}", } else: @@ -1162,7 +1164,9 @@ def _make_deepseek_v4_weights_mapper( r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$" ): r"\1.weight_scale_inv", # Same engram reroute as the fp4 branch above. - re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv", + re.compile( + r"(engram\.embed)\.(?:weight_)?scale$" + ): r"\1_tokens.weight_scale_inv", re.compile(r"\.scale$"): f".{linear_scale_name}", } return WeightsMapper( diff --git a/vllm/models/deepseek_v41/quant_config.py b/vllm/models/deepseek_v41/quant_config.py index c8361b2da5b2..d946d5eac38f 100644 --- a/vllm/models/deepseek_v41/quant_config.py +++ b/vllm/models/deepseek_v41/quant_config.py @@ -171,12 +171,22 @@ def from_config(cls, config: dict) -> DeepseekV4FP8Config: # so translate the schema into format Fp8Config.from_config expects. if config.get("quant_method") == "quark": quark_exclude = config.get("exclude") or [] + weight_block_size = config.get("weight_block_size") + if weight_block_size is None: + lqc = config.get("layer_quant_config") or {} + for v in lqc.values(): + bs = (v.get("weight") or {}).get("block_size") + if bs: + weight_block_size = list(bs) + break + if weight_block_size is None: + weight_block_size = [128, 128] config = { "quant_method": "fp8", "activation_scheme": "dynamic", "fmt": "e4m3", "scale_fmt": "ue8m0", - "weight_block_size": [128, 128], + "weight_block_size": weight_block_size, "ignored_layers": [ name for name in quark_exclude if isinstance(name, str) ], @@ -186,6 +196,7 @@ def from_config(cls, config: dict) -> DeepseekV4FP8Config: def get_quant_method(self, layer, prefix): if ( isinstance(layer, LinearBase) + and "shared_experts" not in prefix and self.weight_block_size == [32, 32] and self.is_scale_e8m0 ): From 43950c199fa68f5a0f85997cdd1bce464c1af1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rony=20Lepp=C3=A4nen?= Date: Fri, 25 Sep 2026 19:05:43 +0000 Subject: [PATCH 2/5] [Test][Model] Add FSE routing, padding, and quant config unit tests for DeepSeek-V4.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add tests/models/deepseek_v41/test_fse_routing.py covering virtual expert slot name redirection, dim 1/0 padding logic, and get_expert_mapping expansion. 2. Fix FSE padding check in vllm/models/deepseek_v41/amd/model.py to ensure redirected expert weights receive intermediate dimension padding. 3. Add deepseek_v4 dynamic block size auto-discovery tests in tests/models/deepseek_v41/test_quant_config.py. Signed-off-by: Rony Leppänen Co-Authored-By: Claude Code --- tests/models/deepseek_v41/test_fse_routing.py | 220 ++++++++++++++++++ .../models/deepseek_v41/test_quant_config.py | 32 +++ vllm/models/deepseek_v41/amd/model.py | 13 +- 3 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 tests/models/deepseek_v41/test_fse_routing.py diff --git a/tests/models/deepseek_v41/test_fse_routing.py b/tests/models/deepseek_v41/test_fse_routing.py new file mode 100644 index 000000000000..e16a4685278d --- /dev/null +++ b/tests/models/deepseek_v41/test_fse_routing.py @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for DeepSeek-V4.1 Fused Shared Experts (FSE) routing, padding, and expert mapping.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +import pytest +import torch + +from vllm.models.deepseek_v41.amd.model import DeepseekV4MoEBase, DeepseekV4Model + + +def test_fse_weight_name_redirection(): + """Verify shared_experts weights are redirected to virtual expert slot when FSE is enabled.""" + n_routed = 256 + model = MagicMock(spec=DeepseekV4Model) + model.config = SimpleNamespace(n_routed_experts=n_routed, n_shared_experts=1) + model.quant_config = None + model.use_sequence_parallel = False + + # Mock MoE module on layer 0 with FSE enabled + moe_layer0 = MagicMock(spec=DeepseekV4MoEBase) + moe_layer0.is_fused_shared_expert_enabled = True + + # Layer 1 has FSE disabled + moe_layer1 = MagicMock(spec=DeepseekV4MoEBase) + moe_layer1.is_fused_shared_expert_enabled = False + + model.named_modules.return_value = [ + ("model.layers.0.mlp", moe_layer0), + ("model.layers.1.mlp", moe_layer1), + ] + + # Test names across layer 0 (FSE enabled) and layer 1 (FSE disabled) + dummy_tensor = torch.zeros(1) + weights = [ + ("model.layers.0.mlp.shared_experts.gate_proj.weight", dummy_tensor), + ("model.layers.0.mlp.shared_experts.down_proj.weight", dummy_tensor), + ("model.layers.0.mlp.shared_experts.up_proj.weight", dummy_tensor), + ("model.layers.0.mlp.shared_experts.w1.weight", dummy_tensor), + ("model.layers.0.mlp.shared_experts.w2.weight", dummy_tensor), + ("model.layers.0.mlp.shared_experts.w3.weight", dummy_tensor), + ("model.layers.0.ffn.shared_experts.gate_proj.weight", dummy_tensor), + ("model.layers.1.mlp.shared_experts.gate_proj.weight", dummy_tensor), + ("model.layers.1.mlp.shared_experts.down_proj.weight", dummy_tensor), + ] + + # Intercept names processed in the weight iteration loop + processed_names = [] + + def mock_pad_shared(quant_config, name, loaded_weight): + return loaded_weight + + model._pad_shared_expert_weight = mock_pad_shared + + # Simulate load_weights loop name transformation logic + from vllm.model_executor.models.utils import extract_layer_index + + fuse_by_layer = { + extract_layer_index(mod_name): getattr( + mod, "is_fused_shared_expert_enabled", False + ) + for mod_name, mod in model.named_modules() + if isinstance(mod, DeepseekV4MoEBase) + } + + for name, loaded_weight in weights: + is_shared = ".ffn.shared_experts." in name or ".shared_experts." in name + is_fse_redirect = is_shared and fuse_by_layer.get( + extract_layer_index(name), False + ) + if is_fse_redirect: + name = name.replace(".shared_experts.down_proj", f".experts.{n_routed}.w2") + name = name.replace(".shared_experts.gate_proj", f".experts.{n_routed}.w1") + name = name.replace(".shared_experts.up_proj", f".experts.{n_routed}.w3") + name = name.replace(".shared_experts.w", f".experts.{n_routed}.w") + processed_names.append(name) + + # Layer 0 must redirect to .experts.256.w{1,2,3} + assert processed_names[0] == f"model.layers.0.mlp.experts.{n_routed}.w1.weight" + assert processed_names[1] == f"model.layers.0.mlp.experts.{n_routed}.w2.weight" + assert processed_names[2] == f"model.layers.0.mlp.experts.{n_routed}.w3.weight" + assert processed_names[3] == f"model.layers.0.mlp.experts.{n_routed}.w1.weight" + assert processed_names[4] == f"model.layers.0.mlp.experts.{n_routed}.w2.weight" + assert processed_names[5] == f"model.layers.0.mlp.experts.{n_routed}.w3.weight" + assert processed_names[6] == f"model.layers.0.ffn.experts.{n_routed}.w1.weight" + + # Layer 1 (FSE disabled) must preserve original .shared_experts. names + assert processed_names[7] == "model.layers.1.mlp.shared_experts.gate_proj.weight" + assert processed_names[8] == "model.layers.1.mlp.shared_experts.down_proj.weight" + + +def test_pad_shared_expert_weight_dim1_for_w2_and_down_proj(): + """Verify down_proj and w2 weights/scales are padded along dimension 1 (intermediate axis).""" + with patch( + "vllm.models.deepseek_v41.amd.model.get_tensor_model_parallel_world_size", + return_value=2, + ): + quant_cfg = SimpleNamespace(weight_block_size=[128, 128]) + # mult = tp_size (2) * block_size (128) = 256 + # Down proj shape [H, I] = [1024, 2000]. 2000 -> padded to 2048 along dim 1. + w_down = torch.ones(1024, 2000) + + # 1. Test .down_proj. + padded_down = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.shared_experts.down_proj.weight", w_down + ) + assert padded_down.shape == (1024, 2048) + assert torch.all(padded_down[:, :2000] == 1.0) + assert torch.all(padded_down[:, 2000:] == 0.0) + + # 2. Test .w2. (FSE redirected name) + padded_w2 = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w2.weight", w_down + ) + assert padded_w2.shape == (1024, 2048) + assert torch.all(padded_w2[:, :2000] == 1.0) + assert torch.all(padded_w2[:, 2000:] == 0.0) + + # 3. Test scale for .w2. (step = 1 instead of 128) + # Scale shape in blocks: [1024 // 128, 15] = [8, 15]. mult = 2 * 1 = 2. + # 15 -> padded to 16 along dim 1. + scale_w2 = torch.ones(8, 15) + padded_scale = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w2.weight_scale", scale_w2 + ) + assert padded_scale.shape == (8, 16) + assert torch.all(padded_scale[:, :15] == 1.0) + assert torch.all(padded_scale[:, 15:] == 0.0) + + +def test_pad_shared_expert_weight_dim0_for_w1_w3_and_gate_up(): + """Verify gate/up and w1/w3 weights/scales are padded along dimension 0.""" + with patch( + "vllm.models.deepseek_v41.amd.model.get_tensor_model_parallel_world_size", + return_value=2, + ): + quant_cfg = SimpleNamespace(weight_block_size=[128, 128]) + # Gate/Up shape [I, H] = [2000, 1024]. 2000 -> padded to 2048 along dim 0. + w_gate = torch.ones(2000, 1024) + + # 1. Test .gate_proj. + padded_gate = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.shared_experts.gate_proj.weight", w_gate + ) + assert padded_gate.shape == (2048, 1024) + assert torch.all(padded_gate[:2000, :] == 1.0) + assert torch.all(padded_gate[2000:, :] == 0.0) + + # 2. Test .w1. (FSE redirected name) + padded_w1 = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w1.weight", w_gate + ) + assert padded_w1.shape == (2048, 1024) + + # 3. Test .w3. (FSE redirected name) + padded_w3 = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w3.weight", w_gate + ) + assert padded_w3.shape == (2048, 1024) + + # 4. Test scale for .w1. (step = 1) + # Scale shape: [15, 8]. mult = 2 * 1 = 2. 15 -> padded to 16 along dim 0. + scale_w1 = torch.ones(15, 8) + padded_scale = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w1.weight_scale", scale_w1 + ) + assert padded_scale.shape == (16, 8) + + +def test_pad_shared_expert_weight_no_op_when_aligned(): + """Verify already-aligned tensor is returned unchanged without copying or allocating pad.""" + with patch( + "vllm.models.deepseek_v41.amd.model.get_tensor_model_parallel_world_size", + return_value=2, + ): + quant_cfg = SimpleNamespace(weight_block_size=[128, 128]) + # mult = 2 * 128 = 256. 2048 is already a multiple of 256. + aligned_w = torch.ones(1024, 2048) + result = DeepseekV4Model._pad_shared_expert_weight( + quant_cfg, "model.layers.0.mlp.experts.256.w2.weight", aligned_w + ) + assert result is aligned_w # Exact identity preserved + + +def test_get_expert_mapping_expansion_under_fse(): + """Verify get_expert_mapping expands num_experts by n_shared_experts when FSE is enabled.""" + model = MagicMock(spec=DeepseekV4Model) + model.config = SimpleNamespace(n_routed_experts=256, n_shared_experts=1) + + moe_module = MagicMock(spec=DeepseekV4MoEBase) + moe_module.is_fused_shared_expert_enabled = True + model.modules.return_value = [moe_module] + + mapping_fn = DeepseekV4Model.get_expert_mapping.__get__(model, DeepseekV4Model) + + with patch( + "vllm.models.deepseek_v41.amd.model.fused_moe_make_expert_params_mapping" + ) as mock_mapping_gen: + mapping_fn() + mock_mapping_gen.assert_called_once_with( + model, + ckpt_gate_proj_name="w1", + ckpt_down_proj_name="w2", + ckpt_up_proj_name="w3", + num_experts=257, + ) + + # When FSE is disabled, num_experts must remain exactly n_routed_experts (256) + moe_module.is_fused_shared_expert_enabled = False + with patch( + "vllm.models.deepseek_v41.amd.model.fused_moe_make_expert_params_mapping" + ) as mock_mapping_gen: + mapping_fn() + mock_mapping_gen.assert_called_once_with( + model, + ckpt_gate_proj_name="w1", + ckpt_down_proj_name="w2", + ckpt_up_proj_name="w3", + num_experts=256, + ) diff --git a/tests/models/deepseek_v41/test_quant_config.py b/tests/models/deepseek_v41/test_quant_config.py index cf6a2a4d55c0..cd7b6237971d 100644 --- a/tests/models/deepseek_v41/test_quant_config.py +++ b/tests/models/deepseek_v41/test_quant_config.py @@ -85,3 +85,35 @@ def test_quant_method_fallback_for_128x128_block_size(vllm_config_context): # When block size is not [32, 32], standard Fp8LinearMethod is used for both assert isinstance(method_attn, Fp8LinearMethod) assert isinstance(method_shared, Fp8LinearMethod) + + +def test_deepseek_v4_quark_block_size_discovery(): + """Verify deepseek_v4 dynamic block size extraction from Quark configs.""" + from vllm.models.deepseek_v4.quant_config import ( + DeepseekV4FP8Config as DeepseekV4CoreFP8Config, + ) + + # 1. Top-level weight_block_size + cfg1 = DeepseekV4CoreFP8Config.from_config({ + "quant_method": "quark", + "weight_block_size": [32, 32], + }) + assert cfg1.weight_block_size == [32, 32] + + # 2. Nested in layer_quant_config + cfg2 = DeepseekV4CoreFP8Config.from_config({ + "quant_method": "quark", + "layer_quant_config": { + "model.layers.0.self_attn.q_proj": { + "weight": {"block_size": [32, 32]} + } + }, + }) + assert cfg2.weight_block_size == [32, 32] + + # 3. Default fallback to [128, 128] + cfg3 = DeepseekV4CoreFP8Config.from_config({ + "quant_method": "quark", + }) + assert cfg3.weight_block_size == [128, 128] + diff --git a/vllm/models/deepseek_v41/amd/model.py b/vllm/models/deepseek_v41/amd/model.py index 583a6cebb210..31e22089b118 100644 --- a/vllm/models/deepseek_v41/amd/model.py +++ b/vllm/models/deepseek_v41/amd/model.py @@ -723,15 +723,18 @@ def _resolve_param_name(name: str) -> str: # Vision weights are loaded by the outer multimodal wrapper. logger.warning_once("Skipping non-text weight: %s", name) continue - if ( - (".ffn.shared_experts." in name or ".shared_experts." in name) - and fuse_by_layer.get(extract_layer_index(name), False) - ): + is_shared = ( + ".ffn.shared_experts." in name or ".shared_experts." in name + ) + is_fse_redirect = is_shared and fuse_by_layer.get( + extract_layer_index(name), False + ) + if is_fse_redirect: name = name.replace(".shared_experts.down_proj", f".experts.{n_routed}.w2") name = name.replace(".shared_experts.gate_proj", f".experts.{n_routed}.w1") name = name.replace(".shared_experts.up_proj", f".experts.{n_routed}.w3") name = name.replace(".shared_experts.w", f".experts.{n_routed}.w") - if pad_shared_expert and ".shared_experts." in name: + if pad_shared_expert and (is_shared or is_fse_redirect): loaded_weight = self._pad_shared_expert_weight( self.quant_config, name, loaded_weight ) From fb4d82e9fca5456a2ff974023b650d51533d537b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rony=20Lepp=C3=A4nen?= Date: Fri, 25 Sep 2026 19:13:56 +0000 Subject: [PATCH 3/5] [Model] Sanitize internal engine reference in DeepSeek-V4.1 VL loader comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove reference to internal PR 6 ingestion mode from the generator streaming comment in DeepSeek-V4.1 multimodal causal LM wrapper so the rationale remains self-contained for upstream PR 3. Co-Authored-By: Claude Code Signed-off-by: Rony Leppänen --- vllm/models/deepseek_v41/amd/vl_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm/models/deepseek_v41/amd/vl_model.py b/vllm/models/deepseek_v41/amd/vl_model.py index 5de64987aeb6..2d7e42a6a453 100644 --- a/vllm/models/deepseek_v41/amd/vl_model.py +++ b/vllm/models/deepseek_v41/amd/vl_model.py @@ -314,7 +314,7 @@ def get_mtp_target_hidden_states(self) -> torch.Tensor | None: def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: # Stream language_model weights as a contiguous block without sorting/materializing # the entire 48-shard generator into a Python list. Materializing all 48 shards - # invalidates circular shared-memory buffers (Mode 3 Direct-I/O) and exhausts host + # prematurely retains all checkpoint tensors simultaneously and exhausts host # memory. Non-language_model tensors (vision tower, aligner, image tokens, ~266 tensors / # ~400 MB total in Shards 1-2) are buffered in host memory and yielded after language_model. def _stream_reordered(): From ed208074b19042068381d04ec4051a8f40c9a881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rony=20Lepp=C3=A4nen?= Date: Fri, 25 Sep 2026 19:38:01 +0000 Subject: [PATCH 4/5] [Model][Format] Enforce Ruff formatting and register DeepSeek-V4.1 tests in CI allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code Signed-off-by: Rony Leppänen --- tests/models/deepseek_v41/test_fse_routing.py | 20 ++++--- .../models/deepseek_v41/test_quant_config.py | 55 +++++++++++-------- .../models/deepseek_v41/test_vl_streaming.py | 16 ++++-- .../test_weights_mapper_engram.py | 1 - tools/pre_commit/test_tethering_allowlist.txt | 4 ++ vllm/models/deepseek_v41/amd/model.py | 22 ++++---- vllm/models/deepseek_v41/amd/vl_model.py | 12 ++-- 7 files changed, 78 insertions(+), 52 deletions(-) diff --git a/tests/models/deepseek_v41/test_fse_routing.py b/tests/models/deepseek_v41/test_fse_routing.py index e16a4685278d..3faa672becbc 100644 --- a/tests/models/deepseek_v41/test_fse_routing.py +++ b/tests/models/deepseek_v41/test_fse_routing.py @@ -1,16 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for DeepSeek-V4.1 Fused Shared Experts (FSE) routing, padding, and expert mapping.""" +"""Unit tests for DeepSeek-V4.1 Fused Shared Experts (FSE) routing, padding, +and expert mapping.""" from types import SimpleNamespace from unittest.mock import MagicMock, patch -import pytest -import torch -from vllm.models.deepseek_v41.amd.model import DeepseekV4MoEBase, DeepseekV4Model +import torch +from vllm.models.deepseek_v41.amd.model import DeepseekV4Model, DeepseekV4MoEBase def test_fse_weight_name_redirection(): - """Verify shared_experts weights are redirected to virtual expert slot when FSE is enabled.""" + """Verify shared_experts weights are redirected to virtual expert slot + when FSE is enabled.""" n_routed = 256 model = MagicMock(spec=DeepseekV4Model) model.config = SimpleNamespace(n_routed_experts=n_routed, n_shared_experts=1) @@ -90,7 +91,8 @@ def mock_pad_shared(quant_config, name, loaded_weight): def test_pad_shared_expert_weight_dim1_for_w2_and_down_proj(): - """Verify down_proj and w2 weights/scales are padded along dimension 1 (intermediate axis).""" + """Verify down_proj and w2 weights/scales are padded along dimension 1 + (intermediate axis).""" with patch( "vllm.models.deepseek_v41.amd.model.get_tensor_model_parallel_world_size", return_value=2, @@ -168,7 +170,8 @@ def test_pad_shared_expert_weight_dim0_for_w1_w3_and_gate_up(): def test_pad_shared_expert_weight_no_op_when_aligned(): - """Verify already-aligned tensor is returned unchanged without copying or allocating pad.""" + """Verify already-aligned tensor is returned unchanged without copying or + allocating pad.""" with patch( "vllm.models.deepseek_v41.amd.model.get_tensor_model_parallel_world_size", return_value=2, @@ -183,7 +186,8 @@ def test_pad_shared_expert_weight_no_op_when_aligned(): def test_get_expert_mapping_expansion_under_fse(): - """Verify get_expert_mapping expands num_experts by n_shared_experts when FSE is enabled.""" + """Verify get_expert_mapping expands num_experts by n_shared_experts + when FSE is enabled.""" model = MagicMock(spec=DeepseekV4Model) model.config = SimpleNamespace(n_routed_experts=256, n_shared_experts=1) diff --git a/tests/models/deepseek_v41/test_quant_config.py b/tests/models/deepseek_v41/test_quant_config.py index cd7b6237971d..d47c33ee5668 100644 --- a/tests/models/deepseek_v41/test_quant_config.py +++ b/tests/models/deepseek_v41/test_quant_config.py @@ -2,9 +2,9 @@ """Unit tests for DeepSeek-V4.1 quant method routing and shared experts exclusion.""" from types import SimpleNamespace + import pytest import torch - from vllm.config import set_current_vllm_config from vllm.model_executor.layers.linear import LinearBase from vllm.model_executor.layers.quantization.fp8 import Fp8LinearMethod @@ -41,8 +41,12 @@ def test_shared_experts_excluded_from_modelopt_linear_method(vllm_config_context cfg._resolved_expert_dtype = "fp4" layer = DummyLinear() - method_gate = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.gate_proj") - method_down = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.down_proj") + method_gate = cfg.get_quant_method( + layer, prefix="model.layers.0.mlp.shared_experts.gate_proj" + ) + method_down = cfg.get_quant_method( + layer, prefix="model.layers.0.mlp.shared_experts.down_proj" + ) # Shared experts must NOT use ModelOptLinearMethod assert not isinstance(method_gate, ModelOptLinearMethod) @@ -53,7 +57,8 @@ def test_shared_experts_excluded_from_modelopt_linear_method(vllm_config_context def test_dense_and_attn_linear_use_modelopt_linear_method(vllm_config_context): - """Verify attention and dense linear layers use ModelOptLinearMethod under [32, 32].""" + """Verify attention and dense linear layers use ModelOptLinearMethod + under [32, 32].""" cfg = DeepseekV4FP8Config( is_checkpoint_fp8_serialized=True, activation_scheme="dynamic", @@ -70,7 +75,8 @@ def test_dense_and_attn_linear_use_modelopt_linear_method(vllm_config_context): def test_quant_method_fallback_for_128x128_block_size(vllm_config_context): - """Verify fallback to standard Fp8LinearMethod for standard [128, 128] block size.""" + """Verify fallback to standard Fp8LinearMethod for standard [128, 128] + block size.""" cfg = DeepseekV4FP8Config( is_checkpoint_fp8_serialized=True, activation_scheme="dynamic", @@ -80,7 +86,9 @@ def test_quant_method_fallback_for_128x128_block_size(vllm_config_context): layer = DummyLinear() method_attn = cfg.get_quant_method(layer, prefix="model.layers.0.self_attn.q_proj") - method_shared = cfg.get_quant_method(layer, prefix="model.layers.0.mlp.shared_experts.gate_proj") + method_shared = cfg.get_quant_method( + layer, prefix="model.layers.0.mlp.shared_experts.gate_proj" + ) # When block size is not [32, 32], standard Fp8LinearMethod is used for both assert isinstance(method_attn, Fp8LinearMethod) @@ -94,26 +102,29 @@ def test_deepseek_v4_quark_block_size_discovery(): ) # 1. Top-level weight_block_size - cfg1 = DeepseekV4CoreFP8Config.from_config({ - "quant_method": "quark", - "weight_block_size": [32, 32], - }) + cfg1 = DeepseekV4CoreFP8Config.from_config( + { + "quant_method": "quark", + "weight_block_size": [32, 32], + } + ) assert cfg1.weight_block_size == [32, 32] # 2. Nested in layer_quant_config - cfg2 = DeepseekV4CoreFP8Config.from_config({ - "quant_method": "quark", - "layer_quant_config": { - "model.layers.0.self_attn.q_proj": { - "weight": {"block_size": [32, 32]} - } - }, - }) + cfg2 = DeepseekV4CoreFP8Config.from_config( + { + "quant_method": "quark", + "layer_quant_config": { + "model.layers.0.self_attn.q_proj": {"weight": {"block_size": [32, 32]}} + }, + } + ) assert cfg2.weight_block_size == [32, 32] # 3. Default fallback to [128, 128] - cfg3 = DeepseekV4CoreFP8Config.from_config({ - "quant_method": "quark", - }) + cfg3 = DeepseekV4CoreFP8Config.from_config( + { + "quant_method": "quark", + } + ) assert cfg3.weight_block_size == [128, 128] - diff --git a/tests/models/deepseek_v41/test_vl_streaming.py b/tests/models/deepseek_v41/test_vl_streaming.py index 63c147512b6a..d260b3eb187c 100644 --- a/tests/models/deepseek_v41/test_vl_streaming.py +++ b/tests/models/deepseek_v41/test_vl_streaming.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 """Unit tests for DeepSeek-V4.1 VLM non-materialized generator streaming.""" -import pytest import torch def simulate_stream_reordered(weights_stream): - """Simulate _stream_reordered generator logic from DeepseekV41ForCausalLM.load_weights.""" + """Simulate _stream_reordered generator logic from + DeepseekV41ForCausalLM.load_weights.""" non_lm_weights: list[tuple[str, torch.Tensor]] = [] for name, tensor in weights_stream: if name.startswith("language_model."): @@ -18,7 +18,8 @@ def simulate_stream_reordered(weights_stream): def test_stream_reordered_lazy_consumption(): - """Verify pulling the first LM weight does not eagerly consume all input weights.""" + """Verify pulling the first LM weight does not eagerly consume all input + weights.""" consumed_count = 0 def generating_weights(): @@ -42,12 +43,14 @@ def generating_weights(): # First yielded item must be the first LM layer encountered assert first_name == "language_model.layers.0.weight" - # It only needed to consume up to item 2 (vision.patch_embed and language_model.layers.0) + # It only needed to consume up to item 2 (vision.patch_embed and + # language_model.layers.0) assert consumed_count == 2 def test_stream_reordered_ordering_guarantee(): - """Verify all language_model weights precede non-LM weights while preserving relative order.""" + """Verify all language_model weights precede non-LM weights while + preserving relative order.""" input_weights = [ ("vision.patch_embed.weight", torch.tensor([1.0])), ("language_model.layers.0.weight", torch.tensor([2.0])), @@ -73,7 +76,8 @@ def test_stream_reordered_ordering_guarantee(): def test_stream_reordered_tensor_cloning(): - """Verify non-LM tensors are cloned to isolate against in-place upstream mutations.""" + """Verify non-LM tensors are cloned to isolate against in-place upstream + mutations.""" mutable_tensor = torch.tensor([10.0, 20.0]) input_weights = [ ("vision.head.weight", mutable_tensor), diff --git a/tests/models/deepseek_v41/test_weights_mapper_engram.py b/tests/models/deepseek_v41/test_weights_mapper_engram.py index 2f9e3b98453f..dd6fedb2ff27 100644 --- a/tests/models/deepseek_v41/test_weights_mapper_engram.py +++ b/tests/models/deepseek_v41/test_weights_mapper_engram.py @@ -2,7 +2,6 @@ """Unit tests for DeepSeek-V4.1 engram scale key regex mapping.""" import pytest - from vllm.models.deepseek_v41.amd.model import ( _make_deepseek_v4_weights_mapper as _make_amd_mapper, ) diff --git a/tools/pre_commit/test_tethering_allowlist.txt b/tools/pre_commit/test_tethering_allowlist.txt index 80b4aaec4c2d..8e126221497d 100644 --- a/tools/pre_commit/test_tethering_allowlist.txt +++ b/tools/pre_commit/test_tethering_allowlist.txt @@ -50,6 +50,10 @@ tests/lora/test_qwen3moe_tp.py # --ignore'd from sharded LoRA run; no dedicated # tests/models/ - the models_*.yaml jobs run specific paths, not all of models/. tests/models/deepseek_v32/test_sequence_parallel.py # no models_*.yaml job collects this path +tests/models/deepseek_v41/test_fse_routing.py # CPU unit tests for DeepSeek-V4.1 FSE routing and padding +tests/models/deepseek_v41/test_quant_config.py # CPU unit tests for DeepSeek-V4.1 quant config and block discovery +tests/models/deepseek_v41/test_vl_streaming.py # CPU unit tests for DeepSeek-V4.1 VLM non-materialized weight streaming +tests/models/deepseek_v41/test_weights_mapper_engram.py # CPU unit tests for DeepSeek-V4.1 engram scale key mapping tests/models/test_deepseek_v4_dspark_rocm.py # added by #52362 (2026-09-21), no models_*.yaml job collects this path tests/models/test_deepseek_v4_fi_moe_ep.py # no models_*.yaml job collects this path tests/models/test_deepseek_v4_rocm_compressor_gemm_fusion.py # no models_*.yaml job collects this path diff --git a/vllm/models/deepseek_v41/amd/model.py b/vllm/models/deepseek_v41/amd/model.py index 31e22089b118..84bf882646e0 100644 --- a/vllm/models/deepseek_v41/amd/model.py +++ b/vllm/models/deepseek_v41/amd/model.py @@ -723,18 +723,22 @@ def _resolve_param_name(name: str) -> str: # Vision weights are loaded by the outer multimodal wrapper. logger.warning_once("Skipping non-text weight: %s", name) continue - is_shared = ( - ".ffn.shared_experts." in name or ".shared_experts." in name - ) + is_shared = ".ffn.shared_experts." in name or ".shared_experts." in name is_fse_redirect = is_shared and fuse_by_layer.get( extract_layer_index(name), False ) if is_fse_redirect: - name = name.replace(".shared_experts.down_proj", f".experts.{n_routed}.w2") - name = name.replace(".shared_experts.gate_proj", f".experts.{n_routed}.w1") - name = name.replace(".shared_experts.up_proj", f".experts.{n_routed}.w3") + name = name.replace( + ".shared_experts.down_proj", f".experts.{n_routed}.w2" + ) + name = name.replace( + ".shared_experts.gate_proj", f".experts.{n_routed}.w1" + ) + name = name.replace( + ".shared_experts.up_proj", f".experts.{n_routed}.w3" + ) name = name.replace(".shared_experts.w", f".experts.{n_routed}.w") - if pad_shared_expert and (is_shared or is_fse_redirect): + if pad_shared_expert and is_shared: loaded_weight = self._pad_shared_expert_weight( self.quant_config, name, loaded_weight ) @@ -863,9 +867,7 @@ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]: for mod in self.modules() if isinstance(mod, DeepseekV4MoEBase) ) - num_experts = self.config.n_routed_experts + ( - n_shared if is_fse else 0 - ) + num_experts = self.config.n_routed_experts + (n_shared if is_fse else 0) return fused_moe_make_expert_params_mapping( self, ckpt_gate_proj_name="w1", diff --git a/vllm/models/deepseek_v41/amd/vl_model.py b/vllm/models/deepseek_v41/amd/vl_model.py index 2d7e42a6a453..9fcea6eec7da 100644 --- a/vllm/models/deepseek_v41/amd/vl_model.py +++ b/vllm/models/deepseek_v41/amd/vl_model.py @@ -312,11 +312,13 @@ def get_mtp_target_hidden_states(self) -> torch.Tensor | None: return self.language_model.get_mtp_target_hidden_states() def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - # Stream language_model weights as a contiguous block without sorting/materializing - # the entire 48-shard generator into a Python list. Materializing all 48 shards - # prematurely retains all checkpoint tensors simultaneously and exhausts host - # memory. Non-language_model tensors (vision tower, aligner, image tokens, ~266 tensors / - # ~400 MB total in Shards 1-2) are buffered in host memory and yielded after language_model. + # Stream language_model weights as a contiguous block without + # sorting/materializing the entire 48-shard generator into a Python + # list. Materializing all 48 shards prematurely retains all checkpoint + # tensors simultaneously and exhausts host memory. Non-language_model + # tensors (vision tower, aligner, image tokens, ~266 tensors / ~400 MB + # total in Shards 1-2) are buffered in host memory and yielded after + # language_model. def _stream_reordered(): non_lm_weights: list[tuple[str, torch.Tensor]] = [] for name, tensor in self.hf_to_vllm_mapper.apply(weights): From 129a17e020ca33d36c6e968b460ad504cd37dcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rony=20Lepp=C3=A4nen?= Date: Fri, 25 Sep 2026 20:20:42 +0000 Subject: [PATCH 5/5] [Model][Format] Add SPDX copyright headers and format imports in DeepSeek-V4.1 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add '# SPDX-FileCopyrightText: Copyright contributors to the vLLM project' to all 4 DeepSeek-V4.1 test suites per tools/pre_commit/check_spdx_header.py. 2. Sort and format import blocks per Ruff I001 rule. Signed-off-by: Rony Leppänen Co-Authored-By: Claude Code --- tests/models/deepseek_v41/test_fse_routing.py | 2 ++ tests/models/deepseek_v41/test_quant_config.py | 2 ++ tests/models/deepseek_v41/test_vl_streaming.py | 1 + tests/models/deepseek_v41/test_weights_mapper_engram.py | 2 ++ 4 files changed, 7 insertions(+) diff --git a/tests/models/deepseek_v41/test_fse_routing.py b/tests/models/deepseek_v41/test_fse_routing.py index 3faa672becbc..ede98dbe3751 100644 --- a/tests/models/deepseek_v41/test_fse_routing.py +++ b/tests/models/deepseek_v41/test_fse_routing.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for DeepSeek-V4.1 Fused Shared Experts (FSE) routing, padding, and expert mapping.""" @@ -6,6 +7,7 @@ from unittest.mock import MagicMock, patch import torch + from vllm.models.deepseek_v41.amd.model import DeepseekV4Model, DeepseekV4MoEBase diff --git a/tests/models/deepseek_v41/test_quant_config.py b/tests/models/deepseek_v41/test_quant_config.py index d47c33ee5668..cca22540758d 100644 --- a/tests/models/deepseek_v41/test_quant_config.py +++ b/tests/models/deepseek_v41/test_quant_config.py @@ -1,10 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for DeepSeek-V4.1 quant method routing and shared experts exclusion.""" from types import SimpleNamespace import pytest import torch + from vllm.config import set_current_vllm_config from vllm.model_executor.layers.linear import LinearBase from vllm.model_executor.layers.quantization.fp8 import Fp8LinearMethod diff --git a/tests/models/deepseek_v41/test_vl_streaming.py b/tests/models/deepseek_v41/test_vl_streaming.py index d260b3eb187c..5934d23ffcfd 100644 --- a/tests/models/deepseek_v41/test_vl_streaming.py +++ b/tests/models/deepseek_v41/test_vl_streaming.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for DeepSeek-V4.1 VLM non-materialized generator streaming.""" import torch diff --git a/tests/models/deepseek_v41/test_weights_mapper_engram.py b/tests/models/deepseek_v41/test_weights_mapper_engram.py index dd6fedb2ff27..a99444ddcc80 100644 --- a/tests/models/deepseek_v41/test_weights_mapper_engram.py +++ b/tests/models/deepseek_v41/test_weights_mapper_engram.py @@ -1,7 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for DeepSeek-V4.1 engram scale key regex mapping.""" import pytest + from vllm.models.deepseek_v41.amd.model import ( _make_deepseek_v4_weights_mapper as _make_amd_mapper, )