main
py 462 lines 16.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the MOE align block size function.
4
5 Run `pytest tests/kernels/moe/test_moe_align_block_size.py`.
6 """
7
8 import pytest
9 import torch
10
11 from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
12 batched_moe_align_block_size,
13 moe_align_block_size,
14 )
15 from vllm.utils.math_utils import cdiv, round_up
16 from vllm.utils.torch_utils import set_random_seed
17
18 NUM_TOKENS = [1, 3, 256, 2256, 4096]
19 NUM_EXPERTS = [32, 160, 256, 257]
20 TOP_KS = [1, 2, 16, 32]
21 BLOCK_SIZES = [32, 128]
22 set_random_seed(0)
23
24
25 @pytest.mark.parametrize("num_experts,num_tokens", [(32, 8), (256, 8), (256, 1024)])
26 @pytest.mark.parametrize("use_expert_map", [False, True])
27 def test_moe_align_scatter_masks_invalid_routes(
28 num_experts, num_tokens, use_expert_map
29 ):
30 topk_ids = torch.arange(num_tokens * 4, device="cuda").reshape(-1, 4)
31 topk_ids %= num_experts
32 topk_ids[0] = torch.tensor([-1, num_experts, 0, 1], device="cuda")
33 expert_map = None
34 valid = (topk_ids >= 0) & (topk_ids < num_experts)
35 if use_expert_map:
36 expert_map = torch.full((num_experts,), -1, dtype=torch.int32, device="cuda")
37 expert_map[::2] = torch.arange(
38 num_experts // 2, device="cuda", dtype=torch.int32
39 )
40 valid &= topk_ids % 2 == 0
41
42 sorted_ids, expert_ids, num_padded, scatter_idx = moe_align_block_size(
43 topk_ids,
44 32,
45 num_experts,
46 expert_map,
47 ignore_invalid_experts=True,
48 return_scatter_idx=True,
49 )
50 expected = torch.arange(topk_ids.numel(), device="cuda", dtype=torch.int32)
51 expected[~valid.flatten()] = -1
52 torch.testing.assert_close(scatter_idx, expected[:, None])
53 active_sorted = sorted_ids[: num_padded.item()]
54 active_sorted = active_sorted[active_sorted < topk_ids.numel()]
55 torch.testing.assert_close(active_sorted.sort().values, expected[expected >= 0])
56
57 baseline = moe_align_block_size(
58 topk_ids, 32, num_experts, expert_map, ignore_invalid_experts=True
59 )
60 assert len(baseline) == 3
61 torch.testing.assert_close(num_padded, baseline[2])
62 torch.testing.assert_close(expert_ids, baseline[1])
63
64
65 def _group_tokens_by_expert(
66 sorted_ids: torch.Tensor,
67 expert_ids: torch.Tensor,
68 block_size: int,
69 valid_length: int,
70 total_tokens: int,
71 ) -> dict:
72 num_blocks = valid_length // block_size
73 expert_tokens: dict[int, list[int]] = {}
74
75 for block_idx in range(num_blocks):
76 expert_id = expert_ids[block_idx].item()
77 block_start = block_idx * block_size
78 block_end = min(block_start + block_size, valid_length)
79
80 block_tokens = sorted_ids[block_start:block_end]
81 valid_tokens = block_tokens[block_tokens < total_tokens]
82
83 if expert_id not in expert_tokens:
84 expert_tokens[expert_id] = []
85 expert_tokens[expert_id].extend(valid_tokens.tolist())
86 return expert_tokens
87
88
89 def _verify_expert_level_sorting(
90 actual_sorted_ids: torch.Tensor,
91 golden_sorted_ids: torch.Tensor,
92 expert_ids: torch.Tensor,
93 block_size: int,
94 valid_length: int,
95 total_tokens: int,
96 ):
97 """Verify that actual_sorted_ids follows the correct expert-level sorting.
98 The kerne limplementation may or may not preserve original token order
99 in topk_ids in the final sorted_ids however this does not impact quality.
100 """
101 # Group tokens by expert from the golden implementation
102 golden_expert_tokens = _group_tokens_by_expert(
103 golden_sorted_ids, expert_ids, block_size, valid_length, total_tokens
104 )
105
106 actual_expert_tokens = _group_tokens_by_expert(
107 actual_sorted_ids, expert_ids, block_size, valid_length, total_tokens
108 )
109
110 assert set(golden_expert_tokens.keys()) == set(actual_expert_tokens.keys()), (
111 f"Expert IDs mismatch: golden={set(golden_expert_tokens.keys())}, "
112 f"actual={set(actual_expert_tokens.keys())}"
113 )
114
115 for expert_id in golden_expert_tokens:
116 golden_tokens = torch.tensor(
117 golden_expert_tokens[expert_id], device=actual_sorted_ids.device
118 )
119 actual_tokens = torch.tensor(
120 actual_expert_tokens[expert_id], device=actual_sorted_ids.device
121 )
122 assert torch.equal(
123 torch.sort(golden_tokens)[0], torch.sort(actual_tokens)[0]
124 ), (
125 f"Expert {expert_id} token mismatch: "
126 f"golden={golden_expert_tokens[expert_id]}, "
127 f"actual={actual_expert_tokens[expert_id]}"
128 )
129
130
131 def torch_moe_align_block_size(
132 topk_ids: torch.Tensor,
133 block_size: int,
134 num_experts: int,
135 expert_map: torch.Tensor | None = None,
136 pad_sorted_ids: bool = False,
137 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
138 """Golden torch implementation of moe_align_block_size.
139
140 This function aligns the token distribution across experts to be compatible
141 with block size for matrix multiplication by sorting tokens by expert and
142 padding to block boundaries.
143 """
144 max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
145 if pad_sorted_ids:
146 max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
147 if topk_ids.numel() < num_experts:
148 max_num_tokens_padded = topk_ids.numel() * block_size
149
150 flattened_token_indices = torch.arange(
151 topk_ids.numel(), device=topk_ids.device, dtype=torch.int32
152 )
153 flattened_expert_ids = topk_ids.flatten()
154 sorted_expert_ids, sort_indices = torch.sort(flattened_expert_ids, stable=True)
155 sorted_token_indices = flattened_token_indices[sort_indices]
156
157 expert_token_counts = torch.zeros(
158 num_experts, dtype=torch.int64, device=topk_ids.device
159 )
160 for expert_id in range(num_experts):
161 mask = sorted_expert_ids == expert_id
162 expert_token_counts[expert_id] = mask.sum()
163
164 expert_padded_counts = torch.zeros(
165 num_experts, dtype=torch.int64, device=topk_ids.device
166 )
167 for expert_id in range(num_experts):
168 original_count = expert_token_counts[expert_id]
169 if expert_map is not None and expert_map[expert_id] == -1:
170 continue
171 if original_count > 0:
172 expert_padded_counts[expert_id] = (
173 (original_count + block_size - 1) // block_size
174 ) * block_size
175
176 sorted_token_ids = torch.full(
177 (max_num_tokens_padded,),
178 topk_ids.numel(),
179 dtype=torch.int32,
180 device=topk_ids.device,
181 )
182 max_num_blocks = (max_num_tokens_padded + block_size - 1) // block_size
183 expert_ids = torch.full(
184 (max_num_blocks,), -1, dtype=torch.int32, device=topk_ids.device
185 )
186
187 current_pos = 0
188 current_block = 0
189 for expert_id in range(num_experts):
190 if expert_map is not None and expert_map[expert_id] == -1:
191 continue
192
193 expert_mask = sorted_expert_ids == expert_id
194 expert_tokens = sorted_token_indices[expert_mask]
195 num_expert_tokens = expert_tokens.shape[0]
196
197 if num_expert_tokens > 0:
198 sorted_token_ids[current_pos : current_pos + num_expert_tokens] = (
199 expert_tokens
200 )
201
202 expert_blocks_needed = expert_padded_counts[expert_id] // block_size
203
204 expert_id_new = expert_id
205 if expert_map is not None:
206 expert_id_new = expert_map[expert_id]
207 expert_ids[current_block : current_block + expert_blocks_needed] = (
208 expert_id_new
209 )
210
211 current_pos += expert_padded_counts[expert_id]
212 current_block += expert_blocks_needed
213
214 total_padded_tokens = expert_padded_counts.sum()
215 num_tokens_post_pad = torch.tensor(
216 [total_padded_tokens], dtype=torch.int32, device=topk_ids.device
217 )
218
219 return sorted_token_ids, expert_ids, num_tokens_post_pad
220
221
222 @pytest.mark.parametrize("m", NUM_TOKENS)
223 @pytest.mark.parametrize("topk", TOP_KS)
224 @pytest.mark.parametrize("num_experts", NUM_EXPERTS)
225 @pytest.mark.parametrize("block_size", BLOCK_SIZES)
226 @pytest.mark.parametrize("pad_sorted_ids", [False, True])
227 def test_moe_align_block_size(
228 m: int, topk: int, num_experts: int, block_size: int, pad_sorted_ids: bool
229 ):
230 """Test moe_align_block_size without expert mapping."""
231 topk_ids = torch.zeros((m, topk), device="cuda", dtype=torch.int32)
232 for i in range(m):
233 experts = torch.randperm(num_experts, device="cuda")[:topk]
234 topk_ids[i] = experts
235
236 actual_sorted_ids, actual_expert_ids, actual_num_tokens = moe_align_block_size(
237 topk_ids=topk_ids,
238 block_size=block_size,
239 num_experts=num_experts,
240 pad_sorted_ids=pad_sorted_ids,
241 )
242 golden_sorted_ids, golden_expert_ids, golden_num_tokens = (
243 torch_moe_align_block_size(
244 topk_ids=topk_ids,
245 block_size=block_size,
246 num_experts=num_experts,
247 pad_sorted_ids=pad_sorted_ids,
248 )
249 )
250
251 torch.testing.assert_close(actual_num_tokens, golden_num_tokens, atol=0, rtol=0)
252 torch.testing.assert_close(actual_expert_ids, golden_expert_ids, atol=0, rtol=0)
253
254 # For sorted_token_ids, verify block-level correctness rather than exact
255 # order Tokens within each expert's blocks can be in any order, but expert
256 # regions must be correct
257 _verify_expert_level_sorting(
258 actual_sorted_ids,
259 golden_sorted_ids,
260 actual_expert_ids,
261 block_size,
262 actual_num_tokens.item(),
263 m * topk,
264 )
265
266 total_tokens = m * topk
267 assert actual_num_tokens.item() % block_size == 0, (
268 "num_tokens_post_pad should be divisible by block_size"
269 )
270 assert actual_num_tokens.item() >= total_tokens, (
271 "num_tokens_post_pad should be at least total_tokens"
272 )
273 valid_tokens = actual_sorted_ids[actual_sorted_ids < total_tokens]
274 assert len(valid_tokens) == total_tokens, (
275 f"Should have exactly {total_tokens} valid tokens, got {len(valid_tokens)}"
276 )
277 actual_num_blocks = cdiv(int(actual_num_tokens.item()), block_size)
278 assert (actual_expert_ids[:actual_num_blocks] >= 0).all() and (
279 actual_expert_ids[:actual_num_blocks] < num_experts
280 ).all(), "expert_ids should contain valid expert indices"
281
282
283 @pytest.mark.parametrize("m", [16, 32, 2048])
284 @pytest.mark.parametrize("topk", [2, 4])
285 @pytest.mark.parametrize("num_experts", [8, 64])
286 @pytest.mark.parametrize("block_size", [64])
287 @pytest.mark.parametrize("mask_inactive_experts", [False, True])
288 def test_moe_align_block_size_with_expert_map(
289 m: int,
290 topk: int,
291 num_experts: int,
292 block_size: int,
293 mask_inactive_experts: bool,
294 ):
295 """Test moe_align_block_size with expert mapping (EP scenario)."""
296 expert_map = torch.full((num_experts,), -1, device="cuda", dtype=torch.int32)
297 local_experts = list(range(0, num_experts, 2))
298 for i, expert_id in enumerate(local_experts):
299 expert_map[expert_id] = i
300
301 topk_ids = torch.empty((m, topk), device="cuda", dtype=torch.int32)
302 for i in range(m):
303 experts = torch.randperm(num_experts, device="cuda")[:topk]
304 for k in range(topk):
305 topk_ids[i, k] = (
306 experts[k]
307 if (experts[k] in local_experts) or not mask_inactive_experts
308 else -1
309 )
310 topk_ids[0, 0] = -1
311
312 actual_sorted_ids, actual_expert_ids, actual_num_tokens = moe_align_block_size(
313 topk_ids=topk_ids,
314 block_size=block_size,
315 num_experts=num_experts,
316 expert_map=expert_map,
317 ignore_invalid_experts=True,
318 )
319 golden_sorted_ids, golden_expert_ids, golden_num_tokens = (
320 torch_moe_align_block_size(
321 topk_ids=topk_ids,
322 block_size=block_size,
323 num_experts=num_experts,
324 expert_map=expert_map,
325 )
326 )
327
328 torch.testing.assert_close(actual_num_tokens, golden_num_tokens, atol=0, rtol=0)
329 torch.testing.assert_close(actual_expert_ids, golden_expert_ids, atol=0, rtol=0)
330 _verify_expert_level_sorting(
331 actual_sorted_ids,
332 golden_sorted_ids,
333 actual_expert_ids,
334 block_size,
335 actual_num_tokens.item(),
336 m * topk,
337 )
338
339
340 def test_moe_align_block_size_deterministic():
341 m, topk, num_experts, block_size = 128, 2, 32, 64
342
343 torch.manual_seed(42)
344 topk_ids = torch.randint(
345 0, num_experts, (m, topk), device="cuda", dtype=torch.int32
346 )
347
348 # expect the results to be reproducible
349 results = []
350 for _ in range(5):
351 sorted_ids, expert_ids, num_tokens = moe_align_block_size(
352 topk_ids=topk_ids, block_size=block_size, num_experts=num_experts
353 )
354 results.append((sorted_ids.clone(), expert_ids.clone(), num_tokens.clone()))
355
356 for i in range(1, len(results)):
357 assert torch.equal(results[0][0], results[i][0]), (
358 "sorted_ids should be deterministic"
359 )
360 assert torch.equal(results[0][1], results[i][1]), (
361 "expert_ids should be deterministic"
362 )
363 assert torch.equal(results[0][2], results[i][2]), (
364 "num_tokens should be deterministic"
365 )
366
367
368 @pytest.mark.parametrize("max_tokens_per_batch", [13, 16, 512])
369 @pytest.mark.parametrize("num_experts", [8, 16, 32, 64])
370 @pytest.mark.parametrize("block_size", [8, 16, 32, 48, 64])
371 @pytest.mark.parametrize(
372 "token_distribution", ["random", "alternating_empty", "skewed"]
373 )
374 def test_batched_moe_align_block_size(
375 max_tokens_per_batch: int,
376 num_experts: int,
377 block_size: int,
378 token_distribution: str,
379 ):
380 def ref_outputs(
381 expert_num_tokens: torch.Tensor,
382 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
383 E = expert_num_tokens.size(0)
384
385 # Round up so each batch can be split to blocks evenly.
386 Msum = round_up(max_tokens_per_batch, block_size) * E
387 ref_sorted_ids = torch.empty((Msum,), dtype=torch.int32)
388 ref_expert_ids = torch.empty((Msum // block_size,), dtype=torch.int32)
389 ref_num_tokens_post_pad = torch.empty((1,), dtype=torch.int32)
390
391 # Initialize
392 sentinel = E * max_tokens_per_batch
393 ref_sorted_ids.fill_(sentinel)
394 ref_expert_ids.fill_(-1)
395
396 # Fill ref_sorted_ids
397 i = 0
398 for expert_id, expert_nt in enumerate(expert_num_tokens):
399 token_offset = expert_id * max_tokens_per_batch
400 for j in range(expert_nt):
401 ref_sorted_ids[i] = token_offset + j
402 i += 1
403 # round up i to the next block_size
404 i = round_up(i, block_size)
405
406 ref_num_tokens_post_pad[0] = i
407
408 # Fill expert_ids
409 nt_ceil_sum = 0
410 for expert_id, expert_nt in enumerate(expert_num_tokens):
411 expert_ids_offset = nt_ceil_sum // block_size
412 ceil_expert_nt = round_up(int(expert_nt.item()), block_size)
413 num_blocks = ceil_expert_nt // block_size
414 for x in range(num_blocks):
415 ref_expert_ids[expert_ids_offset + x] = expert_id
416 nt_ceil_sum += ceil_expert_nt
417
418 return (
419 ref_sorted_ids.to("cuda"),
420 ref_expert_ids.to("cuda"),
421 ref_num_tokens_post_pad.to("cuda"),
422 )
423
424 # Compute expert_num_tokens
425 expert_num_tokens = torch.randint(
426 low=0,
427 high=max_tokens_per_batch + 1,
428 size=(num_experts,),
429 device="cpu",
430 dtype=torch.int32,
431 )
432 if token_distribution == "alternating_empty":
433 expert_num_tokens[::2] = 0
434 elif token_distribution == "skewed":
435 expert_num_tokens.zero_()
436 expert_num_tokens[0] = max_tokens_per_batch
437 expert_num_tokens[-1] = 1
438
439 # ref outputs
440 ref_sorted_ids, ref_expert_ids, ref_num_tokens_post_pad = ref_outputs(
441 expert_num_tokens
442 )
443
444 # outputs
445 sorted_ids, expert_ids, num_tokens_post_pad = batched_moe_align_block_size(
446 max_tokens_per_batch, block_size, expert_num_tokens.to("cuda")
447 )
448
449 assert ref_sorted_ids.size() == sorted_ids.size(), (
450 f"{ref_sorted_ids.size()} vs {sorted_ids.size()}"
451 )
452 assert ref_expert_ids.size() == expert_ids.size(), (
453 f"{ref_expert_ids.size()} vs {expert_ids.size()}"
454 )
455 assert ref_num_tokens_post_pad.size() == num_tokens_post_pad.size(), (
456 f"{ref_num_tokens_post_pad.size()} vs {num_tokens_post_pad.size()}"
457 )
458 torch.testing.assert_close(ref_sorted_ids, sorted_ids, atol=0, rtol=0)
459 torch.testing.assert_close(ref_expert_ids, expert_ids, atol=0, rtol=0)
460 torch.testing.assert_close(
461 ref_num_tokens_post_pad, num_tokens_post_pad, atol=0, rtol=0
462 )