main
py 376 lines 13.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for the Transformers modeling backend's MoE fuser."""
4
5 import pytest
6 import torch
7 import torch.nn as nn
8 import torch.nn.functional as F
9
10 from vllm.model_executor.models.transformers.fusers import MoEBlockFuser
11
12 from .test_linear import GLUMLP
13
14
15 class TopKRouter(nn.Module):
16 """HF v5 top-k router: `linear -> softmax -> topk (-> renorm)`."""
17
18 def __init__(self, num_experts=8, hidden=16, top_k=2, sigmoid=False):
19 super().__init__()
20 self.top_k = top_k
21 self.sigmoid = sigmoid
22 self.weight = nn.Parameter(torch.zeros(num_experts, hidden))
23
24 def forward(self, hidden_states):
25 logits = F.linear(hidden_states, self.weight)
26 scores = torch.sigmoid(logits) if self.sigmoid else F.softmax(logits, dim=-1)
27 value, index = torch.topk(scores, self.top_k, dim=-1)
28 value = value / value.sum(dim=-1, keepdim=True)
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):
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__()
77 self.weight = nn.Parameter(torch.zeros(num_experts, hidden))
78 self.register_buffer("e_score_correction_bias", torch.zeros(num_experts))
79
80 def forward(self, hidden_states):
81 logits = F.linear(hidden_states, self.weight)
82 scores = torch.sigmoid(logits) + self.e_score_correction_bias
83 _, index = torch.topk(scores, 2, dim=-1)
84 return logits, scores, index
85
86
87 class BiasedRouter(TopKRouter):
88 """A valid top-k router but not `weight`-only (extra `bias` param) -> declined."""
89
90 def __init__(self):
91 super().__init__()
92 self.bias = nn.Parameter(torch.zeros(8))
93
94 def forward(self, hidden_states):
95 logits = F.linear(hidden_states, self.weight) + self.bias
96 scores = F.softmax(logits, dim=-1)
97 value, index = torch.topk(scores, self.top_k, dim=-1)
98 return logits, value, index
99
100
101 class DisconnectedRouter(TopKRouter):
102 """linear+softmax+top-k present but top-k ignores the logits -> not a router."""
103
104 def forward(self, hidden_states):
105 logits = F.linear(hidden_states, self.weight)
106 _ = F.softmax(logits, dim=-1) # scored, but not consumed by top-k
107 value, index = torch.topk(hidden_states, self.top_k, dim=-1)
108 return logits, value, index
109
110
111 class MoEExperts(nn.Module):
112 """Packed experts (3D weights); only its name (`experts`) matters here."""
113
114 def __init__(self, num_experts=8, hidden=16, inter=32):
115 super().__init__()
116 self.gate_up_proj = nn.Parameter(torch.zeros(num_experts, 2 * inter, hidden))
117 self.down_proj = nn.Parameter(torch.zeros(num_experts, hidden, inter))
118
119 def forward(self, hidden_states, index, weights):
120 return hidden_states
121
122
123 class MoEBlock(nn.Module):
124 """Single-tensor MoE block (Qwen3-style); subclasses override `_shared`."""
125
126 def __init__(self, router_cls=TopKRouter):
127 super().__init__()
128 self.experts = MoEExperts()
129 self.gate = router_cls()
130
131 def _shared(self, x, logits):
132 """The term added to the experts' output (none for a plain block)."""
133 return 0
134
135 def forward(self, hidden_states):
136 x = hidden_states.reshape(-1, hidden_states.shape[-1])
137 logits, weights, index = self.gate(x)
138 out = self.experts(x, index, weights) + self._shared(x, logits)
139 return out.reshape(hidden_states.shape)
140
141
142 class MoEBlockNoShared(MoEBlock):
143 """No shared-expert child but a gate-derived add -> trace skipped, still fuses."""
144
145 def _shared(self, x, logits):
146 return logits.sum()
147
148
149 class MoEBlockShared(MoEBlock):
150 """A block with a shared expert and its sigmoid gate (Qwen2-style)."""
151
152 def __init__(self):
153 super().__init__()
154 self.shared_expert = GLUMLP()
155 self.shared_expert_gate = nn.Linear(16, 1, bias=False)
156
157 def _shared(self, x, logits):
158 return torch.sigmoid(self.shared_expert_gate(x)) * self.shared_expert(x)
159
160
161 class MoEBlockSharedNoGate(MoEBlock):
162 """A block with an ungated shared expert -> native, shared passed through."""
163
164 def __init__(self):
165 super().__init__()
166 self.shared_expert = GLUMLP()
167
168 def _shared(self, x, logits):
169 return self.shared_expert(x)
170
171
172 class MoEBlockTuple(MoEBlock):
173 """A tuple-returning block (gpt-oss-style) -> must decline."""
174
175 def forward(self, hidden_states):
176 x = hidden_states.reshape(-1, hidden_states.shape[-1])
177 _, weights, index = self.gate(x)
178 return self.experts(x, index, weights), index
179
180
181 class MoEBlockTupleVar(MoEBlock):
182 """Returns a name bound to a tuple, not a literal tuple -> must still decline."""
183
184 def forward(self, hidden_states):
185 x = hidden_states.reshape(-1, hidden_states.shape[-1])
186 _, weights, index = self.gate(x)
187 result = self.experts(x, index, weights), index
188 return result
189
190
191 class MoEBlockNestedTupleReturn(MoEBlock):
192 """Tuple `return` in a nested helper; block returns one tensor -> still fuses."""
193
194 def forward(self, hidden_states):
195 def keep(a, b):
196 return a, b
197
198 x = hidden_states.reshape(-1, hidden_states.shape[-1])
199 _, weights, index = self.gate(x)
200 out, _ = keep(self.experts(x, index, weights), index)
201 return out.reshape(hidden_states.shape)
202
203
204 class PlainMLP(nn.Module):
205 """A non-GLU FFN: `down(act(up(x)))`, no gating multiply."""
206
207 def __init__(self, hidden: int = 16, inter: int = 32):
208 super().__init__()
209 self.up_proj = nn.Linear(hidden, inter, bias=False)
210 self.down_proj = nn.Linear(inter, hidden, bias=False)
211 self.act_fn = nn.SiLU()
212
213 def forward(self, x):
214 return self.down_proj(self.act_fn(self.up_proj(x)))
215
216
217 class MoEBlockSharedNonGLU(MoEBlock):
218 """A non-GLU shared expert -> detected by dataflow (no gate/up merge)."""
219
220 def __init__(self):
221 super().__init__()
222 self.shared_expert = PlainMLP()
223
224 def _shared(self, x, logits):
225 return self.shared_expert(x)
226
227
228 class MoEBlockUnaccounted(MoEBlock):
229 """A weight-bearing child outside the fused dataflow (pre-router) -> declined."""
230
231 def __init__(self):
232 super().__init__()
233 self.extra = nn.Linear(16, 16, bias=False)
234
235 def forward(self, hidden_states):
236 x = self.extra(hidden_states.reshape(-1, hidden_states.shape[-1]))
237 _, weights, index = self.gate(x)
238 return self.experts(x, index, weights).reshape(hidden_states.shape)
239
240
241 class BufferScale(nn.Module):
242 """A stateful child carrying only a buffer (no parameters)."""
243
244 def __init__(self, hidden: int = 16):
245 super().__init__()
246 self.register_buffer("scale", torch.ones(hidden))
247
248 def forward(self, x):
249 return x * self.scale
250
251
252 class MoEBlockUnaccountedBuffer(MoEBlockUnaccounted):
253 """Like `MoEBlockUnaccounted`, but the extra child holds only a buffer."""
254
255 def __init__(self):
256 super().__init__()
257 self.extra = BufferScale()
258
259
260 @pytest.mark.parametrize("sigmoid", [False, True])
261 def test_moe_fuser_detects_router(sigmoid):
262 with torch.device("meta"):
263 block = MoEBlock(lambda: TopKRouter(sigmoid=sigmoid))
264 fuser = MoEBlockFuser.match(block, "experts")
265 assert isinstance(fuser, MoEBlockFuser)
266 assert fuser.gate_name == "gate"
267 assert fuser.scoring_func == ("sigmoid" if sigmoid else "softmax")
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()
312 fuser = MoEBlockFuser.match(block, "experts")
313 assert isinstance(fuser, MoEBlockFuser)
314 assert fuser.shared_name == "shared_expert"
315 assert fuser.shared_gate_name == "shared_expert_gate"
316
317
318 def test_moe_fuser_skips_shared_detection_without_extra_children():
319 """With only experts and gate, shared-expert detection (and its block trace)
320 is skipped, so a gate-derived add is not misread as a shared expert."""
321 with torch.device("meta"):
322 block = MoEBlockNoShared()
323 fuser = MoEBlockFuser.match(block, "experts")
324 assert isinstance(fuser, MoEBlockFuser)
325 assert fuser.shared_name is None and fuser.shared_gate_name is None
326
327
328 def test_moe_fuser_shared_without_gate():
329 with torch.device("meta"):
330 block = MoEBlockSharedNoGate()
331 fuser = MoEBlockFuser.match(block, "experts")
332 assert isinstance(fuser, MoEBlockFuser)
333 assert fuser.shared_name == "shared_expert"
334 assert fuser.shared_gate_name is None
335
336
337 def test_moe_fuser_detects_non_glu_shared_expert():
338 with torch.device("meta"):
339 block = MoEBlockSharedNonGLU()
340 fuser = MoEBlockFuser.match(block, "experts")
341 assert isinstance(fuser, MoEBlockFuser)
342 # Recognised by dataflow (added to the experts' output), though not a GLU.
343 assert fuser.shared_name == "shared_expert"
344 assert fuser.shared_gate_name is None
345
346
347 @pytest.mark.parametrize(
348 "block_cls",
349 [
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
354 MoEBlockUnaccountedBuffer, # buffer-only child outside the fused dataflow
355 ],
356 )
357 def test_moe_fuser_declines_unsupported(block_cls):
358 with torch.device("meta"):
359 block = block_cls()
360 assert MoEBlockFuser.match(block, "experts") is None
361
362
363 def test_moe_fuser_ignores_nested_returns():
364 """A tuple `return` inside a nested helper must not decline a block whose own
365 forward returns a single tensor."""
366 with torch.device("meta"):
367 block = MoEBlockNestedTupleReturn()
368 assert isinstance(MoEBlockFuser.match(block, "experts"), MoEBlockFuser)
369
370
371 def test_moe_fuser_router_requires_connected_dataflow():
372 """A gate with linear + softmax + top-k present but not wired as a router
373 (top-k selects over the input, not the scored logits) is not detected."""
374 with torch.device("meta"):
375 block = MoEBlock(DisconnectedRouter)
376 assert MoEBlockFuser.match(block, "experts") is None