[Bugfix][MoE] Fix fused block-scale orientation (#50727)

Signed-off-by: Andreas Karatzas <akaratza@amd.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Andreas Karatzas
2026-08-11 16:05:38 -04:00
committed by GitHub
co-authored by mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent fd04edef3e
commit 36f4630d89
5 changed files with 80 additions and 29 deletions
@@ -73,43 +73,63 @@ class TestOrientFusedWeight:
def test_w13_standard_orientation_is_untouched(self):
weight = torch.randn(8, 2048, self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, "w1", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, False)
assert result.shape == (8, 2048, self.HIDDEN)
def test_w13_transposed_checkpoint_is_normalised(self):
# e.g. Qwen3 VL MoE stores [experts, hidden, 2 * intermediate]
weight = torch.randn(8, self.HIDDEN, 2048)
result = RoutedExperts._orient_fused_weight(weight, "w3", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, True)
assert result.shape == (8, 2048, self.HIDDEN)
def test_w2_standard_orientation_is_untouched(self):
weight = torch.randn(8, self.HIDDEN, 1024)
result = RoutedExperts._orient_fused_weight(weight, "w2", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, False)
assert result.shape == (8, self.HIDDEN, 1024)
def test_w2_transposed_checkpoint_is_normalised(self):
weight = torch.randn(8, 1024, self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, "w2", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(weight, True)
assert result.shape == (8, self.HIDDEN, 1024)
def test_w13_per_channel_scale_is_untouched(self):
# A fused per-channel scale has no hidden dim, so transposing it would
# leave chunk()/TP sharding operating on the wrong axis.
scale = torch.randn(8, 2048, 1)
result = RoutedExperts._orient_fused_weight(scale, "w1", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(scale, False)
assert result.shape == (8, 2048, 1)
assert result.chunk(2, dim=1)[0].shape == (8, 1024, 1)
def test_w2_per_channel_scale_is_untouched(self):
scale = torch.randn(8, self.HIDDEN, 1)
result = RoutedExperts._orient_fused_weight(scale, "w2", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(scale, False)
assert result.shape == (8, self.HIDDEN, 1)
def test_block_scale_is_untouched(self):
# Block scales are [experts, 2 * intermediate / block, hidden / block]
scale = torch.randn(8, 16, 24)
result = RoutedExperts._orient_fused_weight(scale, "w1", self.HIDDEN)
result = RoutedExperts._orient_fused_weight(scale, False)
assert result.shape == (8, 16, 24)
assert result.data_ptr() == scale.data_ptr()
@pytest.mark.parametrize(
"checkpoint_shape",
[
# Qwen3-VL stores block scales in checkpoint weight orientation.
(8, 16, 12),
(8, 6, 16),
(8, 16, 16),
],
)
def test_qwen3_vl_transposed_block_scale_uses_explicit_layout(
self,
checkpoint_shape: tuple[int, ...],
):
scale = torch.arange(torch.tensor(checkpoint_shape).prod()).reshape(
checkpoint_shape
)
result = RoutedExperts._orient_fused_weight(scale, True)
torch.testing.assert_close(result, scale.transpose(-1, -2))
class TestNarrowExpertDataForPadding:
@@ -130,6 +130,7 @@ def FusedMoEFactory(
is_sequence_parallel: bool = False,
reduce_results: bool = True,
ckpt_names: tuple[str, str, str] = ("gate_proj", "down_proj", "up_proj"),
is_fused_checkpoint_transposed: bool = False,
n_shared_experts: int | None = None,
router_logits_dtype: torch.dtype | None = None,
gate: torch.nn.Module | None = None,
@@ -193,6 +194,8 @@ def FusedMoEFactory(
the late-AR path.
ckpt_names: Checkpoint parameter name tuple (gate_proj, down_proj,
up_proj) used for weight loading
is_fused_checkpoint_transposed: Whether fused checkpoint weights and
block scales use transposed storage.
n_shared_experts: Number of shared experts to fuse into the routed
grouped GEMM (ROCm; requires aiter FSE or the router-append path)
router_logits_dtype: Data type for router logits buffers
@@ -378,6 +381,7 @@ def FusedMoEFactory(
ckpt_gate_proj_name=ckpt_names[0],
ckpt_down_proj_name=ckpt_names[1],
ckpt_up_proj_name=ckpt_names[2],
is_fused_checkpoint_transposed=is_fused_checkpoint_transposed,
# Extra params that are needed by quant_methods, pass along for now
# Prefer getting these from other sources, e.g. moe_config or
# router object
@@ -61,6 +61,7 @@ class RoutedExperts(PluggableLayer):
ckpt_gate_proj_name: str = "gate_proj",
ckpt_down_proj_name: str = "down_proj",
ckpt_up_proj_name: str = "up_proj",
is_fused_checkpoint_transposed: bool = False,
#
# Extra params that are needed by quant_methods, pass along for now
# Prefer getting these from other sources, e.g. moe_config or
@@ -86,6 +87,7 @@ class RoutedExperts(PluggableLayer):
self.ckpt_gate_proj_name = ckpt_gate_proj_name
self.ckpt_down_proj_name = ckpt_down_proj_name
self.ckpt_up_proj_name = ckpt_up_proj_name
self.is_fused_checkpoint_transposed = is_fused_checkpoint_transposed
self.expert_map_manager = expert_map_manager
self.hidden_size = moe_config.hidden_dim
self.global_num_experts = moe_config.num_experts
@@ -423,23 +425,11 @@ class RoutedExperts(PluggableLayer):
@staticmethod
def _orient_fused_weight(
fused_weight: torch.Tensor, shard_id: str, unpadded_hidden: int
fused_weight: torch.Tensor,
is_fused_checkpoint_transposed: bool,
) -> torch.Tensor:
"""Normalise a fused expert tensor from either checkpoint orientation
to (intermediate, hidden) for w1/w3 and (hidden, intermediate) for w2.
Only transposes when the hidden dim is definitively on the wrong axis,
leaving tensors that have no hidden dim alone (e.g. a fused per-channel
scale, (2 * intermediate, 1)).
"""
if shard_id == "w2":
hidden_axis, intermediate_axis = -2, -1
else:
hidden_axis, intermediate_axis = -1, -2
if (
fused_weight.shape[hidden_axis] != unpadded_hidden
and fused_weight.shape[intermediate_axis] == unpadded_hidden
):
"""Normalise a fused expert tensor to the vLLM weight layout."""
if is_fused_checkpoint_transposed:
return fused_weight.transpose(-1, -2)
return fused_weight
@@ -882,9 +872,6 @@ class RoutedExperts(PluggableLayer):
self, weights: Iterable[tuple[str, torch.Tensor]]
) -> Iterable[str]:
expert_mapping = self.get_expert_mapping(include_fused=True)
unpadded_hidden = (
self.moe_config.hidden_dim_unpadded or self.moe_config.hidden_dim
)
for expert_name, loaded_weight in weights:
qual_name = f"{self.layer_name}.{expert_name}"
# Fused expert weights can be identified by their 3D tensors
@@ -915,11 +902,19 @@ class RoutedExperts(PluggableLayer):
f"for checkpoint weight {qual_name!r}"
)
if is_fused:
quant_method = getattr(param, "quant_method", None)
# Block scales share the weight's two-dimensional layout.
# Other quantization metadata can use independent layouts.
uses_weight_layout = (
"scale" not in weight_name
or quant_method == FusedMoeWeightScaleSupported.BLOCK.value
)
# w1 and w3 share one fused tensor; use a local copy so the
# transpose below doesn't mutate loaded_weight across
# iterations (else w3 is transposed twice and wrongly chunked)
fused_weight = self._orient_fused_weight(
loaded_weight, shard_id, unpadded_hidden
loaded_weight,
self.is_fused_checkpoint_transposed and uses_weight_layout,
)
if shard_id in {"w1", "w3"}:
# Repurpose expert_id for deconcatenating w1 and w3
+11 -2
View File
@@ -132,6 +132,7 @@ class Qwen3MoeSparseMoeBlock(nn.Module):
self,
vllm_config: VllmConfig,
prefix: str = "",
is_fused_checkpoint_transposed: bool = False,
):
super().__init__()
@@ -208,6 +209,7 @@ class Qwen3MoeSparseMoeBlock(nn.Module):
enable_eplb=self.enable_eplb,
num_redundant_experts=self.n_redundant_experts,
is_sequence_parallel=self.is_sequence_parallel,
is_fused_checkpoint_transposed=is_fused_checkpoint_transposed,
)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
@@ -339,7 +341,12 @@ class Qwen3MoeAttention(nn.Module):
class Qwen3MoeDecoderLayer(nn.Module):
def __init__(self, vllm_config: VllmConfig, prefix: str = "") -> None:
def __init__(
self,
vllm_config: VllmConfig,
prefix: str = "",
is_fused_checkpoint_transposed: bool = False,
) -> None:
super().__init__()
config = vllm_config.model_config.hf_text_config
@@ -375,7 +382,9 @@ class Qwen3MoeDecoderLayer(nn.Module):
config.num_experts > 0 and (layer_idx + 1) % config.decoder_sparse_step == 0
):
self.mlp = Qwen3MoeSparseMoeBlock(
vllm_config=vllm_config, prefix=f"{prefix}.mlp"
vllm_config=vllm_config,
prefix=f"{prefix}.mlp",
is_fused_checkpoint_transposed=is_fused_checkpoint_transposed,
)
else:
self.mlp = Qwen3MoeMLP(
@@ -43,6 +43,7 @@ from vllm.tokenizers.registry import cached_tokenizer_from_config
from .interfaces import MixtureOfExperts
from .qwen3_moe import (
Qwen3MoeDecoderLayer,
Qwen3MoeForCausalLM,
Qwen3MoeModel,
Qwen3MoeSparseMoeBlock,
@@ -64,6 +65,15 @@ class Qwen3VLMoeProcessingInfo(Qwen3VLProcessingInfo):
return self.ctx.get_hf_config(Qwen3VLMoeConfig)
class Qwen3VLMoeDecoderLayer(Qwen3MoeDecoderLayer):
def __init__(self, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__(
vllm_config=vllm_config,
prefix=prefix,
is_fused_checkpoint_transposed=True,
)
@support_torch_compile(
dynamic_arg_dims={
"input_ids": 0,
@@ -77,6 +87,19 @@ class Qwen3VLMoeProcessingInfo(Qwen3VLProcessingInfo):
}
)
class Qwen3MoeLLMModel(Qwen3MoeModel):
def __init__(
self,
*,
vllm_config: VllmConfig,
prefix: str = "",
decoder_layer_type: type[torch.nn.Module] = Qwen3VLMoeDecoderLayer,
):
super().__init__(
vllm_config=vllm_config,
prefix=prefix,
decoder_layer_type=decoder_layer_type,
)
def forward(
self,
input_ids: torch.Tensor | None,