Fix MLA padding and grouped topk routing in the Transformers modelling backend (#49982)

Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Harry Mellor committed Jul 27, 2026 at 19:32 UTC 99de48e98fe9570d52b733a545d27661929c170c
5 files changed +227 -39
tests/models/transformers/fusers/test_moe.py
+80 -3
@@ -29,8 +29,48 @@ class TopKRouter(nn.Module):
29 return logits, value, index
30
31
32 +class ScaledRouter(TopKRouter):
33 + """Greedy router scaling its top-k weights (DeepSeek `routed_scaling_factor`)."""
34 +
35 + def forward(self, hidden_states):
36 + logits = F.linear(hidden_states, self.weight)
37 + scores = F.softmax(logits, dim=-1)
38 + value, index = torch.topk(scores, self.top_k, dim=-1)
39 + value = value * 16.0
40 + return logits, value, index
41 +
42 +
43 +class Fp32Router(TopKRouter):
44 + """Router that computes its logits in fp32 (DeepSeek/GLM style)."""
45 +
46 + def forward(self, hidden_states):
47 + logits = F.linear(
48 + hidden_states.type(torch.float32), self.weight.type(torch.float32)
49 + )
50 + scores = F.softmax(logits, dim=-1)
51 + value, index = torch.topk(scores, self.top_k, dim=-1)
52 + return logits, value.to(hidden_states.dtype), index
53 +
54 +
55 +class GroupedRouter(TopKRouter):
56 + """Group-limited router (DeepSeek `group_limited_greedy`), scaled weights."""
57 +
58 + def forward(self, hidden_states):
59 + logits = F.linear(hidden_states, self.weight)
60 + scores = F.softmax(logits, dim=-1)
61 + group_scores = scores.view(-1, 4, 2).max(dim=-1).values
62 + group_idx = torch.topk(group_scores, k=2, dim=-1)[1]
63 + group_mask = torch.zeros_like(group_scores)
64 + group_mask.scatter_(1, group_idx, 1)
65 + score_mask = group_mask.unsqueeze(-1).expand(-1, 4, 2).reshape(-1, 8)
66 + scores = scores.masked_fill(~score_mask.bool(), 0.0)
67 + value, index = torch.topk(scores, self.top_k, dim=-1)
68 + value = value * 16.0
69 + return logits, value, index
70 +
71 +
72 class CorrectionRouter(nn.Module):
33 - """Grouped router with a score-correction bias buffer (DeepSeek-V3) -> declined."""
73 + """Router with a score-correction bias buffer (DeepSeek-V3 noaux) -> matched."""
74
75 def __init__(self, num_experts=8, hidden=16):
76 super().__init__()
@@ -228,6 +268,44 @@ def test_moe_fuser_detects_router(sigmoid):
268 assert fuser.shared_name is None and fuser.shared_gate_name is None
269
270
271 +def test_moe_fuser_matches_scaled_router():
272 + """Weight scaling after the top-k (DeepSeek style) does not break matching."""
273 + with torch.device("meta"):
274 + block = MoEBlock(ScaledRouter)
275 + assert isinstance(MoEBlockFuser.match(block, "experts"), MoEBlockFuser)
276 +
277 +
278 +def test_moe_fuser_matches_grouped_router():
279 + """Group masking between the score and the routing top-k still matches: the
280 + scorer is anchored on the routing (last) top-k, not the group one."""
281 + with torch.device("meta"):
282 + block = MoEBlock(GroupedRouter)
283 + fuser = MoEBlockFuser.match(block, "experts")
284 + assert isinstance(fuser, MoEBlockFuser)
285 + assert fuser.scoring_func == "softmax"
286 +
287 +
288 +def test_moe_fuser_matches_correction_router():
289 + """A score-correction bias buffer (DeepSeek-V3 noaux) is allowed; the
290 + rebuilt gate carries it in fp32 for FusedMoE's biased routers."""
291 + with torch.device("meta"):
292 + block = MoEBlock(CorrectionRouter)
293 + fuser = MoEBlockFuser.match(block, "experts")
294 + assert isinstance(fuser, MoEBlockFuser)
295 + assert fuser.scoring_func == "sigmoid"
296 +
297 +
298 +def test_moe_fuser_reads_router_dtype_from_the_gate():
299 + """A router that computes its logits in fp32 must keep routing in fp32 when
300 + rebuilt, even though no config field names the dtype. The cast back to the
301 + activation dtype after the top-k must not be mistaken for the routing dtype."""
302 + with torch.device("meta"):
303 + fp32 = MoEBlockFuser.match(MoEBlock(Fp32Router), "experts")
304 + default = MoEBlockFuser.match(MoEBlock(TopKRouter), "experts")
305 + assert fp32.router_dtype == torch.float32
306 + assert default.router_dtype is None
307 +
308 +
309 def test_moe_fuser_detects_shared_experts():
310 with torch.device("meta"):
311 block = MoEBlockShared()
@@ -269,8 +347,7 @@ def test_moe_fuser_detects_non_glu_shared_expert():
347 @pytest.mark.parametrize(
348 "block_cls",
349 [
272 - lambda: MoEBlock(CorrectionRouter), # score-correction buffer (grouped)
273 - lambda: MoEBlock(BiasedRouter), # router not weight-only (extra param)
350 + lambda: MoEBlock(BiasedRouter), # router with an unrecognized extra param
351 MoEBlockTuple, # tuple-returning block (e.g. gpt-oss)
352 MoEBlockTupleVar, # tuple returned via a name binding, not a literal
353 MoEBlockUnaccounted, # weight-bearing child outside the fused dataflow
vllm/model_executor/models/transformers/__init__.py
+12 -1
@@ -18,6 +18,7 @@
18
19 from typing import TYPE_CHECKING
20
21 +import torch.nn.functional as F
22 from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
23
24 from vllm.model_executor.models.transformers.base import Base
@@ -59,9 +60,19 @@ def vllm_attention_forward(
60 if scaling is not None:
61 self_attn.impl.scale = float(scaling)
62 hidden = query.shape[-2]
63 + head_dim_qk = query.shape[-1]
64 + head_dim_v = value.shape[-1]
65 query, key, value = (x.transpose(1, 2) for x in (query, key, value))
66 query, key, value = (x.reshape(hidden, -1) for x in (query, key, value))
64 - return self_attn.forward(query, key, value), None
67 + # Pad `value` up to the query/key head size when they differ (expanded MLA).
68 + if head_dim_v != head_dim_qk:
69 + value = F.pad(value.view(-1, head_dim_v), (0, head_dim_qk - head_dim_v))
70 + value = value.reshape(hidden, -1)
71 + attn_output = self_attn.forward(query, key, value)
72 + if head_dim_v != head_dim_qk:
73 + attn_output = attn_output.view(-1, head_dim_qk)[..., :head_dim_v]
74 + attn_output = attn_output.reshape(hidden, -1)
75 + return attn_output, None
76
77
78 ALL_ATTENTION_FUNCTIONS["vllm"] = vllm_attention_forward
vllm/model_executor/models/transformers/fusers/moe.py
+55 -22
@@ -6,14 +6,14 @@ import ast
6 import inspect
7 import textwrap
8 import types
9 -from collections.abc import Iterator
9 +from collections.abc import Iterable, Iterator
10 from dataclasses import dataclass
11 -from itertools import chain
11
12 import torch
13 from torch import fx, nn
14
15 from vllm.distributed import tensor_model_parallel_all_gather
16 +from vllm.model_executor.layers.fused_moe import GateLinear
17 from vllm.model_executor.layers.linear import ReplicatedLinear
18 from vllm.model_executor.models.transformers.fx_utils import (
19 find_node,
@@ -21,14 +21,10 @@ from vllm.model_executor.models.transformers.fx_utils import (
21 peel,
22 trace,
23 )
24 +from vllm.model_executor.models.transformers.utils import named_state
25 from vllm.model_executor.models.utils import maybe_prefix, sequence_parallel_chunk
26
27
27 -def named_state(module: nn.Module) -> Iterator[tuple[str, torch.Tensor]]:
28 - """`module`'s own state (i.e. named parameters and buffers)."""
29 - return chain(module.named_parameters(), module.named_buffers())
30 -
31 -
28 def _own_returns(node: ast.AST) -> Iterator[ast.Return]:
29 """`return` statements in `node`'s own scope, not in nested functions."""
30 stack = list(ast.iter_child_nodes(node))
@@ -79,6 +75,25 @@ def _is_scalar_gate(module: nn.Module) -> bool:
75 )
76
77
78 +def _forced_dtype(nodes: Iterable[fx.Node]) -> torch.dtype | None:
79 + """The floating dtype `nodes` cast to, if any.
80 +
81 + Computations that must run in higher precision say so in their forward (e.g.
82 + `hidden_states.type(torch.float32)`), so the dtype is readable from the graph
83 + even when the config does not name it."""
84 + for node in nodes:
85 + if node.op not in ("call_method", "call_function"):
86 + continue
87 + name = str(node.target).rsplit(".", 1)[-1]
88 + if name == "float":
89 + return torch.float32
90 + if name in ("to", "type"):
91 + for arg in (*node.args[1:], *node.kwargs.values()):
92 + if isinstance(arg, torch.dtype) and arg.is_floating_point:
93 + return arg
94 + return None
95 +
96 +
97 def _reaches(node: fx.Node, key: str) -> set[fx.Node]:
98 """Returns the set of nodes reachable from `node` by following `key` edges."""
99 seen: set[fx.Node] = set()
@@ -131,18 +146,24 @@ class MoEBlockFuser:
146 scoring_func: str
147 shared_name: str | None
148 shared_gate_name: str | None
149 + router_dtype: torch.dtype | None = None
150
151 @staticmethod
136 - def _match_router(gate: nn.Module) -> str | None:
137 - """Matches `topk(score(linear(x)))`, `score` being `softmax`/`sigmoid`."""
138 - if [name for name, _ in named_state(gate)] != ["weight"]:
152 + def _match_router(gate: nn.Module) -> tuple[str, torch.dtype | None] | None:
153 + """Matches `topk(score(linear(x)))`, `score` being `softmax`/`sigmoid`.
154 +
155 + Returns the scoring function and the dtype the router computes in."""
156 + state = {name for name, _ in named_state(gate)}
157 + if "weight" not in state or state - {"weight", "e_score_correction_bias"}:
158 return None
159 graph = trace(gate)
160 if graph is None:
161 return None
143 - topk = find_node(graph, lambda n: is_op(n, "topk"))
144 - if topk is None:
162 + # The routing top-k is the last one; any earlier one scores expert groups.
163 + topks = [node for node in graph.nodes if is_op(node, "topk")]
164 + if not topks:
165 return None
166 + topk = topks[-1]
167 # Exactly one scoring op upstream of the top-k, fed (transitively) by a linear.
168 scorers = [
169 n
@@ -152,9 +173,11 @@ class MoEBlockFuser:
173 if len(scorers) != 1:
174 return None
175 scorer = scorers[0]
155 - if not any(is_op(n, "linear") for n in _reaches(scorer, "all_input_nodes")):
176 + logits_cone = _reaches(scorer, "all_input_nodes")
177 + if not any(is_op(n, "linear") for n in logits_cone):
178 return None
157 - return "softmax" if is_op(scorer, "softmax") else "sigmoid"
179 + scoring_func = "softmax" if is_op(scorer, "softmax") else "sigmoid"
180 + return scoring_func, _forced_dtype(logits_cone)
181
182 @staticmethod
183 def _match_shared_experts(
@@ -198,10 +221,14 @@ class MoEBlockFuser:
221 if _returns_tuple(type(moe_block)):
222 return None
223 # Router: the child that scores + top-k selects.
201 - gate_name = scoring_func = None
224 + gate_name = scoring_func = router_dtype = None
225 for name, child in moe_block.named_children():
203 - if name != experts_name and (func := cls._match_router(child)) is not None:
204 - gate_name, scoring_func = name, func
226 + if (
227 + name != experts_name
228 + and (router := cls._match_router(child)) is not None
229 + ):
230 + gate_name = name
231 + scoring_func, router_dtype = router
232 break
233 if gate_name is None or scoring_func is None:
234 return None
@@ -229,17 +256,23 @@ class MoEBlockFuser:
256 for name, child in moe_block.named_children():
257 if name not in accounted and next(named_state(child), None) is not None:
258 return None
232 - return cls(gate_name, scoring_func, shared_name, shared_gate_name)
259 + return cls(gate_name, scoring_func, shared_name, shared_gate_name, router_dtype)
260
234 - def gate(self, moe_block: nn.Module, prefix: str) -> ReplicatedLinear:
235 - """Rebuild the HF gate as a `ReplicatedLinear` for vLLM's fused MoE."""
236 - num_experts, hidden_size = getattr(moe_block, self.gate_name).weight.shape
237 - gate = ReplicatedLinear(
261 + def gate(
262 + self, moe_block: nn.Module, prefix: str, out_dtype: torch.dtype | None = None
263 + ) -> GateLinear:
264 + """Rebuild the HF gate as a `GateLinear` for vLLM's fused MoE."""
265 + hf_gate = getattr(moe_block, self.gate_name)
266 + num_experts, hidden_size = hf_gate.weight.shape
267 + gate = GateLinear(
268 hidden_size,
269 num_experts,
270 bias=False,
271 + out_dtype=out_dtype or self.router_dtype,
272 prefix=maybe_prefix(prefix, self.gate_name),
273 )
274 + if (bias := getattr(hf_gate, "e_score_correction_bias", None)) is not None:
275 + gate.register_buffer("e_score_correction_bias", bias)
276 setattr(moe_block, self.gate_name, gate)
277 return gate
278
vllm/model_executor/models/transformers/moe.py
+73 -13
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any
23 import torch
24 import torch.nn as nn
25
26 +from vllm._aiter_ops import rocm_aiter_ops
27 from vllm.config.utils import getattr_iter
28 from vllm.distributed import get_dp_group, get_ep_group
29 from vllm.forward_context import ForwardContext, get_forward_context
@@ -30,9 +31,10 @@ from vllm.logger import init_logger
31 from vllm.model_executor.custom_op import PluggableLayer
32 from vllm.model_executor.layers.fused_moe import FusedMoE, MoERunner, RoutedExperts
33 from vllm.model_executor.models.interfaces import MixtureOfExperts
34 +from vllm.model_executor.models.transformers.fuser import get_fuser
35 from vllm.model_executor.models.transformers.fusers.moe import MoEBlockFuser
36 from vllm.model_executor.models.utils import maybe_prefix
35 -from vllm.utils.torch_utils import direct_register_custom_op
37 +from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE, direct_register_custom_op
38
39 from .utils import log_replacement
40
@@ -176,17 +178,31 @@ class MoEMixin(MixtureOfExperts):
178 0,
179 )
180
179 - # Unused kwargs since we use custom_routing_function:
180 - # - `scoring_func` and `e_score_correction_bias` only used for grouped
181 - # topk routing inside vLLM and are non-trivial to infer
182 - # and hard code `use_grouped_topk=False`
183 - # - `renormalize` passed anyway because it's easy to infer
184 - # - `num_expert_group` and `topk_group` used for inferring expert
185 - # placement strategy in FusedMoE
186 - # - `apply_router_weight_on_input` is already applied in Transformers
181 + # Common kwargs
182 renormalize = getattr(text_config, "norm_topk_prob", top_k > 1)
183 +
184 + # Routed scaling factor kwargs
185 + routed_scaling_factor = getattr(text_config, "routed_scaling_factor", 1.0)
186 + # aiter applies routed_scaling_factor internally
187 + apply_routed_scale_to_output = not rocm_aiter_ops.is_fused_moe_enabled()
188 + routed_scaling_factor_kwargs = dict(
189 + routed_scaling_factor=routed_scaling_factor,
190 + apply_routed_scale_to_output=apply_routed_scale_to_output,
191 + )
192 +
193 + # Dtype the router computes in, if it is not the activation dtype.
194 + config_router_dtype = getattr(text_config, "moe_router_dtype", None)
195 + config_router_dtype = STR_DTYPE_TO_TORCH_DTYPE.get(config_router_dtype)
196 +
197 + # Grouped topk routing kwargs
198 num_expert_group = getattr(text_config, "n_group", None)
199 topk_group = getattr(text_config, "topk_group", None)
200 + use_grouped_topk = num_expert_group is not None and topk_group is not None
201 + grouped_topk_routing_kwargs = dict(
202 + use_grouped_topk=use_grouped_topk,
203 + num_expert_group=num_expert_group,
204 + topk_group=topk_group,
205 + )
206
207 # MoE activation function
208 activation = "silu"
@@ -211,6 +227,9 @@ class MoEMixin(MixtureOfExperts):
227 self.num_shared_experts = num_shared_experts
228 self.num_redundant_experts = num_redundant_experts
229
230 + # Down projections of shared experts consumed by FusedMoE
231 + shared_down_projs: list[tuple[nn.Module, str]] = []
232 +
233 # Recursively fuse MoE layers
234 def _recursive_replace(module: nn.Module, prefix: str):
235 for child_name, child_module in module.named_children():
@@ -249,7 +268,6 @@ class MoEMixin(MixtureOfExperts):
268 hidden_size=hidden_size,
269 intermediate_size=intermediate_size,
270 renormalize=renormalize,
252 - use_grouped_topk=False,
271 quant_config=self.quant_config,
272 prefix=qual_name,
273 activation=activation,
@@ -259,17 +277,55 @@ class MoEMixin(MixtureOfExperts):
277 routed_experts_cls=TransformersRoutedExperts,
278 )
279 fuser = MoEBlockFuser.match(moe_block, experts_name)
262 - if self.num_expert_groups <= 1 and fuser is not None:
280 + # _maybe_apply_routed_scale_to_output edge case. Transformers
281 + # decoder layers do not compensate for dividing by scaling factor.
282 + reaches_fp16_trick = (
283 + routed_scaling_factor != 1.0
284 + and apply_routed_scale_to_output
285 + and self.model_config.dtype == torch.float16
286 + and fuser is not None
287 + and fuser.shared_name is not None
288 + )
289 + if reaches_fp16_trick:
290 + logger.warning_once(
291 + "%s could be fused but routing it in vLLM would apply "
292 + "`routed_scaling_factor` by dividing the shared expert "
293 + "output, which only fp16 overflow protection expects the "
294 + "decoder layer to compensate for. Falling back to routing "
295 + "in Transformers; run in bfloat16 to fuse it.",
296 + moe_block_cls,
297 + )
298 + if fuser is not None and not reaches_fp16_trick:
299 # MoE block forward is fully replaced.
300 # gate/router and shared expert (if any) runs in FusedMoE.
301 + shared_experts = fuser.shared_experts(moe_block, prefix)
302 + # Store shared experts for later down projection adjustment
303 + if shared_experts is not None:
304 + hf_shared = shared_experts.shared_experts
305 + glu_fuser = get_fuser(hf_shared)
306 + down_name = getattr(glu_fuser, "down_name", None)
307 + if down_name is not None:
308 + shared_down_projs.append((hf_shared, down_name))
309 + # Prefer config, otherwise read it from fuser.
310 + router_dtype = config_router_dtype or fuser.router_dtype
311 + gate = fuser.gate(moe_block, prefix, router_dtype)
312 kwargs |= dict(
313 scoring_func=fuser.scoring_func,
314 is_sequence_parallel=(
315 self.parallel_config.use_sequence_parallel_moe
316 ),
270 - gate=fuser.gate(moe_block, prefix),
271 - shared_experts=fuser.shared_experts(moe_block, prefix),
317 + gate=gate,
318 + shared_experts=shared_experts,
319 )
320 + if router_dtype is not None:
321 + kwargs["router_logits_dtype"] = router_dtype
322 + if use_grouped_topk:
323 + kwargs |= grouped_topk_routing_kwargs
324 + if routed_scaling_factor != 1.0:
325 + kwargs |= routed_scaling_factor_kwargs
326 + bias = getattr(gate, "e_score_correction_bias", None)
327 + if bias is not None:
328 + kwargs["e_score_correction_bias"] = bias
329 fuser.rewrite_forward(moe_block)
330 routed = "gate + experts"
331 if fuser.shared_name:
@@ -333,3 +389,7 @@ class MoEMixin(MixtureOfExperts):
389 self.num_moe_layers = len(self.moe_layers)
390 # Continue with the replacement of layers in Base
391 super().recursive_replace()
392 + # GLUFuser likely fused shared_experts. The down projection after the GLU
393 + # normally immediately reduces but we want FusedMoE to handle the reduction.
394 + for hf_shared, down_name in shared_down_projs:
395 + hf_shared.get_submodule(down_name).reduce_results = False
vllm/model_executor/models/transformers/utils.py
+7
@@ -16,7 +16,9 @@
16 # limitations under the License.
17 """Transformers modeling backend utilities."""
18
19 +from collections.abc import Iterator
20 from contextlib import contextmanager
21 +from itertools import chain
22 from pathlib import Path
23 from typing import TYPE_CHECKING, Literal
24
@@ -209,6 +211,11 @@ def recursive_replace_linear(
211 _recursive_replace(model, prefix=prefix)
212
213
214 +def named_state(module: nn.Module) -> Iterator[tuple[str, torch.Tensor]]:
215 + """`module`'s own state (i.e. named parameters and buffers)."""
216 + return chain(module.named_parameters(), module.named_buffers())
217 +
218 +
219 def log_replacement(name: str, old_module: nn.Module, new_module: nn.Module):
220 logger.debug("%s: %s -> %s", name, old_module, new_module)
221