main
py 1,321 lines 37.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import os
4 import random
5
6 import pytest
7 import torch
8
9 from tests.utils import ensure_current_vllm_config, multi_gpu_test
10 from vllm import _custom_ops as ops
11 from vllm.distributed import (
12 init_distributed_environment,
13 initialize_model_parallel,
14 tensor_model_parallel_all_gather,
15 tensor_model_parallel_all_reduce,
16 )
17 from vllm.distributed.parallel_state import (
18 get_tensor_model_parallel_world_size,
19 )
20 from vllm.lora.ops.triton_ops import fused_moe_lora
21 from vllm.platforms import current_platform
22 from vllm.utils.network_utils import get_open_port
23 from vllm.utils.torch_utils import set_random_seed
24
25 # The tensor-parallel cases build their distributed environment inside processes
26 # spawned by torch.multiprocessing.spawn, so this process never has one to tear down.
27 pytestmark = pytest.mark.skip_global_cleanup
28
29
30 @pytest.fixture(autouse=True)
31 def reset_device(reset_default_device):
32 pass
33
34
35 def round_up(x, base):
36 return ((x + base - 1) // base) * base
37
38
39 def CEILDIV(x, y):
40 return (x + y - 1) // y
41
42
43 def assign_loras_to_tokens(num_tokens: int, num_sequences: int, max_loras: int):
44 """Split `num_tokens` into `num_sequences` sequences.
45 Each sequence randomly selects 1 LoRA index from [0, max_loras),
46 and all tokens in that sequence are assigned this LoRA index.
47
48 Args:
49 num_tokens (int): Total number of tokens.
50 num_sequences (int): Number of sequences to split the tokens into.
51 max_loras (int): Total number of available LoRA modules.
52
53 Returns:
54 torch.Tensor: 1D tensor of shape [num_tokens], where each value
55 is the LoRA index assigned to that token.
56
57 """
58 assert num_sequences > 0 and max_loras > 0
59 assert num_tokens >= num_sequences, "num_tokens must be >= num_sequences"
60
61 # Compute token distribution per sequence (distribute remainder evenly)
62 tokens_per_seq = num_tokens // num_sequences
63 remainder = num_tokens % num_sequences
64
65 token_lora_mapping = torch.empty(num_tokens, dtype=torch.int32)
66
67 start = 0
68 for seq_idx in range(num_sequences):
69 # Determine the token range for this sequence
70 end = start + tokens_per_seq + (1 if seq_idx < remainder else 0)
71
72 # Randomly select one LoRA ID for this sequence
73 lora_id = random.randint(0, max_loras - 1)
74
75 # Assign the same LoRA ID to all tokens in this sequence
76 token_lora_mapping[start:end] = lora_id
77
78 start = end
79
80 return token_lora_mapping
81
82
83 def assign_experts_to_tokens(num_tokens: int, num_experts: int, top_k_num: int):
84 """For each token, randomly select `top_k_num` distinct experts out of
85 `num_experts`, and assign normalized random weights that sum to 1.
86
87 Args:
88 num_tokens (int): Total number of tokens.
89 num_experts (int): Total number of available experts.
90 top_k_num (int): Number of experts to select per token.
91
92 Returns:
93 expert_indices (torch.Tensor): shape [num_tokens, top_k_num],
94 expert index for each token.
95 expert_weights (torch.Tensor): shape [num_tokens, top_k_num],
96 normalized weights (sum = 1 per row).
97
98 """
99 assert top_k_num <= num_experts, "top_k_num must be <= num_experts"
100
101 # Randomly select top_k_num distinct experts for each token
102 expert_indices = torch.empty((num_tokens, top_k_num), dtype=torch.int32)
103 for i in range(num_tokens):
104 # Randomly choose unique expert indices
105 selected = torch.randperm(num_experts)[:top_k_num]
106 expert_indices[i] = selected
107
108 # Generate random weights and normalize along dim=1
109 expert_weights = torch.rand((num_tokens, top_k_num), dtype=torch.float32)
110 expert_weights = expert_weights / expert_weights.sum(dim=1, keepdim=True)
111
112 return expert_indices, expert_weights
113
114
115 def sample_data(
116 num_tokens: int,
117 num_sequences: int,
118 max_loras: int,
119 num_experts: int,
120 top_k_num: int,
121 ):
122 topk_ids, topk_weights = assign_experts_to_tokens(
123 num_tokens, num_experts, top_k_num
124 )
125 token_lora_mapping = assign_loras_to_tokens(num_tokens, num_sequences, max_loras)
126 active_lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
127 lora_ids = torch.unique(token_lora_mapping, sorted=True)
128 active_lora_ids[: lora_ids.size(0)].copy_(lora_ids, non_blocking=True)
129 return topk_ids, topk_weights, token_lora_mapping, active_lora_ids
130
131
132 def use_fused_moe_lora_kernel(
133 topk_ids,
134 topk_weights,
135 token_lora_mapping,
136 max_lora_rank,
137 top_k_num,
138 lora_ids,
139 lora_a_stacked,
140 lora_b_stacked,
141 hidden_states,
142 output,
143 max_loras,
144 num_experts,
145 block_size,
146 fully_sharded=False,
147 offset=0,
148 add_inputs=True,
149 ):
150 max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
151 max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
152 max_num_m_blocks = CEILDIV(max_num_tokens_padded, block_size)
153
154 # init output tensors
155 sorted_token_ids = torch.empty(
156 (max_loras * max_num_tokens_padded,),
157 dtype=torch.int32,
158 )
159 expert_ids = torch.empty((max_loras * max_num_m_blocks,), dtype=torch.int32)
160 num_tokens_post_padded = torch.empty((max_loras,), dtype=torch.int32)
161 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
162
163 # call kernel
164 ops.moe_lora_align_block_size(
165 topk_ids,
166 token_lora_mapping,
167 num_experts,
168 block_size,
169 max_loras,
170 max_num_tokens_padded,
171 max_num_m_blocks,
172 sorted_token_ids,
173 expert_ids,
174 num_tokens_post_padded,
175 adapter_enabled,
176 lora_ids,
177 )
178
179 config = {
180 "BLOCK_SIZE_M": block_size,
181 "BLOCK_SIZE_N": 32,
182 "BLOCK_SIZE_K": 64,
183 "GROUP_SIZE_M": 1,
184 "NUM_WARPS": 4,
185 "NUM_STAGES": 3,
186 "SPLIT_K": 1,
187 }
188
189 mul_routed_weight = False
190 expert_ids = expert_ids.view(max_loras, -1)
191 sorted_token_ids = sorted_token_ids.view(max_loras, -1)
192
193 # num_active_loras is the number of active LoRAs
194 # (max_loras + 1 to include no-lora case)
195 # Stored as CPU tensor to match the kernel API (torch.compile compatibility)
196 num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
197
198 fused_moe_lora(
199 output,
200 hidden_states,
201 lora_a_stacked,
202 lora_b_stacked,
203 topk_weights,
204 sorted_token_ids,
205 expert_ids,
206 num_tokens_post_padded,
207 token_lora_mapping,
208 max_lora_rank,
209 top_k_num,
210 lora_ids,
211 num_active_loras,
212 adapter_enabled,
213 config["BLOCK_SIZE_M"],
214 config["BLOCK_SIZE_N"],
215 config["BLOCK_SIZE_K"],
216 config["GROUP_SIZE_M"],
217 config["NUM_WARPS"],
218 config["NUM_STAGES"],
219 config["SPLIT_K"],
220 config["BLOCK_SIZE_M"],
221 config["BLOCK_SIZE_N"],
222 config["BLOCK_SIZE_K"],
223 config["GROUP_SIZE_M"],
224 config["NUM_WARPS"],
225 config["NUM_STAGES"],
226 config["SPLIT_K"],
227 mul_routed_weight,
228 fully_sharded=fully_sharded,
229 offset=offset,
230 add_inputs=add_inputs,
231 )
232
233
234 def use_torch(
235 hidden_states,
236 token_lora_mapping,
237 topk_ids,
238 lora_a_stacked,
239 lora_b_stacked,
240 top_k_num,
241 num_slices=1,
242 ):
243 outputs = []
244 for i in range(hidden_states.shape[0]):
245 slice_tensors = []
246 for slice_id in range(num_slices):
247 lora_idx = token_lora_mapping[i]
248 expert_ids = topk_ids[i]
249 lora_a = lora_a_stacked[slice_id][lora_idx][expert_ids]
250 lora_b = lora_b_stacked[slice_id][lora_idx][expert_ids]
251 tensors = [
252 hidden_states[i] @ lora_a[x].T @ lora_b[x].T for x in range(top_k_num)
253 ]
254 slice_tensors.append(torch.stack(tensors, dim=0))
255
256 outputs.append(torch.concat(slice_tensors, dim=-1))
257 return torch.stack(outputs, dim=0)
258
259
260 DEVICE_TYPE = current_platform.device_type
261 DTYPES = [torch.float16, torch.bfloat16]
262 DEVICES = [f"{DEVICE_TYPE}:{0}"]
263 SEED = [42]
264
265
266 @pytest.mark.parametrize("num_tokens", [100])
267 @pytest.mark.parametrize("top_k_num", [6, 12])
268 @pytest.mark.parametrize("num_experts", [64])
269 @pytest.mark.parametrize("max_loras", [4, 6, 16])
270 @pytest.mark.parametrize("N", [1408])
271 @pytest.mark.parametrize("K", [2048])
272 @pytest.mark.parametrize("max_lora_rank", [16, 32, 64])
273 @pytest.mark.parametrize("block_size", [16])
274 @pytest.mark.parametrize("num_slices", [1, 2])
275 @pytest.mark.parametrize("dtype", DTYPES)
276 @pytest.mark.parametrize("device", DEVICES)
277 @pytest.mark.parametrize("seed", SEED)
278 def test_fused_moe_lora_kernel(
279 num_tokens,
280 top_k_num,
281 num_experts,
282 max_loras,
283 N,
284 K,
285 max_lora_rank,
286 block_size,
287 num_slices,
288 dtype,
289 device,
290 seed,
291 ):
292 torch.set_default_device(device)
293 set_random_seed(seed)
294 # the number of randomly generated sentences.
295 num_sequences = 10
296 # generate data
297 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
298 num_tokens, num_sequences, max_loras, num_experts, top_k_num
299 )
300
301 # init lora weights
302 lora_a_stacked = [
303 torch.rand(
304 (
305 max_loras,
306 num_experts,
307 max_lora_rank,
308 K,
309 ),
310 dtype=dtype,
311 )
312 for _ in range(num_slices)
313 ]
314 lora_b_stacked = [
315 torch.rand(
316 (
317 max_loras,
318 num_experts,
319 N // num_slices,
320 max_lora_rank,
321 ),
322 dtype=dtype,
323 )
324 for _ in range(num_slices)
325 ]
326 hidden_states = torch.rand(
327 (
328 num_tokens,
329 K,
330 ),
331 dtype=dtype,
332 )
333
334 # fused_moe_lora_kernel output
335 output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
336 use_fused_moe_lora_kernel(
337 topk_ids,
338 topk_weights,
339 token_lora_mapping,
340 max_lora_rank,
341 top_k_num,
342 lora_ids,
343 lora_a_stacked,
344 lora_b_stacked,
345 hidden_states,
346 output,
347 max_loras,
348 num_experts,
349 block_size,
350 )
351 # pytorch output
352 output2 = use_torch(
353 hidden_states,
354 token_lora_mapping,
355 topk_ids,
356 lora_a_stacked,
357 lora_b_stacked,
358 top_k_num,
359 num_slices,
360 )
361
362 torch.testing.assert_close(output, output2, atol=1e-2, rtol=1e-2)
363
364
365 @pytest.mark.parametrize("max_lora_rank", [128, 256])
366 @pytest.mark.parametrize("add_inputs", [True, False])
367 @pytest.mark.parametrize("num_slices", [1, 2])
368 @pytest.mark.parametrize("device", DEVICES)
369 def test_fused_moe_lora_kernel_large_rank_fallback(
370 max_lora_rank,
371 add_inputs,
372 num_slices,
373 device,
374 ):
375 """Ranks above the one-shot limit fall back to shrink/expand kernels."""
376 torch.set_default_device(device)
377 set_random_seed(42)
378
379 num_tokens = 8
380 num_sequences = 4
381 top_k_num = 2
382 num_experts = 4
383 max_loras = 2
384 N = 256
385 K = 256
386 block_size = 16
387 dtype = torch.bfloat16
388
389 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
390 num_tokens, num_sequences, max_loras, num_experts, top_k_num
391 )
392 lora_a_stacked = [
393 torch.rand(
394 (max_loras, num_experts, max_lora_rank, K),
395 dtype=dtype,
396 )
397 for _ in range(num_slices)
398 ]
399 lora_b_stacked = [
400 torch.rand(
401 (max_loras, num_experts, N // num_slices, max_lora_rank),
402 dtype=dtype,
403 )
404 for _ in range(num_slices)
405 ]
406 hidden_states = torch.rand((num_tokens, K), dtype=dtype)
407
408 residual = torch.randn((num_tokens, top_k_num, N), dtype=dtype)
409 output = residual.clone()
410 use_fused_moe_lora_kernel(
411 topk_ids,
412 topk_weights,
413 token_lora_mapping,
414 max_lora_rank,
415 top_k_num,
416 lora_ids,
417 lora_a_stacked,
418 lora_b_stacked,
419 hidden_states,
420 output,
421 max_loras,
422 num_experts,
423 block_size,
424 add_inputs=add_inputs,
425 )
426
427 expected = use_torch(
428 hidden_states,
429 token_lora_mapping,
430 topk_ids,
431 lora_a_stacked,
432 lora_b_stacked,
433 top_k_num,
434 num_slices,
435 )
436 if add_inputs:
437 expected += residual
438
439 torch.testing.assert_close(output, expected, atol=2e-2, rtol=2e-2)
440
441
442 def use_fused_moe_lora_kernel_naive(
443 topk_ids,
444 topk_weights,
445 token_lora_mapping,
446 max_lora_rank,
447 top_k_num,
448 lora_ids,
449 lora_a_stacked,
450 lora_b_stacked,
451 hidden_states,
452 output,
453 max_loras,
454 block_size,
455 fully_sharded=False,
456 offset=0,
457 add_inputs=True,
458 ):
459 """Test helper for naive_block_assignment path.
460 Skips moe_lora_align_block_size and uses flattened topk_ids as expert_ids.
461 """
462 config = {
463 "BLOCK_SIZE_M": block_size,
464 "BLOCK_SIZE_N": 32,
465 "BLOCK_SIZE_K": 64,
466 "GROUP_SIZE_M": 1,
467 "NUM_WARPS": 4,
468 "NUM_STAGES": 3,
469 "SPLIT_K": 1,
470 }
471
472 mul_routed_weight = False
473
474 # In naive mode:
475 # - expert_ids = topk_ids.view(-1), shape: (num_tokens * top_k,)
476 # - sorted_token_ids = None
477 # - num_tokens_post_padded = None
478 expert_ids = topk_ids.reshape(-1)
479 sorted_token_ids = None
480 num_tokens_post_padded = None
481
482 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
483
484 # num_active_loras is the number of active LoRAs
485 # (max_loras + 1 to include no-lora case)
486 # Stored as CPU tensor to match the kernel API (torch.compile compatibility)
487 num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
488
489 fused_moe_lora(
490 output,
491 hidden_states,
492 lora_a_stacked,
493 lora_b_stacked,
494 topk_weights,
495 sorted_token_ids,
496 expert_ids,
497 num_tokens_post_padded,
498 token_lora_mapping,
499 max_lora_rank,
500 top_k_num,
501 lora_ids,
502 num_active_loras,
503 adapter_enabled,
504 config["BLOCK_SIZE_M"],
505 config["BLOCK_SIZE_N"],
506 config["BLOCK_SIZE_K"],
507 config["GROUP_SIZE_M"],
508 config["NUM_WARPS"],
509 config["NUM_STAGES"],
510 config["SPLIT_K"],
511 config["BLOCK_SIZE_M"],
512 config["BLOCK_SIZE_N"],
513 config["BLOCK_SIZE_K"],
514 config["GROUP_SIZE_M"],
515 config["NUM_WARPS"],
516 config["NUM_STAGES"],
517 config["SPLIT_K"],
518 mul_routed_weight=mul_routed_weight,
519 fully_sharded=fully_sharded,
520 offset=offset,
521 add_inputs=add_inputs,
522 )
523
524
525 @pytest.mark.parametrize("num_tokens", [1, 2, 4, 8])
526 @pytest.mark.parametrize("top_k_num", [1, 2])
527 @pytest.mark.parametrize("num_experts", [64, 128])
528 @pytest.mark.parametrize("max_loras", [4, 8])
529 @pytest.mark.parametrize("N", [1408])
530 @pytest.mark.parametrize("K", [2048])
531 @pytest.mark.parametrize("max_lora_rank", [16, 32])
532 @pytest.mark.parametrize("block_size", [16])
533 @pytest.mark.parametrize("num_slices", [1, 2])
534 @pytest.mark.parametrize("dtype", DTYPES)
535 @pytest.mark.parametrize("device", DEVICES)
536 @pytest.mark.parametrize("seed", SEED)
537 def test_fused_moe_lora_kernel_naive_block_assignment(
538 num_tokens,
539 top_k_num,
540 num_experts,
541 max_loras,
542 N,
543 K,
544 max_lora_rank,
545 block_size,
546 num_slices,
547 dtype,
548 device,
549 seed,
550 ):
551 """Test the naive_block_assignment path of the fused_moe_lora kernel.
552 This path is triggered when batch_size * top_k is much smaller than
553 num_experts * max_loras, and skips the moe_lora_align_block_size kernel.
554 """
555 torch.set_default_device(device)
556 set_random_seed(seed)
557
558 # Verify this configuration would trigger naive_block_assignment
559 # (num_tokens * top_k * SPARSITY_FACTOR <= num_experts * max_loras)
560 SPARSITY_FACTOR = 8
561 assert num_tokens * top_k_num * SPARSITY_FACTOR <= num_experts * max_loras, (
562 f"Test configuration doesn't meet naive_block_assignment condition: "
563 f"{num_tokens} * {top_k_num} * {SPARSITY_FACTOR} > {num_experts} * {max_loras}"
564 )
565
566 # the number of randomly generated sentences.
567 num_sequences = min(num_tokens, 4)
568 # generate data
569 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
570 num_tokens, num_sequences, max_loras, num_experts, top_k_num
571 )
572
573 # init lora weights
574 lora_a_stacked = [
575 torch.rand(
576 (
577 max_loras,
578 num_experts,
579 max_lora_rank,
580 K,
581 ),
582 dtype=dtype,
583 )
584 for _ in range(num_slices)
585 ]
586 lora_b_stacked = [
587 torch.rand(
588 (
589 max_loras,
590 num_experts,
591 N // num_slices,
592 max_lora_rank,
593 ),
594 dtype=dtype,
595 )
596 for _ in range(num_slices)
597 ]
598 hidden_states = torch.rand(
599 (
600 num_tokens,
601 K,
602 ),
603 dtype=dtype,
604 )
605
606 # fused_moe_lora_kernel output (naive path)
607 output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
608 use_fused_moe_lora_kernel_naive(
609 topk_ids,
610 topk_weights,
611 token_lora_mapping,
612 max_lora_rank,
613 top_k_num,
614 lora_ids,
615 lora_a_stacked,
616 lora_b_stacked,
617 hidden_states,
618 output,
619 max_loras,
620 block_size,
621 )
622
623 # pytorch reference output
624 output_ref = use_torch(
625 hidden_states,
626 token_lora_mapping,
627 topk_ids,
628 lora_a_stacked,
629 lora_b_stacked,
630 top_k_num,
631 num_slices,
632 )
633
634 torch.testing.assert_close(output, output_ref, atol=1e-2, rtol=1e-2)
635
636
637 @multi_gpu_test(num_gpus=2)
638 @pytest.mark.parametrize("num_tokens", [100])
639 @pytest.mark.parametrize("top_k_num", [6])
640 @pytest.mark.parametrize("num_experts", [64])
641 @pytest.mark.parametrize("max_loras", [4])
642 @pytest.mark.parametrize("N", [1408])
643 @pytest.mark.parametrize("K", [2048])
644 @pytest.mark.parametrize("max_lora_rank", [16, 32, 64])
645 @pytest.mark.parametrize("block_size", [16])
646 @pytest.mark.parametrize("dtype", DTYPES)
647 @pytest.mark.parametrize("seed", SEED)
648 @pytest.mark.parametrize("column_parallel", [True, False])
649 def test_fused_moe_lora_kernel_fully_sharded(
650 num_tokens,
651 top_k_num,
652 num_experts,
653 max_loras,
654 N,
655 K,
656 max_lora_rank,
657 block_size,
658 dtype,
659 seed,
660 column_parallel,
661 ):
662 set_random_seed(seed)
663 # the number of randomly generated sentences.
664 num_sequences = 10
665 # generate data
666 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
667 num_tokens, num_sequences, max_loras, num_experts, top_k_num
668 )
669
670 def run_torch_spawn(fn, nprocs):
671 torch.multiprocessing.spawn(
672 fn,
673 args=(
674 nprocs,
675 f"tcp://{os.getenv('LOCALHOST', 'localhost')}:{get_open_port()}",
676 dtype,
677 seed,
678 N,
679 K,
680 num_tokens,
681 topk_ids,
682 topk_weights,
683 token_lora_mapping,
684 max_lora_rank,
685 top_k_num,
686 lora_ids,
687 max_loras,
688 num_experts,
689 block_size,
690 column_parallel,
691 ),
692 nprocs=nprocs,
693 )
694
695 run_torch_spawn(use_fused_moe_lora_kernel_tensor_parallel, nprocs=2)
696
697
698 def use_fused_moe_lora_kernel_tensor_parallel(
699 local_rank,
700 world_size,
701 init_method,
702 dtype,
703 seed,
704 N,
705 K,
706 num_tokens,
707 topk_ids,
708 topk_weights,
709 token_lora_mapping,
710 max_lora_rank,
711 top_k_num,
712 lora_ids,
713 max_loras,
714 num_experts,
715 block_size,
716 column_parallel,
717 ):
718 def _get_shard_slice(shard_size):
719 return slice(local_rank * shard_size, (local_rank + 1) * shard_size)
720
721 set_random_seed(seed)
722
723 device = torch.device(f"{DEVICE_TYPE}:{local_rank}")
724 torch.accelerator.set_device_index(device)
725 torch.set_default_device(device)
726 torch.set_default_dtype(dtype)
727
728 init_distributed_environment(
729 world_size=world_size,
730 rank=local_rank,
731 local_rank=local_rank,
732 distributed_init_method=init_method,
733 backend=current_platform.dist_backend,
734 )
735 with ensure_current_vllm_config():
736 initialize_model_parallel(world_size, 1)
737 tp_size = get_tensor_model_parallel_world_size()
738
739 input_dim = K if column_parallel else N
740 output_dim = N if column_parallel else K
741
742 # init lora weights
743 lora_a = torch.rand(
744 (
745 max_loras,
746 num_experts,
747 max_lora_rank,
748 input_dim,
749 ),
750 dtype=dtype,
751 )
752 lora_b = torch.rand(
753 (
754 max_loras,
755 num_experts,
756 output_dim,
757 max_lora_rank,
758 ),
759 dtype=dtype,
760 )
761
762 hidden_states = torch.rand(
763 (
764 num_tokens,
765 input_dim,
766 ),
767 dtype=dtype,
768 )
769
770 output = torch.zeros((num_tokens, top_k_num, output_dim), dtype=dtype)
771 topk_ids = topk_ids.to(device)
772 topk_weights = topk_weights.to(device)
773 token_lora_mapping = token_lora_mapping.to(device)
774 lora_ids = lora_ids.to(device)
775
776 ref_output = use_torch(
777 hidden_states,
778 token_lora_mapping,
779 topk_ids,
780 [lora_a],
781 [lora_b],
782 top_k_num,
783 )
784
785 if column_parallel:
786 # Column parallel (e.g. gate_up_proj): LoRA A is sliced along the rank dim,
787 # and Lora B is sliced along the output dim
788 lora_a_shard_size = max_lora_rank // tp_size
789 lora_a = lora_a[:, :, _get_shard_slice(lora_a_shard_size), :]
790 max_lora_rank = lora_a_shard_size
791 offset = 0
792
793 lora_b_shard_size = output_dim // tp_size
794 lora_b = lora_b[:, :, _get_shard_slice(lora_b_shard_size), :]
795 output = output[:, :, _get_shard_slice(lora_b_shard_size)].contiguous()
796 else:
797 # Row parallel (e.g. down proj): LoRA A is sliced along the input dim,
798 # and LoRA B is sliced along the output dim
799 lora_a_shard_size = input_dim // tp_size
800 lora_a = lora_a[:, :, :, _get_shard_slice(lora_a_shard_size)]
801 hidden_states = hidden_states[:, _get_shard_slice(lora_a_shard_size)]
802
803 lora_b_shard_size = output_dim // tp_size
804 lora_b = lora_b[:, :, _get_shard_slice(lora_b_shard_size), :]
805 offset = lora_b_shard_size * local_rank
806
807 use_fused_moe_lora_kernel(
808 topk_ids,
809 topk_weights,
810 token_lora_mapping,
811 max_lora_rank,
812 top_k_num,
813 lora_ids,
814 [lora_a],
815 [lora_b],
816 hidden_states,
817 output,
818 max_loras,
819 num_experts,
820 block_size,
821 fully_sharded=True,
822 offset=offset,
823 )
824
825 if column_parallel:
826 output = tensor_model_parallel_all_gather(output)
827 else:
828 output = tensor_model_parallel_all_reduce(output)
829
830 torch.testing.assert_close(output, ref_output, atol=1e-2, rtol=1e-2)
831
832
833 # -- one-shot fast-path coverage --------------------------------------------
834 # The fused shrink+expand one-shot kernel pads `BLOCK_R` to next_pow2(rank),
835 # with a floor of 16 (tensor-core minimum). Small ranks (4, 8) exercise the
836 # rank-dim masking and are not covered by the original tests, which start at
837 # rank=16. The legacy two-kernel path additionally fails on rank=4 in TMA
838 # mode because the rank-dim stride (rank * elem_size) is not 16-byte
839 # aligned; the one-shot fast path takes precedence whenever fully_sharded
840 # is False so this regression is hidden in normal use, but the test still
841 # ensures the one-shot logic is correct against the pytorch reference.
842
843
844 @pytest.mark.parametrize("num_tokens", [16, 100])
845 @pytest.mark.parametrize("top_k_num", [2])
846 @pytest.mark.parametrize("num_experts", [8, 64])
847 @pytest.mark.parametrize("max_loras", [4])
848 @pytest.mark.parametrize("N", [1408])
849 @pytest.mark.parametrize("K", [2048])
850 @pytest.mark.parametrize("max_lora_rank", [4, 8])
851 @pytest.mark.parametrize("block_size", [16, 64])
852 @pytest.mark.parametrize("num_slices", [1, 2])
853 @pytest.mark.parametrize("dtype", DTYPES)
854 @pytest.mark.parametrize("device", DEVICES)
855 @pytest.mark.parametrize("seed", SEED)
856 def test_fused_moe_lora_kernel_small_rank(
857 num_tokens,
858 top_k_num,
859 num_experts,
860 max_loras,
861 N,
862 K,
863 max_lora_rank,
864 block_size,
865 num_slices,
866 dtype,
867 device,
868 seed,
869 ):
870 """One-shot fast path covering rank<16 (padded to BLOCK_R=16 inside kernel)."""
871 torch.set_default_device(device)
872 set_random_seed(seed)
873 num_sequences = max(1, min(num_tokens, 8))
874 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
875 num_tokens, num_sequences, max_loras, num_experts, top_k_num
876 )
877
878 lora_a_stacked = [
879 torch.rand(
880 (max_loras, num_experts, max_lora_rank, K),
881 dtype=dtype,
882 )
883 for _ in range(num_slices)
884 ]
885 lora_b_stacked = [
886 torch.rand(
887 (max_loras, num_experts, N // num_slices, max_lora_rank),
888 dtype=dtype,
889 )
890 for _ in range(num_slices)
891 ]
892 hidden_states = torch.rand((num_tokens, K), dtype=dtype)
893
894 output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
895 use_fused_moe_lora_kernel(
896 topk_ids,
897 topk_weights,
898 token_lora_mapping,
899 max_lora_rank,
900 top_k_num,
901 lora_ids,
902 lora_a_stacked,
903 lora_b_stacked,
904 hidden_states,
905 output,
906 max_loras,
907 num_experts,
908 block_size,
909 )
910 output_ref = use_torch(
911 hidden_states,
912 token_lora_mapping,
913 topk_ids,
914 lora_a_stacked,
915 lora_b_stacked,
916 top_k_num,
917 num_slices,
918 )
919 torch.testing.assert_close(output, output_ref, atol=1e-2, rtol=1e-2)
920
921
922 @pytest.mark.parametrize("num_tokens", [16, 64])
923 @pytest.mark.parametrize("top_k_num", [2])
924 @pytest.mark.parametrize("num_experts", [8])
925 @pytest.mark.parametrize("max_loras", [4])
926 @pytest.mark.parametrize("N", [2048])
927 @pytest.mark.parametrize("K", [4096])
928 @pytest.mark.parametrize("max_lora_rank", [8, 16, 32, 64])
929 @pytest.mark.parametrize("block_size", [64])
930 @pytest.mark.parametrize("num_slices", [2])
931 @pytest.mark.parametrize("dtype", [torch.bfloat16])
932 @pytest.mark.parametrize("device", DEVICES)
933 @pytest.mark.parametrize("seed", SEED)
934 def test_fused_moe_lora_kernel_npid_path(
935 num_tokens,
936 top_k_num,
937 num_experts,
938 max_loras,
939 N,
940 K,
941 max_lora_rank,
942 block_size,
943 num_slices,
944 dtype,
945 device,
946 seed,
947 ):
948 """Exercise the small-batch / NPID > 1 branch of the one-shot fast path.
949
950 With these sizes the one-shot wrapper computes NPID_FACTOR > 1 (base CTA count
951 < SM count), so each program covers only an outer chunk of N. The
952 cross-outer-block write mask is the correctness-critical bit.
953 """
954 torch.set_default_device(device)
955 set_random_seed(seed)
956 num_sequences = max(1, min(num_tokens, 4))
957 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
958 num_tokens, num_sequences, max_loras, num_experts, top_k_num
959 )
960
961 lora_a_stacked = [
962 torch.rand(
963 (max_loras, num_experts, max_lora_rank, K),
964 dtype=dtype,
965 )
966 for _ in range(num_slices)
967 ]
968 lora_b_stacked = [
969 torch.rand(
970 (max_loras, num_experts, N // num_slices, max_lora_rank),
971 dtype=dtype,
972 )
973 for _ in range(num_slices)
974 ]
975 hidden_states = torch.rand((num_tokens, K), dtype=dtype)
976
977 output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
978 use_fused_moe_lora_kernel(
979 topk_ids,
980 topk_weights,
981 token_lora_mapping,
982 max_lora_rank,
983 top_k_num,
984 lora_ids,
985 lora_a_stacked,
986 lora_b_stacked,
987 hidden_states,
988 output,
989 max_loras,
990 num_experts,
991 block_size,
992 )
993 output_ref = use_torch(
994 hidden_states,
995 token_lora_mapping,
996 topk_ids,
997 lora_a_stacked,
998 lora_b_stacked,
999 top_k_num,
1000 num_slices,
1001 )
1002 torch.testing.assert_close(output, output_ref, atol=2e-2, rtol=2e-2)
1003
1004
1005 # -- one-shot corner-case coverage ------------------------------------------
1006 # Each of the following exercises a path where the kernel is launched but
1007 # every program early-exits, leaving the output unchanged. The contract is
1008 # additive (`output += contribution`), so an empty contribution must leave
1009 # the input residual untouched.
1010
1011
1012 def _build_one_shot_inputs(
1013 num_tokens,
1014 top_k_num,
1015 num_experts,
1016 max_loras,
1017 max_lora_rank,
1018 K,
1019 N,
1020 num_slices,
1021 block_size,
1022 dtype,
1023 ):
1024 """Common scaffolding for the corner-case tests below."""
1025 num_sequences = max(1, min(num_tokens, 4)) if num_tokens > 0 else 1
1026 if num_tokens > 0:
1027 topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
1028 num_tokens, num_sequences, max_loras, num_experts, top_k_num
1029 )
1030 else:
1031 # M=0 path: caller may still hand us empty tensors with the right shape.
1032 topk_ids = torch.empty((0, top_k_num), dtype=torch.int32)
1033 topk_weights = torch.empty((0, top_k_num), dtype=torch.float32)
1034 token_lora_mapping = torch.empty((0,), dtype=torch.int32)
1035 lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
1036
1037 lora_a_stacked = [
1038 torch.rand((max_loras, num_experts, max_lora_rank, K), dtype=dtype)
1039 for _ in range(num_slices)
1040 ]
1041 lora_b_stacked = [
1042 torch.rand(
1043 (max_loras, num_experts, N // num_slices, max_lora_rank), dtype=dtype
1044 )
1045 for _ in range(num_slices)
1046 ]
1047 hidden_states = torch.rand((max(num_tokens, 0), K), dtype=dtype)
1048 return (
1049 topk_ids,
1050 topk_weights.to(dtype),
1051 token_lora_mapping,
1052 lora_ids,
1053 lora_a_stacked,
1054 lora_b_stacked,
1055 hidden_states,
1056 )
1057
1058
1059 def _call_one_shot(
1060 output,
1061 hidden_states,
1062 lora_a_stacked,
1063 lora_b_stacked,
1064 topk_weights,
1065 sorted_token_ids,
1066 expert_ids,
1067 num_tokens_post_padded,
1068 token_lora_mapping,
1069 max_lora_rank,
1070 top_k_num,
1071 lora_ids,
1072 num_active_loras,
1073 adapter_enabled,
1074 block_size,
1075 add_inputs=True,
1076 ):
1077 """Direct call into fused_moe_lora with one-shot-routed defaults."""
1078 from vllm.lora.ops.triton_ops import fused_moe_lora as _op
1079
1080 _op(
1081 output,
1082 hidden_states,
1083 lora_a_stacked,
1084 lora_b_stacked,
1085 topk_weights,
1086 sorted_token_ids,
1087 expert_ids,
1088 num_tokens_post_padded,
1089 token_lora_mapping,
1090 max_lora_rank,
1091 top_k_num,
1092 lora_ids,
1093 num_active_loras,
1094 adapter_enabled,
1095 block_size,
1096 32,
1097 64,
1098 1,
1099 4,
1100 3,
1101 1,
1102 block_size,
1103 32,
1104 64,
1105 1,
1106 4,
1107 3,
1108 1,
1109 False,
1110 False,
1111 0,
1112 add_inputs,
1113 )
1114
1115
1116 @pytest.mark.parametrize(
1117 "trigger",
1118 ["sorted_lora_ids_neg", "naive_mapping_neg", "naive_all_disabled"],
1119 )
1120 @pytest.mark.parametrize("device", DEVICES)
1121 def test_fused_moe_lora_kernel_one_shot_early_exit(trigger, device):
1122 """one-shot must leave the residual byte-identical when every program
1123 must early-exit. Three trigger conditions are covered:
1124
1125 - "sorted_lora_ids_neg": sorted path, lora_ids all -1 (lora_id<0 check)
1126 - "naive_mapping_neg": naive path, token_lora_mapping all -1
1127 - "naive_all_disabled": naive path, adapter_enabled all 0
1128 """
1129 torch.set_default_device(device)
1130 set_random_seed(0)
1131
1132 # Per-trigger shapes: naive_mapping_neg needs the naive dispatch gate
1133 # `num_tokens*top_k*8 <= num_experts*max_loras` to hold, hence the
1134 # larger E/max_loras and smaller num_tokens.
1135 if trigger == "naive_mapping_neg":
1136 num_tokens, top_k, E, max_loras, R = 8, 2, 64, 8, 16
1137 elif trigger == "naive_all_disabled":
1138 num_tokens, top_k, E, max_loras, R = 32, 2, 8, 4, 32
1139 else: # sorted_lora_ids_neg
1140 num_tokens, top_k, E, max_loras, R = 32, 2, 8, 4, 16
1141 K, N = 1024, 1024
1142 block_size, num_slices, dtype = 16, 2, torch.bfloat16
1143
1144 (
1145 topk_ids,
1146 topk_weights,
1147 token_lora_mapping,
1148 lora_ids,
1149 lora_a_stacked,
1150 lora_b_stacked,
1151 hidden_states,
1152 ) = _build_one_shot_inputs(
1153 num_tokens, top_k, E, max_loras, R, K, N, num_slices, block_size, dtype
1154 )
1155
1156 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1157 num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
1158
1159 if trigger == "sorted_lora_ids_neg":
1160 lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
1161 max_pad = topk_ids.numel() + E * (block_size - 1)
1162 max_pad = round_up(max_pad, block_size)
1163 max_blocks = CEILDIV(max_pad, block_size)
1164 sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1165 expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1166 num_post = torch.zeros((max_loras,), dtype=torch.int32)
1167 else:
1168 sorted_token_ids = None
1169 expert_ids = topk_ids.reshape(-1).contiguous()
1170 num_post = None
1171 if trigger == "naive_mapping_neg":
1172 token_lora_mapping = torch.full((num_tokens,), -1, dtype=torch.int32)
1173 lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
1174 else: # naive_all_disabled
1175 adapter_enabled = torch.zeros(max_loras + 1, dtype=torch.int32)
1176
1177 residual = torch.randn((num_tokens, top_k, N), dtype=dtype) * 0.1
1178 output = residual.clone()
1179
1180 _call_one_shot(
1181 output,
1182 hidden_states,
1183 lora_a_stacked,
1184 lora_b_stacked,
1185 topk_weights,
1186 sorted_token_ids,
1187 expert_ids,
1188 num_post,
1189 token_lora_mapping,
1190 R,
1191 top_k,
1192 lora_ids,
1193 num_active_loras,
1194 adapter_enabled,
1195 block_size,
1196 )
1197 torch.testing.assert_close(output, residual, atol=0, rtol=0)
1198
1199
1200 @pytest.mark.parametrize("device", DEVICES)
1201 def test_fused_moe_lora_kernel_zero_grid_no_crash(device):
1202 """num_active_loras=0 (or num_slices=0) would otherwise launch a grid
1203 with a zero dimension. one-shot wrapper must short-circuit before launch."""
1204 torch.set_default_device(device)
1205 set_random_seed(0)
1206 num_tokens, top_k, E, max_loras, R, K, N = 8, 2, 8, 4, 16, 1024, 1024
1207 block_size, num_slices, dtype = 16, 2, torch.bfloat16
1208
1209 (
1210 topk_ids,
1211 topk_weights,
1212 token_lora_mapping,
1213 lora_ids,
1214 lora_a_stacked,
1215 lora_b_stacked,
1216 hidden_states,
1217 ) = _build_one_shot_inputs(
1218 num_tokens,
1219 top_k,
1220 E,
1221 max_loras,
1222 R,
1223 K,
1224 N,
1225 num_slices,
1226 block_size,
1227 dtype,
1228 )
1229 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1230 num_active_loras = torch.tensor([0], dtype=torch.int32, device="cpu")
1231 residual = torch.randn((num_tokens, top_k, N), dtype=dtype) * 0.1
1232 output = residual.clone()
1233
1234 # sorted path is the one that uses num_active_loras for grid axis 2
1235 max_pad = topk_ids.numel() + E * (block_size - 1)
1236 max_pad = round_up(max_pad, block_size)
1237 max_blocks = CEILDIV(max_pad, block_size)
1238 sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1239 expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1240 num_post = torch.zeros((max_loras,), dtype=torch.int32)
1241 _call_one_shot(
1242 output,
1243 hidden_states,
1244 lora_a_stacked,
1245 lora_b_stacked,
1246 topk_weights,
1247 sorted_token_ids,
1248 expert_ids,
1249 num_post,
1250 token_lora_mapping,
1251 R,
1252 top_k,
1253 lora_ids,
1254 num_active_loras,
1255 adapter_enabled,
1256 block_size,
1257 )
1258 torch.testing.assert_close(output, residual, atol=0, rtol=0)
1259
1260
1261 @pytest.mark.parametrize("device", DEVICES)
1262 def test_fused_moe_lora_kernel_rejects_bad_block_size_m(device):
1263 """one-shot must surface a clear assertion when shrink_block_size_m is not
1264 a power of 2 / less than 16, instead of the cryptic Triton compile
1265 failure (`arange's range must be a power of 2`)."""
1266 torch.set_default_device(device)
1267 set_random_seed(0)
1268 num_tokens, top_k, E, max_loras, R, K, N = 32, 2, 8, 4, 16, 1024, 1024
1269 num_slices, dtype = 2, torch.bfloat16
1270 block_size = 24 # NOT a power of 2
1271
1272 (
1273 topk_ids,
1274 topk_weights,
1275 token_lora_mapping,
1276 lora_ids,
1277 lora_a_stacked,
1278 lora_b_stacked,
1279 hidden_states,
1280 ) = _build_one_shot_inputs(
1281 num_tokens,
1282 top_k,
1283 E,
1284 max_loras,
1285 R,
1286 K,
1287 N,
1288 num_slices,
1289 16,
1290 dtype,
1291 )
1292 # Build sorted-mode metadata at block_size=16 so shapes are sane,
1293 # but pass block_size=24 to the op (the buggy combination).
1294 max_pad = topk_ids.numel() + E * (16 - 1)
1295 max_pad = round_up(max_pad, 16)
1296 max_blocks = CEILDIV(max_pad, 16)
1297 sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1298 expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1299 num_post = torch.zeros((max_loras,), dtype=torch.int32)
1300 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1301 num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
1302 output = torch.zeros((num_tokens, top_k, N), dtype=dtype)
1303
1304 with pytest.raises(AssertionError, match="shrink_block_size_m"):
1305 _call_one_shot(
1306 output,
1307 hidden_states,
1308 lora_a_stacked,
1309 lora_b_stacked,
1310 topk_weights,
1311 sorted_token_ids,
1312 expert_ids,
1313 num_post,
1314 token_lora_mapping,
1315 R,
1316 top_k,
1317 lora_ids,
1318 num_active_loras,
1319 adapter_enabled,
1320 block_size,
1321 )