@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / ec5de7fa7d
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 2, 20:45 UTC

[LoRA] Add one shot triton kernel For MoE LoRA (#42290)

Signed-off-by: Jee Jee Li <pandaleefree@gmail.com>

Jee Jee Li committed May 26, 2026 at 10:47 UTC ec5de7fa7d1303051ccd6c7316171b6d70bac4b4
14 files changed +1975 -85
benchmarks/kernels/benchmark_fused_moe_lora_one_shot.py new
+465
@@ -0,0 +1,465 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Benchmark the fused MoE-LoRA fast path (one-shot) vs two-kernel baseline.
4 +
5 +The "one_shot" provider goes through `vllm.lora.ops.triton_ops.fused_moe_lora`
6 +which dispatches to the single-kernel one-shot implementation when
7 +fully_sharded=False (the prefill default).
8 +
9 +The "two_kernel" provider drives `fused_moe_lora_shrink` + `fused_moe_lora_expand`
10 +directly, bypassing the dispatch and matching the legacy two-kernel path's
11 +work distribution. This isolates the win from kernel fusion.
12 +
13 +Run:
14 + .venv/bin/python -m benchmarks.kernels.benchmark_fused_moe_lora_one_shot
15 + .venv/bin/python -m benchmarks.kernels.benchmark_fused_moe_lora_one_shot \\
16 + --model qwen3moe
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +import argparse
22 +import os
23 +import random
24 +
25 +import torch
26 +
27 +from vllm import _custom_ops as ops
28 +from vllm.lora.ops.triton_ops import (
29 + fused_moe_lora,
30 + fused_moe_lora_expand,
31 + fused_moe_lora_shrink,
32 +)
33 +from vllm.triton_utils import triton
34 +
35 +DTYPE = torch.bfloat16
36 +DEVICE = "cuda"
37 +
38 +
39 +# ----- input fabrication -----------------------------------------------------
40 +
41 +
42 +def _round_up(x: int, base: int) -> int:
43 + return ((x + base - 1) // base) * base
44 +
45 +
46 +def _ceildiv(x: int, y: int) -> int:
47 + return (x + y - 1) // y
48 +
49 +
50 +def _assign_loras(num_tokens: int, num_sequences: int, max_loras: int) -> torch.Tensor:
51 + tokens_per_seq = num_tokens // num_sequences
52 + rem = num_tokens % num_sequences
53 + out = torch.empty(num_tokens, dtype=torch.int32)
54 + start = 0
55 + for i in range(num_sequences):
56 + end = start + tokens_per_seq + (1 if i < rem else 0)
57 + out[start:end] = random.randint(0, max_loras - 1)
58 + start = end
59 + return out
60 +
61 +
62 +def _assign_experts(num_tokens: int, num_experts: int, top_k: int):
63 + expert_indices = torch.empty((num_tokens, top_k), dtype=torch.int32)
64 + for i in range(num_tokens):
65 + expert_indices[i] = torch.randperm(num_experts)[:top_k]
66 + weights = torch.rand((num_tokens, top_k), dtype=torch.float32)
67 + weights = weights / weights.sum(dim=1, keepdim=True)
68 + return expert_indices, weights
69 +
70 +
71 +def _make_inputs(
72 + M: int,
73 + K: int,
74 + N_per_slice: int,
75 + rank: int,
76 + num_experts: int,
77 + top_k: int,
78 + max_loras: int,
79 + num_slices: int,
80 + block_size_m: int,
81 +):
82 + """Mirrors the production caller's tensor layout."""
83 + torch.manual_seed(0)
84 + random.seed(0)
85 +
86 + num_sequences = max(1, min(M, 8))
87 + topk_ids_cpu, topk_weights_cpu = _assign_experts(M, num_experts, top_k)
88 + token_lora_cpu = _assign_loras(M, num_sequences, max_loras)
89 + lora_ids_cpu = torch.full((max_loras + 1,), -1, dtype=torch.int32)
90 + uniq = torch.unique(token_lora_cpu, sorted=True)
91 + lora_ids_cpu[: uniq.size(0)].copy_(uniq)
92 +
93 + topk_ids = topk_ids_cpu.to(DEVICE)
94 + topk_weights = topk_weights_cpu.to(device=DEVICE, dtype=DTYPE)
95 + token_lora_mapping = token_lora_cpu.to(DEVICE)
96 + lora_ids = lora_ids_cpu.to(DEVICE)
97 + adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32, device=DEVICE)
98 +
99 + lora_a = [
100 + torch.randn((max_loras, num_experts, rank, K), dtype=DTYPE, device=DEVICE)
101 + / max(K, 1) ** 0.5
102 + for _ in range(num_slices)
103 + ]
104 + lora_b = [
105 + torch.randn(
106 + (max_loras, num_experts, N_per_slice, rank),
107 + dtype=DTYPE,
108 + device=DEVICE,
109 + )
110 + / max(rank, 1) ** 0.5
111 + for _ in range(num_slices)
112 + ]
113 + hidden = torch.randn((M, K), dtype=DTYPE, device=DEVICE)
114 + out_template = torch.zeros(
115 + (M, top_k, num_slices * N_per_slice), dtype=DTYPE, device=DEVICE
116 + )
117 +
118 + # Sorted-path metadata (the prefill default).
119 + max_pad = topk_ids.numel() + num_experts * (block_size_m - 1)
120 + max_pad = _round_up(max_pad, block_size_m)
121 + max_blocks = _ceildiv(max_pad, block_size_m)
122 + sorted_token_ids = torch.empty(
123 + (max_loras * max_pad,), dtype=torch.int32, device=DEVICE
124 + )
125 + expert_ids = torch.empty(
126 + (max_loras * max_blocks,), dtype=torch.int32, device=DEVICE
127 + )
128 + num_post = torch.empty((max_loras,), dtype=torch.int32, device=DEVICE)
129 + ops.moe_lora_align_block_size(
130 + topk_ids,
131 + token_lora_mapping,
132 + num_experts,
133 + block_size_m,
134 + max_loras,
135 + max_pad,
136 + max_blocks,
137 + sorted_token_ids,
138 + expert_ids,
139 + num_post,
140 + adapter_enabled,
141 + lora_ids,
142 + )
143 + expert_ids = expert_ids.view(max_loras, -1).contiguous()
144 + sorted_token_ids = sorted_token_ids.view(max_loras, -1).contiguous()
145 + num_active = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
146 +
147 + return dict(
148 + hidden=hidden,
149 + lora_a=lora_a,
150 + lora_b=lora_b,
151 + topk_weights=topk_weights,
152 + sorted_token_ids=sorted_token_ids,
153 + expert_ids=expert_ids,
154 + num_post=num_post,
155 + token_lora_mapping=token_lora_mapping,
156 + lora_ids=lora_ids,
157 + num_active=num_active,
158 + adapter_enabled=adapter_enabled,
159 + out_template=out_template,
160 + # bookkeeping
161 + M=M,
162 + K=K,
163 + N_per_slice=N_per_slice,
164 + rank=rank,
165 + num_experts=num_experts,
166 + top_k=top_k,
167 + max_loras=max_loras,
168 + num_slices=num_slices,
169 + block_size_m=block_size_m,
170 + )
171 +
172 +
173 +# ----- providers -------------------------------------------------------------
174 +
175 +
176 +def _run_one_shot(inp: dict):
177 + """Drive `fused_moe_lora` with fully_sharded=False -> one-shot fast path."""
178 + out = inp["out_template"].clone()
179 + fused_moe_lora(
180 + out,
181 + inp["hidden"],
182 + inp["lora_a"],
183 + inp["lora_b"],
184 + inp["topk_weights"],
185 + inp["sorted_token_ids"],
186 + inp["expert_ids"],
187 + inp["num_post"],
188 + inp["token_lora_mapping"],
189 + inp["rank"],
190 + inp["top_k"],
191 + inp["lora_ids"],
192 + inp["num_active"],
193 + inp["adapter_enabled"],
194 + inp["block_size_m"],
195 + 64,
196 + 32,
197 + 8,
198 + 4,
199 + 3,
200 + 1,
201 + inp["block_size_m"],
202 + 64,
203 + 32,
204 + 8,
205 + 4,
206 + 3,
207 + 1,
208 + False,
209 + False,
210 + 0,
211 + )
212 + return out
213 +
214 +
215 +def _run_two_kernel(inp: dict):
216 + """Drive `fused_moe_lora_shrink` + `fused_moe_lora_expand` directly,
217 + bypassing the dispatch. Matches the legacy two-kernel work distribution.
218 + """
219 + M = inp["M"]
220 + top_k = inp["top_k"]
221 + rank = inp["rank"]
222 + num_slices = inp["num_slices"]
223 + N_per_slice = inp["N_per_slice"]
224 + K = inp["K"]
225 + num_experts = inp["num_experts"]
226 + block_m = inp["block_size_m"]
227 +
228 + intermediate = torch.zeros((num_slices, M, top_k, rank), dtype=DTYPE, device=DEVICE)
229 + out = inp["out_template"].clone()
230 + EM = inp["sorted_token_ids"].shape[1]
231 + num_tokens = M * top_k
232 +
233 + fused_moe_lora_shrink(
234 + intermediate,
235 + inp["hidden"],
236 + inp["lora_a"],
237 + inp["topk_weights"],
238 + inp["sorted_token_ids"],
239 + inp["expert_ids"],
240 + inp["num_post"],
241 + inp["token_lora_mapping"],
242 + top_k,
243 + inp["lora_ids"],
244 + inp["adapter_enabled"],
245 + torch.device(DEVICE),
246 + rank,
247 + M,
248 + EM,
249 + K,
250 + num_tokens,
251 + num_experts,
252 + num_slices,
253 + block_m,
254 + 64,
255 + 32,
256 + 8,
257 + 4,
258 + 3,
259 + 1,
260 + inp["num_active"],
261 + False,
262 + )
263 + fused_moe_lora_expand(
264 + out,
265 + intermediate,
266 + inp["lora_b"],
267 + inp["topk_weights"],
268 + inp["sorted_token_ids"],
269 + inp["expert_ids"],
270 + inp["num_post"],
271 + inp["token_lora_mapping"],
272 + top_k,
273 + inp["lora_ids"],
274 + inp["adapter_enabled"],
275 + torch.device(DEVICE),
276 + rank,
277 + M,
278 + EM,
279 + K,
280 + num_tokens,
281 + num_experts,
282 + num_slices,
283 + rank,
284 + N_per_slice,
285 + block_m,
286 + 64,
287 + 32,
288 + 8,
289 + 4,
290 + 3,
291 + 1,
292 + inp["num_active"],
293 + False,
294 + 0,
295 + )
296 + return out
297 +
298 +
299 +PROVIDER_FNS = {
300 + "one_shot": _run_one_shot,
301 + "two_kernel": _run_two_kernel,
302 +}
303 +
304 +
305 +# ----- model presets ---------------------------------------------------------
306 +
307 +
308 +MODEL_PRESETS: dict[str, dict] = {
309 + # Mixtral-8x7B style: E=8, top_k=2, hidden=4096, intermediate=14336
310 + "mixtral": dict(
311 + K=4096,
312 + N_per_slice=7168,
313 + num_experts=8,
314 + top_k=2,
315 + max_loras=4,
316 + num_slices=2,
317 + block_size_m=64,
318 + ),
319 + # Qwen3-MoE / DeepSeek-V2 style: E=64, top_k=8, hidden=2048, inter=1408
320 + "qwen3moe": dict(
321 + K=2048,
322 + N_per_slice=1408,
323 + num_experts=64,
324 + top_k=8,
325 + max_loras=4,
326 + num_slices=2,
327 + block_size_m=64,
328 + ),
329 + # GLM-5.1 (zai-org/GLM-5.1-FP8): E=256, top_k=8, hidden=6144,
330 + # moe_intermediate=2048
331 + "glm5_1": dict(
332 + K=6144,
333 + N_per_slice=2048,
334 + num_experts=256,
335 + top_k=8,
336 + max_loras=4,
337 + num_slices=2,
338 + block_size_m=64,
339 + ),
340 +}
341 +
342 +
343 +M_RANGE = [16, 64, 256, 1024, 4096, 16384]
344 +RANK_RANGE = [8, 16, 32, 64]
345 +
346 +
347 +def get_benchmark(model: str, max_loras: int | None = None):
348 + preset = dict(MODEL_PRESETS[model])
349 + if max_loras is not None:
350 + preset["max_loras"] = max_loras
351 +
352 + @triton.testing.perf_report(
353 + triton.testing.Benchmark(
354 + x_names=["M", "rank"],
355 + x_vals=[(M, R) for M in M_RANGE for R in RANK_RANGE],
356 + line_arg="provider",
357 + line_vals=list(PROVIDER_FNS.keys()),
358 + line_names=["one_shot (fused)", "two_kernel (legacy)"],
359 + styles=[("red", "-"), ("blue", "-")],
360 + ylabel="ms",
361 + plot_name=f"fused_moe_lora-{model}-loras{preset['max_loras']}",
362 + args={"preset": preset},
363 + )
364 + )
365 + def benchmark(M, rank, provider, preset):
366 + inp = _make_inputs(
367 + M=M,
368 + K=preset["K"],
369 + N_per_slice=preset["N_per_slice"],
370 + rank=rank,
371 + num_experts=preset["num_experts"],
372 + top_k=preset["top_k"],
373 + max_loras=preset["max_loras"],
374 + num_slices=preset["num_slices"],
375 + block_size_m=preset["block_size_m"],
376 + )
377 + fn = PROVIDER_FNS[provider]
378 + quantiles = [0.5, 0.2, 0.8]
379 + ms, min_ms, max_ms = triton.testing.do_bench(
380 + lambda: fn(inp), quantiles=quantiles
381 + )
382 + return ms, max_ms, min_ms
383 +
384 + return benchmark
385 +
386 +
387 +# ----- correctness sanity ---------------------------------------------------
388 +
389 +
390 +def calculate_diff(model: str, M: int, rank: int, max_loras: int | None = None):
391 + preset = dict(MODEL_PRESETS[model])
392 + if max_loras is not None:
393 + preset["max_loras"] = max_loras
394 + inp = _make_inputs(
395 + M=M,
396 + K=preset["K"],
397 + N_per_slice=preset["N_per_slice"],
398 + rank=rank,
399 + num_experts=preset["num_experts"],
400 + top_k=preset["top_k"],
401 + max_loras=preset["max_loras"],
402 + num_slices=preset["num_slices"],
403 + block_size_m=preset["block_size_m"],
404 + )
405 + out_one = _run_one_shot(inp)
406 + out_two = _run_two_kernel(inp)
407 + max_abs = (out_one.float() - out_two.float()).abs().max().item()
408 + print(
409 + f" model={model:<9} M={M:<6} rank={rank:<3} "
410 + f"max|one_shot - two_kernel|={max_abs:.4g} "
411 + f"ref|max|={out_two.float().abs().max().item():.3g}"
412 + )
413 + if max_abs <= 5e-2:
414 + print(" ✅ outputs match within bf16 tolerance")
415 + else:
416 + print(" ❌ outputs differ beyond expected bf16 noise")
417 +
418 +
419 +# ----- main ------------------------------------------------------------------
420 +
421 +
422 +if __name__ == "__main__":
423 + parser = argparse.ArgumentParser()
424 + parser.add_argument(
425 + "--model",
426 + type=str,
427 + default="mixtral",
428 + choices=list(MODEL_PRESETS.keys()),
429 + help="Model preset to sweep",
430 + )
431 + parser.add_argument(
432 + "--save-path",
433 + type=str,
434 + default="./configs/fused_moe_lora_one_shot/",
435 + help="Directory to save benchmark results",
436 + )
437 + parser.add_argument(
438 + "--check-only",
439 + action="store_true",
440 + help="Run correctness sanity check only, no perf sweep",
441 + )
442 + parser.add_argument(
443 + "--max-loras",
444 + type=int,
445 + default=None,
446 + help="Override max_loras in the model preset (number of LoRA adapters "
447 + "active in the batch). Defaults to the preset's value.",
448 + )
449 + args = parser.parse_args()
450 +
451 + print(f"Correctness check ({args.model}):")
452 + calculate_diff(args.model, M=256, rank=32, max_loras=args.max_loras)
453 + if args.check_only:
454 + raise SystemExit(0)
455 +
456 + effective_max_loras = (
457 + args.max_loras
458 + if args.max_loras is not None
459 + else MODEL_PRESETS[args.model]["max_loras"]
460 + )
461 + print(f"\nGPU: {torch.cuda.get_device_name()}")
462 + print(f"Model preset: {args.model} max_loras={effective_max_loras}\n")
463 + benchmark = get_benchmark(args.model, max_loras=args.max_loras)
464 + os.makedirs(args.save_path, exist_ok=True)
465 + benchmark.run(print_data=True, save_path=args.save_path)
tests/lora/test_fused_moe_lora_kernel.py
+495
@@ -141,6 +141,7 @@ def use_fused_moe_lora_kernel(
141 block_size,
142 fully_sharded=False,
143 offset=0,
144 + add_inputs=True,
145 ):
146 max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
147 max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
@@ -222,6 +223,7 @@ def use_fused_moe_lora_kernel(
223 mul_routed_weight,
224 fully_sharded=fully_sharded,
225 offset=offset,
226 + add_inputs=add_inputs,
227 )
228
229
@@ -371,6 +373,7 @@ def use_fused_moe_lora_kernel_naive(
373 block_size,
374 fully_sharded=False,
375 offset=0,
376 + add_inputs=True,
377 ):
378 """
379 Test helper for naive_block_assignment path.
@@ -435,6 +438,7 @@ def use_fused_moe_lora_kernel_naive(
438 mul_routed_weight=mul_routed_weight,
439 fully_sharded=fully_sharded,
440 offset=offset,
441 + add_inputs=add_inputs,
442 )
443
444
@@ -745,3 +749,494 @@ def use_fused_moe_lora_kernel_tensor_parallel(
749 output = tensor_model_parallel_all_reduce(output)
750
751 torch.testing.assert_close(output, ref_output, atol=1e-2, rtol=1e-2)
752 +
753 +
754 +# -- one-shot fast-path coverage --------------------------------------------
755 +# The fused shrink+expand one-shot kernel pads `BLOCK_R` to next_pow2(rank),
756 +# with a floor of 16 (tensor-core minimum). Small ranks (4, 8) exercise the
757 +# rank-dim masking and are not covered by the original tests, which start at
758 +# rank=16. The legacy two-kernel path additionally fails on rank=4 in TMA
759 +# mode because the rank-dim stride (rank * elem_size) is not 16-byte
760 +# aligned; the one-shot fast path takes precedence whenever fully_sharded
761 +# is False so this regression is hidden in normal use, but the test still
762 +# ensures the one-shot logic is correct against the pytorch reference.
763 +
764 +
765 +@pytest.mark.parametrize("num_tokens", [16, 100])
766 +@pytest.mark.parametrize("top_k_num", [2])
767 +@pytest.mark.parametrize("num_experts", [8, 64])
768 +@pytest.mark.parametrize("max_loras", [4])
769 +@pytest.mark.parametrize("N", [1408])
770 +@pytest.mark.parametrize("K", [2048])
771 +@pytest.mark.parametrize("max_lora_rank", [4, 8])
772 +@pytest.mark.parametrize("block_size", [16, 64])
773 +@pytest.mark.parametrize("num_slices", [1, 2])
774 +@pytest.mark.parametrize("dtype", DTYPES)
775 +@pytest.mark.parametrize("device", DEVICES)
776 +@pytest.mark.parametrize("seed", SEED)
777 +def test_fused_moe_lora_kernel_small_rank(
778 + num_tokens,
779 + top_k_num,
780 + num_experts,
781 + max_loras,
782 + N,
783 + K,
784 + max_lora_rank,
785 + block_size,
786 + num_slices,
787 + dtype,
788 + device,
789 + seed,
790 +):
791 + """One-shot fast path covering rank<16 (padded to BLOCK_R=16 inside kernel)."""
792 + torch.set_default_device(device)
793 + set_random_seed(seed)
794 + num_sequences = max(1, min(num_tokens, 8))
795 + topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
796 + num_tokens, num_sequences, max_loras, num_experts, top_k_num
797 + )
798 +
799 + lora_a_stacked = [
800 + torch.rand(
801 + (max_loras, num_experts, max_lora_rank, K),
802 + dtype=dtype,
803 + )
804 + for _ in range(num_slices)
805 + ]
806 + lora_b_stacked = [
807 + torch.rand(
808 + (max_loras, num_experts, N // num_slices, max_lora_rank),
809 + dtype=dtype,
810 + )
811 + for _ in range(num_slices)
812 + ]
813 + hidden_states = torch.rand((num_tokens, K), dtype=dtype)
814 +
815 + output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
816 + use_fused_moe_lora_kernel(
817 + topk_ids,
818 + topk_weights,
819 + token_lora_mapping,
820 + max_lora_rank,
821 + top_k_num,
822 + lora_ids,
823 + lora_a_stacked,
824 + lora_b_stacked,
825 + hidden_states,
826 + output,
827 + max_loras,
828 + num_experts,
829 + block_size,
830 + )
831 + output_ref = use_torch(
832 + hidden_states,
833 + token_lora_mapping,
834 + topk_ids,
835 + lora_a_stacked,
836 + lora_b_stacked,
837 + top_k_num,
838 + num_slices,
839 + )
840 + torch.testing.assert_close(output, output_ref, atol=1e-2, rtol=1e-2)
841 +
842 +
843 +@pytest.mark.parametrize("num_tokens", [16, 64])
844 +@pytest.mark.parametrize("top_k_num", [2])
845 +@pytest.mark.parametrize("num_experts", [8])
846 +@pytest.mark.parametrize("max_loras", [4])
847 +@pytest.mark.parametrize("N", [2048])
848 +@pytest.mark.parametrize("K", [4096])
849 +@pytest.mark.parametrize("max_lora_rank", [8, 16, 32, 64])
850 +@pytest.mark.parametrize("block_size", [64])
851 +@pytest.mark.parametrize("num_slices", [2])
852 +@pytest.mark.parametrize("dtype", [torch.bfloat16])
853 +@pytest.mark.parametrize("device", DEVICES)
854 +@pytest.mark.parametrize("seed", SEED)
855 +def test_fused_moe_lora_kernel_npid_path(
856 + num_tokens,
857 + top_k_num,
858 + num_experts,
859 + max_loras,
860 + N,
861 + K,
862 + max_lora_rank,
863 + block_size,
864 + num_slices,
865 + dtype,
866 + device,
867 + seed,
868 +):
869 + """Exercise the small-batch / NPID > 1 branch of the one-shot fast path.
870 +
871 + With these sizes the one-shot wrapper computes NPID_FACTOR > 1 (base CTA count
872 + < SM count), so each program covers only an outer chunk of N. The
873 + cross-outer-block write mask is the correctness-critical bit.
874 + """
875 + torch.set_default_device(device)
876 + set_random_seed(seed)
877 + num_sequences = max(1, min(num_tokens, 4))
878 + topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
879 + num_tokens, num_sequences, max_loras, num_experts, top_k_num
880 + )
881 +
882 + lora_a_stacked = [
883 + torch.rand(
884 + (max_loras, num_experts, max_lora_rank, K),
885 + dtype=dtype,
886 + )
887 + for _ in range(num_slices)
888 + ]
889 + lora_b_stacked = [
890 + torch.rand(
891 + (max_loras, num_experts, N // num_slices, max_lora_rank),
892 + dtype=dtype,
893 + )
894 + for _ in range(num_slices)
895 + ]
896 + hidden_states = torch.rand((num_tokens, K), dtype=dtype)
897 +
898 + output = torch.zeros((num_tokens, top_k_num, N), dtype=dtype)
899 + use_fused_moe_lora_kernel(
900 + topk_ids,
901 + topk_weights,
902 + token_lora_mapping,
903 + max_lora_rank,
904 + top_k_num,
905 + lora_ids,
906 + lora_a_stacked,
907 + lora_b_stacked,
908 + hidden_states,
909 + output,
910 + max_loras,
911 + num_experts,
912 + block_size,
913 + )
914 + output_ref = use_torch(
915 + hidden_states,
916 + token_lora_mapping,
917 + topk_ids,
918 + lora_a_stacked,
919 + lora_b_stacked,
920 + top_k_num,
921 + num_slices,
922 + )
923 + torch.testing.assert_close(output, output_ref, atol=2e-2, rtol=2e-2)
924 +
925 +
926 +# -- one-shot corner-case coverage ------------------------------------------
927 +# Each of the following exercises a path where the kernel is launched but
928 +# every program early-exits, leaving the output unchanged. The contract is
929 +# additive (`output += contribution`), so an empty contribution must leave
930 +# the input residual untouched.
931 +
932 +
933 +def _build_one_shot_inputs(
934 + num_tokens,
935 + top_k_num,
936 + num_experts,
937 + max_loras,
938 + max_lora_rank,
939 + K,
940 + N,
941 + num_slices,
942 + block_size,
943 + dtype,
944 +):
945 + """Common scaffolding for the corner-case tests below."""
946 + num_sequences = max(1, min(num_tokens, 4)) if num_tokens > 0 else 1
947 + if num_tokens > 0:
948 + topk_ids, topk_weights, token_lora_mapping, lora_ids = sample_data(
949 + num_tokens, num_sequences, max_loras, num_experts, top_k_num
950 + )
951 + else:
952 + # M=0 path: caller may still hand us empty tensors with the right shape.
953 + topk_ids = torch.empty((0, top_k_num), dtype=torch.int32)
954 + topk_weights = torch.empty((0, top_k_num), dtype=torch.float32)
955 + token_lora_mapping = torch.empty((0,), dtype=torch.int32)
956 + lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
957 +
958 + lora_a_stacked = [
959 + torch.rand((max_loras, num_experts, max_lora_rank, K), dtype=dtype)
960 + for _ in range(num_slices)
961 + ]
962 + lora_b_stacked = [
963 + torch.rand(
964 + (max_loras, num_experts, N // num_slices, max_lora_rank), dtype=dtype
965 + )
966 + for _ in range(num_slices)
967 + ]
968 + hidden_states = torch.rand((max(num_tokens, 0), K), dtype=dtype)
969 + return (
970 + topk_ids,
971 + topk_weights.to(dtype),
972 + token_lora_mapping,
973 + lora_ids,
974 + lora_a_stacked,
975 + lora_b_stacked,
976 + hidden_states,
977 + )
978 +
979 +
980 +def _call_one_shot(
981 + output,
982 + hidden_states,
983 + lora_a_stacked,
984 + lora_b_stacked,
985 + topk_weights,
986 + sorted_token_ids,
987 + expert_ids,
988 + num_tokens_post_padded,
989 + token_lora_mapping,
990 + max_lora_rank,
991 + top_k_num,
992 + lora_ids,
993 + num_active_loras,
994 + adapter_enabled,
995 + block_size,
996 + add_inputs=True,
997 +):
998 + """Direct call into fused_moe_lora with one-shot-routed defaults."""
999 + from vllm.lora.ops.triton_ops import fused_moe_lora as _op
1000 +
1001 + _op(
1002 + output,
1003 + hidden_states,
1004 + lora_a_stacked,
1005 + lora_b_stacked,
1006 + topk_weights,
1007 + sorted_token_ids,
1008 + expert_ids,
1009 + num_tokens_post_padded,
1010 + token_lora_mapping,
1011 + max_lora_rank,
1012 + top_k_num,
1013 + lora_ids,
1014 + num_active_loras,
1015 + adapter_enabled,
1016 + block_size,
1017 + 32,
1018 + 64,
1019 + 1,
1020 + 4,
1021 + 3,
1022 + 1,
1023 + block_size,
1024 + 32,
1025 + 64,
1026 + 1,
1027 + 4,
1028 + 3,
1029 + 1,
1030 + False,
1031 + False,
1032 + 0,
1033 + add_inputs,
1034 + )
1035 +
1036 +
1037 +@pytest.mark.parametrize(
1038 + "trigger",
1039 + ["sorted_lora_ids_neg", "naive_mapping_neg", "naive_all_disabled"],
1040 +)
1041 +@pytest.mark.parametrize("device", DEVICES)
1042 +def test_fused_moe_lora_kernel_one_shot_early_exit(trigger, device):
1043 + """one-shot must leave the residual byte-identical when every program
1044 + must early-exit. Three trigger conditions are covered:
1045 +
1046 + - "sorted_lora_ids_neg": sorted path, lora_ids all -1 (lora_id<0 check)
1047 + - "naive_mapping_neg": naive path, token_lora_mapping all -1
1048 + - "naive_all_disabled": naive path, adapter_enabled all 0
1049 + """
1050 + torch.set_default_device(device)
1051 + set_random_seed(0)
1052 +
1053 + # Per-trigger shapes: naive_mapping_neg needs the naive dispatch gate
1054 + # `num_tokens*top_k*8 <= num_experts*max_loras` to hold, hence the
1055 + # larger E/max_loras and smaller num_tokens.
1056 + if trigger == "naive_mapping_neg":
1057 + num_tokens, top_k, E, max_loras, R = 8, 2, 64, 8, 16
1058 + elif trigger == "naive_all_disabled":
1059 + num_tokens, top_k, E, max_loras, R = 32, 2, 8, 4, 32
1060 + else: # sorted_lora_ids_neg
1061 + num_tokens, top_k, E, max_loras, R = 32, 2, 8, 4, 16
1062 + K, N = 1024, 1024
1063 + block_size, num_slices, dtype = 16, 2, torch.bfloat16
1064 +
1065 + (
1066 + topk_ids,
1067 + topk_weights,
1068 + token_lora_mapping,
1069 + lora_ids,
1070 + lora_a_stacked,
1071 + lora_b_stacked,
1072 + hidden_states,
1073 + ) = _build_one_shot_inputs(
1074 + num_tokens, top_k, E, max_loras, R, K, N, num_slices, block_size, dtype
1075 + )
1076 +
1077 + adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1078 + num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
1079 +
1080 + if trigger == "sorted_lora_ids_neg":
1081 + lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
1082 + max_pad = topk_ids.numel() + E * (block_size - 1)
1083 + max_pad = round_up(max_pad, block_size)
1084 + max_blocks = CEILDIV(max_pad, block_size)
1085 + sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1086 + expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1087 + num_post = torch.zeros((max_loras,), dtype=torch.int32)
1088 + else:
1089 + sorted_token_ids = None
1090 + expert_ids = topk_ids.reshape(-1).contiguous()
1091 + num_post = None
1092 + if trigger == "naive_mapping_neg":
1093 + token_lora_mapping = torch.full((num_tokens,), -1, dtype=torch.int32)
1094 + lora_ids = torch.full((max_loras + 1,), -1, dtype=torch.int32)
1095 + else: # naive_all_disabled
1096 + adapter_enabled = torch.zeros(max_loras + 1, dtype=torch.int32)
1097 +
1098 + residual = torch.randn((num_tokens, top_k, N), dtype=dtype) * 0.1
1099 + output = residual.clone()
1100 +
1101 + _call_one_shot(
1102 + output,
1103 + hidden_states,
1104 + lora_a_stacked,
1105 + lora_b_stacked,
1106 + topk_weights,
1107 + sorted_token_ids,
1108 + expert_ids,
1109 + num_post,
1110 + token_lora_mapping,
1111 + R,
1112 + top_k,
1113 + lora_ids,
1114 + num_active_loras,
1115 + adapter_enabled,
1116 + block_size,
1117 + )
1118 + torch.testing.assert_close(output, residual, atol=0, rtol=0)
1119 +
1120 +
1121 +@pytest.mark.parametrize("device", DEVICES)
1122 +def test_fused_moe_lora_kernel_zero_grid_no_crash(device):
1123 + """num_active_loras=0 (or num_slices=0) would otherwise launch a grid
1124 + with a zero dimension. one-shot wrapper must short-circuit before launch."""
1125 + torch.set_default_device(device)
1126 + set_random_seed(0)
1127 + num_tokens, top_k, E, max_loras, R, K, N = 8, 2, 8, 4, 16, 1024, 1024
1128 + block_size, num_slices, dtype = 16, 2, torch.bfloat16
1129 +
1130 + (
1131 + topk_ids,
1132 + topk_weights,
1133 + token_lora_mapping,
1134 + lora_ids,
1135 + lora_a_stacked,
1136 + lora_b_stacked,
1137 + hidden_states,
1138 + ) = _build_one_shot_inputs(
1139 + num_tokens,
1140 + top_k,
1141 + E,
1142 + max_loras,
1143 + R,
1144 + K,
1145 + N,
1146 + num_slices,
1147 + block_size,
1148 + dtype,
1149 + )
1150 + adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1151 + num_active_loras = torch.tensor([0], dtype=torch.int32, device="cpu")
1152 + residual = torch.randn((num_tokens, top_k, N), dtype=dtype) * 0.1
1153 + output = residual.clone()
1154 +
1155 + # sorted path is the one that uses num_active_loras for grid axis 2
1156 + max_pad = topk_ids.numel() + E * (block_size - 1)
1157 + max_pad = round_up(max_pad, block_size)
1158 + max_blocks = CEILDIV(max_pad, block_size)
1159 + sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1160 + expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1161 + num_post = torch.zeros((max_loras,), dtype=torch.int32)
1162 + _call_one_shot(
1163 + output,
1164 + hidden_states,
1165 + lora_a_stacked,
1166 + lora_b_stacked,
1167 + topk_weights,
1168 + sorted_token_ids,
1169 + expert_ids,
1170 + num_post,
1171 + token_lora_mapping,
1172 + R,
1173 + top_k,
1174 + lora_ids,
1175 + num_active_loras,
1176 + adapter_enabled,
1177 + block_size,
1178 + )
1179 + torch.testing.assert_close(output, residual, atol=0, rtol=0)
1180 +
1181 +
1182 +@pytest.mark.parametrize("device", DEVICES)
1183 +def test_fused_moe_lora_kernel_rejects_bad_block_size_m(device):
1184 + """one-shot must surface a clear assertion when shrink_block_size_m is not
1185 + a power of 2 / less than 16, instead of the cryptic Triton compile
1186 + failure (`arange's range must be a power of 2`)."""
1187 + torch.set_default_device(device)
1188 + set_random_seed(0)
1189 + num_tokens, top_k, E, max_loras, R, K, N = 32, 2, 8, 4, 16, 1024, 1024
1190 + num_slices, dtype = 2, torch.bfloat16
1191 + block_size = 24 # NOT a power of 2
1192 +
1193 + (
1194 + topk_ids,
1195 + topk_weights,
1196 + token_lora_mapping,
1197 + lora_ids,
1198 + lora_a_stacked,
1199 + lora_b_stacked,
1200 + hidden_states,
1201 + ) = _build_one_shot_inputs(
1202 + num_tokens,
1203 + top_k,
1204 + E,
1205 + max_loras,
1206 + R,
1207 + K,
1208 + N,
1209 + num_slices,
1210 + 16,
1211 + dtype,
1212 + )
1213 + # Build sorted-mode metadata at block_size=16 so shapes are sane,
1214 + # but pass block_size=24 to the op (the buggy combination).
1215 + max_pad = topk_ids.numel() + E * (16 - 1)
1216 + max_pad = round_up(max_pad, 16)
1217 + max_blocks = CEILDIV(max_pad, 16)
1218 + sorted_token_ids = torch.zeros((max_loras, max_pad), dtype=torch.int32)
1219 + expert_ids = torch.full((max_loras, max_blocks), -1, dtype=torch.int32)
1220 + num_post = torch.zeros((max_loras,), dtype=torch.int32)
1221 + adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
1222 + num_active_loras = torch.tensor([max_loras + 1], dtype=torch.int32, device="cpu")
1223 + output = torch.zeros((num_tokens, top_k, N), dtype=dtype)
1224 +
1225 + with pytest.raises(AssertionError, match="shrink_block_size_m"):
1226 + _call_one_shot(
1227 + output,
1228 + hidden_states,
1229 + lora_a_stacked,
1230 + lora_b_stacked,
1231 + topk_weights,
1232 + sorted_token_ids,
1233 + expert_ids,
1234 + num_post,
1235 + token_lora_mapping,
1236 + R,
1237 + top_k,
1238 + lora_ids,
1239 + num_active_loras,
1240 + adapter_enabled,
1241 + block_size,
1242 + )
vllm/envs.py
+2
@@ -1971,6 +1971,8 @@ environment_variables: dict[str, Callable[[], Any]] = {
1971 int(os.getenv("VLLM_USE_SIMPLE_KV_OFFLOAD", "0"))
1972 ),
1973 # Whether to enable dual cuda streams for LoRA computation
1974 + # (used by both BaseLinearLayerWithLoRA and FusedMoEWithLoRA to
1975 + # overlap the base layer compute with the LoRA fast path).
1976 "VLLM_LORA_ENABLE_DUAL_STREAM": lambda: bool(
1977 int(os.getenv("VLLM_LORA_ENABLE_DUAL_STREAM", "0"))
1978 ),
vllm/lora/layers/base_linear.py
+1 -8
@@ -25,16 +25,9 @@ from vllm.utils.multi_stream_utils import maybe_execute_in_parallel
25 from vllm.utils.torch_utils import direct_register_custom_op
26
27 from .base import BaseLayerWithLoRA
28 -from .utils import _get_lora_device
28 +from .utils import _get_lora_aux_cuda_stream, _get_lora_device
29
30 if envs.VLLM_LORA_ENABLE_DUAL_STREAM:
31 - _lora_aux_cuda_stream: torch.cuda.Stream | None = None
32 -
33 - def _get_lora_aux_cuda_stream() -> torch.cuda.Stream | None:
34 - global _lora_aux_cuda_stream
35 - if _lora_aux_cuda_stream is None and current_platform.is_cuda_alike():
36 - _lora_aux_cuda_stream = torch.cuda.Stream()
37 - return _lora_aux_cuda_stream
31
32 def lora_linear_async(
33 layer_name: str,
vllm/lora/layers/fused_moe.py
+25 -1
@@ -19,8 +19,9 @@ from vllm.model_executor.layers.fused_moe.modular_kernel import FusedMoEKernel
19 from vllm.model_executor.layers.fused_moe.prepare_finalize import (
20 MoEPrepareAndFinalizeNoDPEPModular,
21 )
22 +from vllm.platforms import current_platform
23
23 -from .utils import _get_lora_device
24 +from .utils import _get_lora_aux_cuda_stream, _get_lora_device
25
26
27 class FusedMoEWithLoRA(BaseLayerWithLoRA):
@@ -34,6 +35,9 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
35 self.tp_size = self.base_layer.tp_size
36 self.tp_rank = self.base_layer.tp_rank
37 self.device = _get_lora_device(base_layer)
38 +
39 + self._enable_aux_cuda_stream = envs.VLLM_LORA_ENABLE_DUAL_STREAM
40 + self._init_lora_stream_context()
41 # For non-gated MoE (is_act_and_mul=False), only 1 slice is needed
42 # since there's only up_proj (w1), not gate_proj + up_proj (w1 + w3)
43 self._w13_slices = 2 if base_layer.moe_config.is_act_and_mul else 1
@@ -65,7 +69,25 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
69 FusedMoEModularMethod(self.base_layer.quant_method, moe_kernel)
70 )
71
72 + def _init_lora_stream_context(self) -> None:
73 + self._lora_stream: torch.cuda.Stream | None = None
74 + self._events: tuple[torch.cuda.Event, ...] | None = None
75 + if not self._enable_aux_cuda_stream:
76 + return
77 + if not current_platform.is_cuda_alike():
78 + return
79 + self._lora_stream = _get_lora_aux_cuda_stream()
80 + # 4 events: 2 per (base GEMM, LoRA) pair so w13 and w2 don't reuse
81 + # the same event objects; reuse-within-a-pair is fine because the
82 + # second pair starts only after intermediate_cache1.add_() has joined.
83 + self._events = tuple(torch.cuda.Event() for _ in range(4))
84 +
85 def _build_lora_context(self):
86 + use_dual_stream = (
87 + self._enable_aux_cuda_stream
88 + and not self.fully_sharded
89 + and self._lora_stream is not None
90 + )
91 return MoELoRAContext(
92 w13_lora_a_stacked=self.w13_lora_a_stacked,
93 w13_lora_b_stacked=self.w13_lora_b_stacked,
@@ -81,6 +103,8 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
103 local_num_experts=self.base_layer.local_num_experts,
104 punica_wrapper=self.punica_wrapper,
105 use_tuned_config=bool(envs.VLLM_TUNED_CONFIG_FOLDER),
106 + aux_stream=self._lora_stream if use_dual_stream else None,
107 + events=self._events if use_dual_stream else None,
108 )
109
110 def _create_lora_a_weights(
vllm/lora/layers/utils.py
+13
@@ -7,9 +7,22 @@ from enum import Enum
7 import torch
8 import torch.nn as nn
9
10 +from vllm import envs
11 from vllm.model_executor.layers.fused_moe.fused_moe import try_get_optimal_moe_config
12 +from vllm.platforms import current_platform
13 from vllm.utils.math_utils import next_power_of_2
14
15 +_lora_aux_cuda_stream: torch.cuda.Stream | None = None
16 +
17 +
18 +def _get_lora_aux_cuda_stream() -> torch.cuda.Stream | None:
19 + if not envs.VLLM_LORA_ENABLE_DUAL_STREAM:
20 + return None
21 + global _lora_aux_cuda_stream
22 + if _lora_aux_cuda_stream is None and current_platform.is_cuda_alike():
23 + _lora_aux_cuda_stream = torch.cuda.Stream()
24 + return _lora_aux_cuda_stream
25 +
26
27 class LoRAMappingType(Enum):
28 LANGUAGE = 1
vllm/lora/ops/triton_ops/fused_moe_lora_op.py
+791
@@ -105,6 +105,742 @@ def _get_c_ptrs(
105 _LORA_PTR_DICT: dict[tuple[int, ...], torch.tensor] = {}
106
107
108 +# ---------------------------------------------------------------------------
109 +# Fully-fused MoE-LoRA kernel (one-shot): shrink + expand combined into a single
110 +# launch with the rank-dim intermediate kept in registers. Used by the fast
111 +# path of `_fused_moe_lora` for `fully_sharded=False`. The legacy two-kernel
112 +# path (`_fused_moe_lora_kernel` above) is retained for `fully_sharded=True`
113 +# because that path needs to materialise the intermediate cache for an
114 +# all_reduce / all_gather between shrink and expand.
115 +# ---------------------------------------------------------------------------
116 +
117 +
118 +@triton.heuristics({"EVEN_K": lambda args: args["K"] % args["BLOCK_K"] == 0})
119 +@triton.jit
120 +def _fused_moe_lora_one_shot_kernel(
121 + # ---- pointers ----
122 + x_ptr,
123 + A_ptrs,
124 + B_ptrs,
125 + out_ptr,
126 + topk_weights_ptr,
127 + sorted_token_ids_ptr,
128 + expert_ids_ptr,
129 + num_tokens_post_padded_ptr,
130 + token_lora_mapping_ptr,
131 + lora_ids_ptr,
132 + adapter_enabled_ptr,
133 + # ---- dims ----
134 + N,
135 + K,
136 + num_valid_tokens,
137 + top_k_num,
138 + max_loras,
139 + # ---- strides ----
140 + stride_xm,
141 + stride_xk,
142 + stride_A_lora,
143 + stride_A_expert,
144 + stride_A_r,
145 + stride_A_k,
146 + stride_B_lora,
147 + stride_B_expert,
148 + stride_B_n,
149 + stride_B_r,
150 + stride_om,
151 + stride_on,
152 + stride_tl_,
153 + stride_el,
154 + # ---- scalar ----
155 + slice_n_offset,
156 + # ---- constexpr (set per call) ----
157 + token_mapping_factor: tl.constexpr,
158 + naive_block_assignment: tl.constexpr,
159 + MUL_ROUTED_WEIGHT: tl.constexpr,
160 + BLOCK_M: tl.constexpr,
161 + BLOCK_R: tl.constexpr,
162 + actual_rank: tl.constexpr,
163 + NPID_FACTOR: tl.constexpr,
164 + BLOCK_N: tl.constexpr,
165 + BLOCK_K: tl.constexpr,
166 + EVEN_K: tl.constexpr,
167 + ADD_INPUTS: tl.constexpr,
168 +):
169 + pid_full = tl.program_id(axis=0)
170 + pid_m = pid_full // NPID_FACTOR
171 + pid_n_outer = pid_full % NPID_FACTOR
172 + slice_id = tl.program_id(axis=1)
173 + lora_idx = tl.program_id(axis=2)
174 +
175 + # Resolve lora_id.
176 + if naive_block_assignment:
177 + token_idx_for_lora = pid_m // top_k_num
178 + lora_id = tl.load(token_lora_mapping_ptr + token_idx_for_lora)
179 + else:
180 + lora_id = tl.load(lora_ids_ptr + lora_idx)
181 + if lora_id < 0:
182 + return
183 + if lora_id >= max_loras:
184 + return
185 + enabled = tl.load(adapter_enabled_ptr + lora_id)
186 + if enabled == 0:
187 + return
188 +
189 + if not naive_block_assignment:
190 + ntpp = tl.load(num_tokens_post_padded_ptr + lora_id)
191 + if pid_m * BLOCK_M >= ntpp:
192 + return
193 +
194 + # Resolve expert_id.
195 + if naive_block_assignment:
196 + expert_id = tl.load(expert_ids_ptr + pid_m)
197 + else:
198 + ind = lora_id * stride_el + pid_m
199 + expert_id = tl.load(
200 + expert_ids_ptr + ind, mask=ind < max_loras * stride_el, other=-1
201 + )
202 + if expert_id < 0:
203 + return
204 +
205 + # Compute offs_token (flat token ids).
206 + offs = tl.arange(0, BLOCK_M).to(tl.int64)
207 + if naive_block_assignment:
208 + offs_token = tl.where(offs == 0, pid_m, num_valid_tokens)
209 + else:
210 + offs_token_id = pid_m * BLOCK_M + offs
211 + token_ind = stride_tl_ * lora_id + offs_token_id
212 + offs_token = tl.load(
213 + sorted_token_ids_ptr + token_ind,
214 + mask=token_ind < max_loras * stride_tl_,
215 + other=num_valid_tokens,
216 + )
217 + token_mask = offs_token < num_valid_tokens
218 +
219 + # N range owned by this program. Splitting [0, N) into NPID_FACTOR
220 + # contiguous outer blocks lets us scale parallelism for small batches.
221 + n_per_outer = tl.cdiv(N, NPID_FACTOR)
222 + n_lo = pid_n_outer * n_per_outer
223 + n_hi = tl.minimum((pid_n_outer + 1) * n_per_outer, N)
224 + if n_lo >= N:
225 + return
226 +
227 + # Slice pointers.
228 + cur_A_ptr = tl.load(A_ptrs + slice_id).to(tl.pointer_type(out_ptr.dtype.element_ty))
229 + cur_B_ptr = tl.load(B_ptrs + slice_id).to(tl.pointer_type(out_ptr.dtype.element_ty))
230 +
231 + A_base = cur_A_ptr + lora_id * stride_A_lora + expert_id * stride_A_expert
232 + B_base = cur_B_ptr + lora_id * stride_B_lora + expert_id * stride_B_expert
233 +
234 + # SHRINK: tmp[BLOCK_M, BLOCK_R] = x @ A^T, accumulated in fp32 registers.
235 + offs_r = tl.arange(0, BLOCK_R)
236 + rank_mask = offs_r < actual_rank
237 + # Clamp rank offsets so OOB rows of A / B map to address 0; the mask
238 + # zeros the loaded values. Required when BLOCK_R > actual_rank
239 + # (e.g. rank=4 padded to 16) -- without clamping, tl.load would address
240 + # the next expert's memory.
241 + safe_offs_r = tl.where(rank_mask, offs_r, 0)
242 + offs_k = tl.arange(0, BLOCK_K)
243 +
244 + offs_x_row = offs_token // token_mapping_factor
245 + x_ptrs = x_ptr + offs_x_row[:, None] * stride_xm + offs_k[None, :] * stride_xk
246 + a_ptrs = A_base + offs_k[:, None] * stride_A_k + safe_offs_r[None, :] * stride_A_r
247 +
248 + tmp = tl.zeros((BLOCK_M, BLOCK_R), dtype=tl.float32)
249 + if EVEN_K:
250 + for _ in range(0, K, BLOCK_K):
251 + x = tl.load(x_ptrs, mask=token_mask[:, None], other=0.0)
252 + a = tl.load(a_ptrs, mask=rank_mask[None, :], other=0.0)
253 + tmp += tl.dot(x, a)
254 + x_ptrs += BLOCK_K * stride_xk
255 + a_ptrs += BLOCK_K * stride_A_k
256 + else:
257 + for kb in range(0, K, BLOCK_K):
258 + k_remain = K - kb
259 + k_mask = offs_k < k_remain
260 + x = tl.load(x_ptrs, mask=token_mask[:, None] & k_mask[None, :], other=0.0)
261 + a = tl.load(a_ptrs, mask=k_mask[:, None] & rank_mask[None, :], other=0.0)
262 + tmp += tl.dot(x, a)
263 + x_ptrs += BLOCK_K * stride_xk
264 + a_ptrs += BLOCK_K * stride_A_k
265 +
266 + tmp_typed = tmp.to(out_ptr.dtype.element_ty)
267 +
268 + # EXPAND: out[tokens, n] += tmp @ B^T, looped over BLOCK_N tiles within
269 + # this program's [n_lo, n_hi). The (offs_n < n_hi) mask is required
270 + # whenever BLOCK_N > n_per_outer to keep adjacent outer blocks from
271 + # writing into each other's columns.
272 + if MUL_ROUTED_WEIGHT:
273 + moe_w = tl.load(topk_weights_ptr + offs_token, mask=token_mask, other=0.0).to(
274 + tl.float32
275 + )
276 +
277 + out_slice_base = out_ptr + slice_id * slice_n_offset
278 +
279 + for n_start in range(n_lo, n_hi, BLOCK_N):
280 + offs_n = n_start + tl.arange(0, BLOCK_N)
281 + n_mask = (offs_n < N) & (offs_n < n_hi)
282 +
283 + b_ptrs = (
284 + B_base + safe_offs_r[:, None] * stride_B_r + offs_n[None, :] * stride_B_n
285 + )
286 + b = tl.load(b_ptrs, mask=rank_mask[:, None] & n_mask[None, :], other=0.0)
287 +
288 + acc = tl.dot(tmp_typed, b) # (BLOCK_M, BLOCK_N) fp32
289 + if MUL_ROUTED_WEIGHT:
290 + acc = acc * moe_w[:, None]
291 +
292 + out_ptrs = (
293 + out_slice_base
294 + + offs_token[:, None] * stride_om
295 + + offs_n[None, :] * stride_on
296 + )
297 + out_mask = token_mask[:, None] & n_mask[None, :]
298 + if ADD_INPUTS:
299 + prev = tl.load(out_ptrs, mask=out_mask, other=0.0)
300 + tl.store(out_ptrs, prev + acc.to(out_ptr.dtype.element_ty), mask=out_mask)
301 + else:
302 + tl.store(out_ptrs, acc.to(out_ptr.dtype.element_ty), mask=out_mask)
303 +
304 +
305 +def _run_fused_moe_lora_one_shot(
306 + output: torch.Tensor,
307 + qcurr_hidden_states: torch.Tensor,
308 + lora_a_stacked: list[torch.Tensor],
309 + lora_b_stacked: list[torch.Tensor],
310 + topk_weights: torch.Tensor,
311 + sorted_token_ids: torch.Tensor | None,
312 + expert_ids: torch.Tensor,
313 + num_tokens_post_padded: torch.Tensor | None,
314 + token_lora_mapping: torch.Tensor,
315 + max_lora_rank: int,
316 + top_k_num: int,
317 + lora_ids: torch.Tensor,
318 + num_active_loras: torch.Tensor,
319 + adapter_enabled: torch.Tensor,
320 + mul_routed_weight: bool,
321 + block_size_m: int,
322 + add_inputs: bool = True,
323 +) -> None:
324 + """Fast-path wrapper: launches one fused shrink+expand kernel.
325 +
326 + The shape contract matches `_fused_moe_lora`. `output` has shape
327 + `(num_tokens, top_k_num, num_slices * N_per_slice)`. When
328 + `add_inputs=True` (default) the kernel reads-modifies-writes `output`
329 + in place; when `add_inputs=False` the kernel overwrites `output` with
330 + the LoRA delta only. The latter is used by the dual-stream path that
331 + sums LoRA into the base output on a separate stream.
332 + """
333 + num_slices = len(lora_a_stacked)
334 + device = qcurr_hidden_states.device
335 +
336 + A0 = lora_a_stacked[0]
337 + B0 = lora_b_stacked[0]
338 + max_loras_w = A0.shape[0]
339 + rank = A0.shape[2]
340 + K = A0.shape[3]
341 + N_per_slice = B0.shape[2]
342 +
343 + # rank padding is to next pow2 with a floor of 16 (tensor-core minimum
344 + # K-dim). Beyond 128 the (BLOCK_M, BLOCK_R) accumulator outgrows the
345 + # register file; rank tiling would be needed but is out of scope for
346 + # this kernel. Tried floor=32 to double MMA density per K-step but it
347 + # regressed across all M (+8 to +40%): the (64,32) fp32 accumulator +
348 + # widened B tile pushed register count past spill threshold, lowering
349 + # occupancy by more than the MMA gain saved.
350 + assert rank <= 128, (
351 + f"fused_moe_lora_one_shot supports max_lora_rank<=128; got rank={rank}"
352 + )
353 + BLOCK_R = max(triton.next_power_of_2(rank), 16)
354 +
355 + num_experts = A0.shape[1]
356 + naive = sorted_token_ids is None
357 + if sorted_token_ids is None:
358 + EM_grid = topk_weights.numel()
359 + BLOCK_M = 16
360 + stride_tl_ = 0
361 + stride_el = 0
362 + grid_lora_dim = 1
363 + else:
364 + EM_grid = sorted_token_ids.shape[1]
365 + # BLOCK_M must equal moe_lora_align_block_size's block_size. The
366 + # caller passes that explicitly; deriving it from tensor shapes is
367 + # unsafe because sorted_token_ids.shape[1] is the raw padded length
368 + # (not necessarily a multiple of block_size — e.g. OLMoE prefill
369 + # produces sorted=139200 with expert_ids=1088 and block_size=128).
370 + # tl.arange and tl.dot need block_size_m to be a power of 2 and at
371 + # least 16. The Python-side assertion gives a clearer error than
372 + # the cryptic Triton compile failure.
373 + assert block_size_m >= 16 and (block_size_m & (block_size_m - 1)) == 0, (
374 + f"shrink_block_size_m must be a power of 2 and >=16; got {block_size_m}"
375 + )
376 + BLOCK_M = block_size_m
377 + stride_tl_ = sorted_token_ids.stride(0)
378 + stride_el = expert_ids.stride(0)
379 + grid_lora_dim = int(num_active_loras.item())
380 +
381 + # Empty-work guards: the grid would otherwise have a zero dimension,
382 + # which Triton rejects. None of these is a hot path in production -- a
383 + # batch with zero tokens, an EM_grid of zero, or zero active LoRAs all
384 + # mean there's nothing to add to `output`.
385 + if EM_grid == 0 or grid_lora_dim == 0 or num_slices == 0:
386 + return
387 +
388 + token_mapping_factor = 1 if mul_routed_weight else top_k_num
389 +
390 + A_ptrs = _get_ptr(lora_a_stacked, device)
391 + B_ptrs = _get_ptr(lora_b_stacked, device)
392 +
393 + # Flatten (num_tokens, top_k) → flat_token axis. The kernel addresses
394 + # output via offs_token * stride_om, which is correct iff the dim-0 /
395 + # dim-1 strides collapse cleanly: stride(0) == top_k * stride(1). All
396 + # production callers pass contiguous output, so this always holds; the
397 + # explicit check guards against future regressions where a non-trivial
398 + # view (e.g. permute) would silently break in-place accumulation.
399 + assert output.dim() == 3, f"output must be 3-D, got {output.shape}"
400 + assert output.stride(0) == output.shape[1] * output.stride(1), (
401 + "fused_moe_lora_one_shot requires output.stride(0) == top_k*stride(1); "
402 + f"got shape={output.shape} strides={output.stride()}"
403 + )
404 + out_view = output.view(-1, output.shape[-1])
405 + M_blocks = triton.cdiv(EM_grid, BLOCK_M) if not naive else EM_grid
406 +
407 + # NPID_FACTOR heuristic: scale N-axis parallelism when base CTA count is
408 + # short of saturating the SM array. Cap by the cost of redundant shrink.
409 + sm_count = torch.cuda.get_device_properties(device).multi_processor_count
410 + base_programs = max(M_blocks * num_slices * grid_lora_dim, 1)
411 + shrink_ratio = K / max(K + N_per_slice, 1)
412 + max_npid_by_budget = max(1, int(1.5 / max(shrink_ratio, 1e-3)) + 1)
413 + target = 2 * sm_count
414 + if base_programs >= int(1.5 * sm_count):
415 + npid = 1
416 + else:
417 + npid_occ = max(1, min(16, (target + base_programs - 1) // base_programs))
418 + npid = min(npid_occ, max_npid_by_budget)
419 + npid = max(1, min(npid, max(1, N_per_slice // 128)))
420 +
421 + # Robust defaults across the prefill regime (H100/H200/B200, bf16/fp16).
422 + # NPID > 1 is the small-M / under-saturated path -- more warps help
423 + # amortise the inner-N expand loop. ns=3 instead of 4: GB200 ncu showed
424 + # the 4-stage pipeline pushed register count to 168/thread and capped
425 + # achieved occupancy at ~17% (3 blocks/SM, register-bound); ns=3 frees
426 + # ~30 regs/thread which keeps a 4th block resident on small grids.
427 + # Tried BLOCK_N=64 for w13 (N=192) to avoid the half-wasted second
428 + # tile: regressed 11-29% because the "waste" was just masked stores
429 + # (cheap) and the extra iteration added load + index overhead.
430 + if npid > 1:
431 + block_n, nw, ns = 128, 8, 3
432 + else:
433 + block_n, nw, ns = 128, 4, 3
434 + # BLOCK_K choice: for hidden-sized K (≥256, i.e. the K=hidden_size
435 + # shrink input on w13) force BLOCK_K=128 -- the wider tile halves the
436 + # K-loop trip count and removes the scoreboard stalls that dominated
437 + # M=16-64 on GB200 (kernel time -13% to -37% vs the work_per_expert
438 + # heuristic which picked 64 for low-tokens-per-expert ratios). For
439 + # small-K shapes (e.g. w2 with K=192 where the down-proj reads the
440 + # MoE intermediate) keep the work_per_expert heuristic: BLOCK_K=128
441 + # would force the EVEN_K=False masked path and add no K-loop savings
442 + # (K/64=3 vs K/128=2 masked) while inflating per-program startup.
443 + if K >= 256:
444 + block_k = 128
445 + else:
446 + work_per_expert = topk_weights.numel() / max(num_experts, 1)
447 + block_k = 128 if work_per_expert >= 16 else 64
448 +
449 + grid = (M_blocks * npid, num_slices, grid_lora_dim)
450 +
451 + _fused_moe_lora_one_shot_kernel[grid](
452 + qcurr_hidden_states,
453 + A_ptrs,
454 + B_ptrs,
455 + out_view,
456 + topk_weights,
457 + sorted_token_ids,
458 + expert_ids,
459 + num_tokens_post_padded,
460 + token_lora_mapping,
461 + lora_ids,
462 + adapter_enabled,
463 + N_per_slice,
464 + K,
465 + topk_weights.numel(),
466 + top_k_num,
467 + max_loras_w,
468 + qcurr_hidden_states.stride(0),
469 + qcurr_hidden_states.stride(1),
470 + A0.stride(0),
471 + A0.stride(1),
472 + A0.stride(2),
473 + A0.stride(3),
474 + B0.stride(0),
475 + B0.stride(1),
476 + B0.stride(2),
477 + B0.stride(3),
478 + out_view.stride(0),
479 + out_view.stride(1),
480 + stride_tl_,
481 + stride_el,
482 + N_per_slice,
483 + token_mapping_factor=token_mapping_factor,
484 + naive_block_assignment=naive,
485 + MUL_ROUTED_WEIGHT=mul_routed_weight,
486 + BLOCK_M=BLOCK_M,
487 + BLOCK_R=BLOCK_R,
488 + actual_rank=rank,
489 + NPID_FACTOR=npid,
490 + BLOCK_N=block_n,
491 + BLOCK_K=block_k,
492 + ADD_INPUTS=add_inputs,
493 + num_warps=nw,
494 + num_stages=ns,
495 + )
496 +
497 +
498 +# ---------------------------------------------------------------------------
499 +# Small-batch (decode-style) fused MoE-LoRA kernel — sub-path of the
500 +# one_shot fast path.
501 +# ---------------------------------------------------------------------------
502 +
503 +
504 +@triton.heuristics({"EVEN_K": lambda args: args["K"] % args["BLOCK_K"] == 0})
505 +@triton.jit
506 +def _fused_moe_lora_small_batch_kernel(
507 + # ---- pointers ----
508 + x_ptr,
509 + A_ptrs,
510 + B_ptrs,
511 + out_ptr,
512 + topk_weights_ptr,
513 + expert_ids_ptr, # (num_tokens * top_k_num,)
514 + token_lora_mapping_ptr, # (num_tokens,)
515 + adapter_enabled_ptr,
516 + # ---- dims ----
517 + N,
518 + K,
519 + top_k_num,
520 + max_loras,
521 + work_total, # = pair_slices * n_chunks_per_pair_slice
522 + pair_slices, # = num_tokens * top_k_num * NUM_SLICES
523 + # ---- strides ----
524 + stride_xm,
525 + stride_xk,
526 + stride_A_lora,
527 + stride_A_expert,
528 + stride_A_r,
529 + stride_A_k,
530 + stride_B_lora,
531 + stride_B_expert,
532 + stride_B_n,
533 + stride_B_r,
534 + stride_om,
535 + stride_on,
536 + # ---- scalar (runtime ints, NOT constexpr) ----
537 + # n_tiles_per_program / n_chunks_per_pair_slice are deliberately
538 + # runtime: each distinct value would otherwise trigger a fresh Triton
539 + # compile -> fresh kernel binary -> fresh CUDA graph instance per
540 + # batch size. Production traces showed that variant explosion adding
541 + # ~5.9k graph instantiations on top of legacy. Runtime args mean one
542 + # shared binary across all chunk sizes.
543 + slice_n_offset,
544 + n_tiles_per_program,
545 + n_chunks_per_pair_slice,
546 + # ---- constexpr ----
547 + token_mapping_factor: tl.constexpr,
548 + MUL_ROUTED_WEIGHT: tl.constexpr,
549 + ADD_INPUTS: tl.constexpr,
550 + BLOCK_R: tl.constexpr,
551 + actual_rank: tl.constexpr,
552 + BLOCK_N: tl.constexpr,
553 + BLOCK_K: tl.constexpr,
554 + NUM_SLICES: tl.constexpr,
555 + EVEN_K: tl.constexpr,
556 +):
557 + """Persistent fused MoE-LoRA kernel for naive_block_assignment inputs.
558 +
559 + Each program owns one (pair × slice × n_chunk) work item. A "chunk"
560 + covers `n_tiles_per_program` consecutive output-N tiles, all of which
561 + share a single shrink — so the rank-vector is computed once per
562 + program and the A weights for that (lora, expert, slice) are loaded
563 + once instead of n_tiles_per_program times.
564 +
565 + The wrapper picks `n_tiles_per_program` to keep the grid close to
566 + 2*SM_count: at very small batch (work_total ≤ SM_count) the chunk
567 + size collapses to 1 and behaviour matches a per-tile GEMV; as batch
568 + grows the chunk grows so we trade some N-axis parallelism for shrink
569 + reuse. When `work_total` exceeds the launched grid, the outer stride
570 + loop drains the leftover work units serially.
571 + """
572 + pid = tl.program_id(axis=0)
573 + num_programs = tl.num_programs(axis=0)
574 +
575 + offs_r = tl.arange(0, BLOCK_R)
576 + rank_mask = offs_r < actual_rank
577 + # Clamp OOB rank lanes so they address row 0 of A/B; the mask zeros
578 + # the loaded values. Required when BLOCK_R > actual_rank (e.g. rank=4
579 + # padded to 16) -- without clamping, tl.load would address the next
580 + # expert's memory.
581 + safe_offs_r = tl.where(rank_mask, offs_r, 0)
582 + offs_k = tl.arange(0, BLOCK_K)
583 +
584 + # Persistent stride loop: when grid < work_total each program walks
585 + # multiple work items. When grid == work_total the loop runs exactly
586 + # once and the kernel degenerates to the per-tile GEMV.
587 + for work_id in range(pid, work_total, num_programs):
588 + n_chunk_idx = work_id % n_chunks_per_pair_slice
589 + pair_slice_idx = work_id // n_chunks_per_pair_slice
590 + # NUM_SLICES is constexpr (typ. 1 or 2) so divmod folds.
591 + pair_idx = pair_slice_idx // NUM_SLICES
592 + slice_id = pair_slice_idx % NUM_SLICES
593 +
594 + # Resolve lora_id / expert_id; skip the body for inactive lanes.
595 + # Using a single `valid` flag instead of early `return` keeps the
596 + # outer stride loop alive — `return` would exit the whole program
597 + # and skip later work items assigned to this SM.
598 + token_idx = pair_idx // top_k_num
599 + lora_id = tl.load(token_lora_mapping_ptr + token_idx)
600 + valid = (lora_id >= 0) & (lora_id < max_loras)
601 + enabled = tl.load(adapter_enabled_ptr + tl.where(valid, lora_id, 0))
602 + valid = valid & (enabled != 0)
603 + expert_id = tl.load(expert_ids_ptr + pair_idx)
604 + valid = valid & (expert_id >= 0)
605 +
606 + if valid:
607 + cur_A_ptr = tl.load(A_ptrs + slice_id).to(
608 + tl.pointer_type(out_ptr.dtype.element_ty)
609 + )
610 + cur_B_ptr = tl.load(B_ptrs + slice_id).to(
611 + tl.pointer_type(out_ptr.dtype.element_ty)
612 + )
613 + A_base = cur_A_ptr + lora_id * stride_A_lora + expert_id * stride_A_expert
614 + B_base = cur_B_ptr + lora_id * stride_B_lora + expert_id * stride_B_expert
615 +
616 + x_row = pair_idx // token_mapping_factor
617 + x_row_ptr = x_ptr + x_row * stride_xm
618 +
619 + # SHRINK GEMV (once per program; reused across n_tiles_per_program
620 + # expand tiles below). Sum-reduction over BLOCK_K with fp32
621 + # accumulator — same precision path as the one_shot kernel.
622 + rank_vec = tl.zeros((BLOCK_R,), dtype=tl.float32)
623 + if EVEN_K:
624 + for kb in range(0, K, BLOCK_K):
625 + cur_k = kb + offs_k
626 + x_tile = tl.load(x_row_ptr + cur_k * stride_xk).to(tl.float32)
627 + a_tile = tl.load(
628 + A_base
629 + + safe_offs_r[:, None] * stride_A_r
630 + + cur_k[None, :] * stride_A_k,
631 + mask=rank_mask[:, None],
632 + other=0.0,
633 + ).to(tl.float32)
634 + rank_vec += tl.sum(a_tile * x_tile[None, :], axis=1)
635 + else:
636 + for kb in range(0, K, BLOCK_K):
637 + cur_k = kb + offs_k
638 + k_mask = cur_k < K
639 + x_tile = tl.load(
640 + x_row_ptr + cur_k * stride_xk, mask=k_mask, other=0.0
641 + ).to(tl.float32)
642 + a_tile = tl.load(
643 + A_base
644 + + safe_offs_r[:, None] * stride_A_r
645 + + cur_k[None, :] * stride_A_k,
646 + mask=rank_mask[:, None] & k_mask[None, :],
647 + other=0.0,
648 + ).to(tl.float32)
649 + rank_vec += tl.sum(a_tile * x_tile[None, :], axis=1)
650 +
651 + # EXPAND: walk n_tiles_per_program consecutive output-N tiles
652 + # using the same rank_vec. The loop is a runtime range (not
653 + # tl.static_range) so a single compiled kernel handles every
654 + # chunk size — see the note on the kernel signature.
655 + n_tile_start = n_chunk_idx * n_tiles_per_program
656 + out_row_ptr = out_ptr + slice_id * slice_n_offset + pair_idx * stride_om
657 +
658 + if MUL_ROUTED_WEIGHT:
659 + moe_w = tl.load(topk_weights_ptr + pair_idx).to(tl.float32)
660 +
661 + for nt in range(n_tiles_per_program):
662 + n_lo = (n_tile_start + nt) * BLOCK_N
663 + if n_lo < N:
664 + offs_n = n_lo + tl.arange(0, BLOCK_N)
665 + n_mask = offs_n < N
666 + b_tile = tl.load(
667 + B_base
668 + + offs_n[:, None] * stride_B_n
669 + + safe_offs_r[None, :] * stride_B_r,
670 + mask=n_mask[:, None] & rank_mask[None, :],
671 + other=0.0,
672 + ).to(tl.float32)
673 + out_tile = tl.sum(b_tile * rank_vec[None, :], axis=1)
674 +
675 + if MUL_ROUTED_WEIGHT:
676 + out_tile = out_tile * moe_w
677 +
678 + out_ptrs = out_row_ptr + offs_n * stride_on
679 + if ADD_INPUTS:
680 + prev = tl.load(out_ptrs, mask=n_mask, other=0.0).to(tl.float32)
681 + tl.store(
682 + out_ptrs,
683 + (prev + out_tile).to(out_ptr.dtype.element_ty),
684 + mask=n_mask,
685 + )
686 + else:
687 + tl.store(
688 + out_ptrs,
689 + out_tile.to(out_ptr.dtype.element_ty),
690 + mask=n_mask,
691 + )
692 +
693 +
694 +def _pick_small_batch_chunk(pair_slices: int, N_tiles: int, sm_count: int) -> int:
695 + """Pick `n_tiles_per_program` so the launched grid stays near
696 + 2*SM_count.
697 +
698 + Sizes for occupancy first (more programs in flight → better latency
699 + hiding for the K-loop A/x loads). Once the per-tile grid already
700 + exceeds 2*SM_count we increase the chunk size to amortise the shrink
701 + cost — at that point the GPU is saturated by per-program work and
702 + packing more tiles per program lets the rank_vec be reused.
703 + """
704 + target_grid = max(1, 2 * sm_count)
705 + total_work = pair_slices * N_tiles
706 + if total_work <= target_grid:
707 + return 1
708 + ntpp = (total_work + target_grid - 1) // target_grid
709 + return min(ntpp, N_tiles)
710 +
711 +
712 +def _run_fused_moe_lora_small_batch(
713 + output: torch.Tensor,
714 + qcurr_hidden_states: torch.Tensor,
715 + lora_a_stacked: list[torch.Tensor],
716 + lora_b_stacked: list[torch.Tensor],
717 + topk_weights: torch.Tensor,
718 + expert_ids_flat: torch.Tensor, # (num_tokens * top_k_num,)
719 + token_lora_mapping: torch.Tensor,
720 + top_k_num: int,
721 + adapter_enabled: torch.Tensor,
722 + mul_routed_weight: bool,
723 + add_inputs: bool = True,
724 +) -> None:
725 + """Small-batch GEMV-style wrapper. Naive-block-assignment inputs only.
726 +
727 + Shape contract matches `_run_fused_moe_lora_one_shot`: `output` is
728 + `(num_tokens, top_k_num, num_slices * N_per_slice)` with
729 + contiguous-style strides, `expert_ids_flat` is the flattened
730 + `topk_ids` of shape `(num_tokens * top_k_num,)`, and the
731 + rank-padded LoRA weights live in `lora_a_stacked` /
732 + `lora_b_stacked`.
733 +
734 + The kernel is persistent over (pair × slice × n_chunk) work items —
735 + each program does one shrink and reuses the rank vector across
736 + `n_tiles_per_program` expand tiles. The chunk size scales with the
737 + pair-slice count so very small batches keep per-tile parallelism
738 + while medium batches cut redundant shrinks.
739 + """
740 + num_slices = len(lora_a_stacked)
741 + device = qcurr_hidden_states.device
742 +
743 + A0 = lora_a_stacked[0]
744 + B0 = lora_b_stacked[0]
745 + max_loras_w = A0.shape[0]
746 + rank = A0.shape[2]
747 + K = A0.shape[3]
748 + N_per_slice = B0.shape[2]
749 +
750 + # Rank padding: floor 16 (tensor-core min K), ceil to next pow2. The
751 + # ≤64 cap is set conservatively for the prototype: at rank 64 the
752 + # per-program register footprint is rank_vec(64 fp32) + b_tile(BLOCK_N
753 + # × 64 fp32) = e.g. 128*64*4 = 32 KiB, comfortably within the 64 KiB
754 + # register file even with num_warps=8. Doubling to 128 would push us
755 + # against the limit and require shared-memory staging.
756 + assert rank <= 64, f"fused_moe_lora_small_batch supports rank<=64; got rank={rank}"
757 + BLOCK_R = max(triton.next_power_of_2(rank), 16)
758 +
759 + num_tokens = topk_weights.shape[0]
760 + M_grid = num_tokens * top_k_num
761 + if M_grid == 0 or num_slices == 0:
762 + return
763 +
764 + token_mapping_factor = 1 if mul_routed_weight else top_k_num
765 +
766 + A_ptrs = _get_ptr(lora_a_stacked, device)
767 + B_ptrs = _get_ptr(lora_b_stacked, device)
768 +
769 + assert output.dim() == 3, f"output must be 3-D, got {output.shape}"
770 + assert output.stride(0) == output.shape[1] * output.stride(1), (
771 + "fused_moe_lora_small_batch requires output.stride(0) == "
772 + f"top_k*stride(1); got shape={output.shape} strides={output.stride()}"
773 + )
774 + out_view = output.view(-1, output.shape[-1])
775 +
776 + # Block sizes. BLOCK_N=128 matches the one_shot's expand tile and gives
777 + # 6-24 N tiles for typical N ∈ [768, 3072], enough to saturate the SM
778 + # array once M_grid * num_slices reaches ~SM_count. BLOCK_K=128 halves
779 + # the K-loop trip count vs 64 and pays for itself once K ≥ 1024 (the
780 + # only regime we care about — hidden sizes are always large here).
781 + BLOCK_N = 128
782 + BLOCK_K = 128
783 + nw = 4
784 + ns = 3
785 +
786 + N_tiles = triton.cdiv(N_per_slice, BLOCK_N)
787 + pair_slices = M_grid * num_slices
788 +
789 + sm_count = torch.cuda.get_device_properties(device).multi_processor_count
790 + n_tiles_per_program = _pick_small_batch_chunk(pair_slices, N_tiles, sm_count)
791 + n_chunks = triton.cdiv(N_tiles, n_tiles_per_program)
792 + work_total = pair_slices * n_chunks
793 +
794 + # Grid sizing: keep parallelism uncapped when work_total is small (so
795 + # very small batches still spread across SMs); cap at 2*SM_count once
796 + # we have plenty of work, letting the in-kernel stride loop drain the
797 + # remainder.
798 + grid_size = min(work_total, max(1, 2 * sm_count))
799 + grid = (grid_size,)
800 +
801 + _fused_moe_lora_small_batch_kernel[grid](
802 + qcurr_hidden_states,
803 + A_ptrs,
804 + B_ptrs,
805 + out_view,
806 + topk_weights,
807 + expert_ids_flat,
808 + token_lora_mapping,
809 + adapter_enabled,
810 + N_per_slice,
811 + K,
812 + top_k_num,
813 + max_loras_w,
814 + work_total,
815 + pair_slices,
816 + qcurr_hidden_states.stride(0),
817 + qcurr_hidden_states.stride(1),
818 + A0.stride(0),
819 + A0.stride(1),
820 + A0.stride(2),
821 + A0.stride(3),
822 + B0.stride(0),
823 + B0.stride(1),
824 + B0.stride(2),
825 + B0.stride(3),
826 + out_view.stride(0),
827 + out_view.stride(1),
828 + N_per_slice,
829 + n_tiles_per_program,
830 + n_chunks,
831 + token_mapping_factor=token_mapping_factor,
832 + MUL_ROUTED_WEIGHT=mul_routed_weight,
833 + ADD_INPUTS=add_inputs,
834 + BLOCK_R=BLOCK_R,
835 + actual_rank=rank,
836 + BLOCK_N=BLOCK_N,
837 + BLOCK_K=BLOCK_K,
838 + NUM_SLICES=num_slices,
839 + num_warps=nw,
840 + num_stages=ns,
841 + )
842 +
843 +
844 def _get_ptr(lora_weights: list[torch.Tensor], device: torch.device):
845 """
846 `_LORA_PTR_DICT` collects the required information during `profile_run`,
@@ -706,6 +1442,7 @@ def _fused_moe_lora(
1442 mul_routed_weight: bool = False,
1443 fully_sharded: bool = False,
1444 offset: int = 0,
1445 + add_inputs: bool = True,
1446 ) -> None:
1447 assert len(lora_a_stacked) == len(lora_b_stacked) > 0
1448 assert topk_weights.dim() == qcurr_hidden_states.dim() == 2
@@ -728,6 +1465,59 @@ def _fused_moe_lora(
1465 )
1466 assert output.shape[0] == topk_weights.shape[0]
1467 assert top_k_num == topk_weights.shape[1]
1468 +
1469 + # Fast path: single fused kernel
1470 + if not fully_sharded:
1471 + M_pairs = topk_weights.numel()
1472 + if (
1473 + sorted_token_ids is None
1474 + and max_lora_rank <= 64
1475 + and M_pairs * max_lora_rank <= 1024
1476 + ):
1477 + _run_fused_moe_lora_small_batch(
1478 + output,
1479 + qcurr_hidden_states,
1480 + lora_a_stacked,
1481 + lora_b_stacked,
1482 + topk_weights,
1483 + expert_ids,
1484 + token_lora_mapping,
1485 + top_k_num,
1486 + adapter_enabled,
1487 + mul_routed_weight,
1488 + add_inputs=add_inputs,
1489 + )
1490 + return
1491 + # shrink/expand BLOCK_SIZE_M must match the block_size that
1492 + # moe_lora_align_block_size used; both shrink and expand pass the
1493 + # same value (asserted by `shrink_block_size_m == expand_block_size_m`
1494 + # below).
1495 + _run_fused_moe_lora_one_shot(
1496 + output,
1497 + qcurr_hidden_states,
1498 + lora_a_stacked,
1499 + lora_b_stacked,
1500 + topk_weights,
1501 + sorted_token_ids,
1502 + expert_ids,
1503 + num_tokens_post_padded,
1504 + token_lora_mapping,
1505 + max_lora_rank,
1506 + top_k_num,
1507 + lora_ids,
1508 + num_active_loras,
1509 + adapter_enabled,
1510 + mul_routed_weight,
1511 + shrink_block_size_m,
1512 + add_inputs=add_inputs,
1513 + )
1514 + return
1515 +
1516 + assert add_inputs, (
1517 + "fused_moe_lora(add_inputs=False) is only supported on the "
1518 + "fully_sharded=False fast path"
1519 + )
1520 +
1521 device = qcurr_hidden_states.device
1522 num_slices = len(lora_a_stacked)
1523 w1_lora_b_stacked = lora_b_stacked[0]
@@ -894,6 +1684,7 @@ def _fused_moe_lora_fake(
1684 mul_routed_weight: bool = False,
1685 fully_sharded: bool = False,
1686 offset: int = 0,
1687 + add_inputs: bool = True,
1688 ) -> None:
1689 return
1690
vllm/lora/punica_wrapper/punica_base.py
+2
@@ -514,6 +514,7 @@ class PunicaWrapperBase(PunicaWrapperABC):
514 num_slices: int,
515 fully_sharded: bool,
516 use_tuned_config: bool,
517 + add_inputs: bool = True,
518 token_lora_mapping: torch.Tensor | None = None,
519 ) -> tuple[
520 torch.Tensor | None,
@@ -554,6 +555,7 @@ class PunicaWrapperBase(PunicaWrapperABC):
555 fully_sharded: bool,
556 tp_rank: int,
557 use_tuned_config: bool,
558 + add_inputs: bool = True,
559 ) -> None:
560 """Apply w2 LoRA to y (intermediate_cache3) in-place before moe_sum.
561
vllm/lora/punica_wrapper/punica_gpu.py
+6
@@ -435,6 +435,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
435 fully_sharded: bool = False,
436 offset: int = 0,
437 token_lora_mapping: torch.Tensor | None = None,
438 + add_inputs: bool = True,
439 ):
440 """
441 Performs a fused forward computation for LoRA of Mixture-of-Experts (MoE) layer.
@@ -484,6 +485,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
485 mul_routed_weight,
486 fully_sharded,
487 offset,
488 + add_inputs,
489 )
490
491 def add_lora_w13(
@@ -506,6 +508,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
508 num_slices: int,
509 fully_sharded: bool,
510 use_tuned_config: bool,
511 + add_inputs: bool = True,
512 token_lora_mapping: torch.Tensor | None = None,
513 ) -> tuple[
514 torch.Tensor | None,
@@ -610,6 +613,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
613 adapter_enabled,
614 fully_sharded=fully_sharded,
615 token_lora_mapping=token_lora_mapping,
616 + add_inputs=add_inputs,
617 )
618
619 return (
@@ -640,6 +644,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
644 fully_sharded: bool,
645 tp_rank: int,
646 use_tuned_config: bool,
647 + add_inputs: bool = True,
648 ) -> None:
649 import functools
650
@@ -722,4 +727,5 @@ class PunicaWrapperGPU(PunicaWrapperBase):
727 fully_sharded=fully_sharded,
728 offset=offset,
729 token_lora_mapping=token_lora_mapping,
730 + add_inputs=add_inputs,
731 )
vllm/lora/punica_wrapper/punica_xpu.py
+4
@@ -390,6 +390,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
390 fully_sharded: bool = False,
391 offset: int = 0,
392 token_lora_mapping: torch.Tensor | None = None,
393 + add_inputs: bool = True,
394 ):
395 """
396 Performs a fused forward computation for LoRA of Mixture-of-Experts (MoE) layer.
@@ -439,6 +440,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
440 mul_routed_weight,
441 fully_sharded,
442 offset,
443 + add_inputs,
444 )
445
446 def add_lora_w13(
@@ -461,6 +463,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
463 num_slices: int,
464 fully_sharded: bool,
465 use_tuned_config: bool,
466 + add_inputs: bool = True,
467 token_lora_mapping: torch.Tensor | None = None,
468 ) -> tuple[
469 torch.Tensor | None,
@@ -594,6 +597,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
597 fully_sharded: bool,
598 tp_rank: int,
599 use_tuned_config: bool,
600 + add_inputs: bool = True,
601 ) -> None:
602 import functools
603
vllm/model_executor/layers/fused_moe/experts/lora_context.py
+10
@@ -43,6 +43,16 @@ class MoELoRAContext:
43 # try_get_optimal_moe_lora_config for Triton kernel tile configs.
44 use_tuned_config: bool
45
46 + # Optional dual-stream support for overlapping each (base GEMM, LoRA)
47 + # pair. When aux_stream is None, the experts.apply() path runs the
48 + # original sequential schedule. When set, base GEMM runs on the default
49 + # stream and the LoRA fast-path writes the delta into a fresh buffer on
50 + # aux_stream, which the default stream sums in afterwards.
51 + # Events are paired one-per-overlap-pair: events[0,1] for w13,
52 + # events[2,3] for w2, so the two pairs do not race on the same event.
53 + aux_stream: torch.cuda.Stream | None = None
54 + events: tuple[torch.cuda.Event, ...] | None = None
55 +
56 # Per-rank token→LoRA mapping after EP dispatch. Set by
57 # FusedMoEPrepareAndFinalizeModular.prepare() when EP+LoRA is active, read
58 # by LoRAExpertsMixin helpers in place of punica_wrapper's global mapping.
vllm/model_executor/layers/fused_moe/experts/lora_experts_mixin.py
+4
@@ -45,6 +45,7 @@ class LoRAExpertsMixin:
45 w2: torch.Tensor,
46 num_tokens: int,
47 top_k_num: int,
48 + add_inputs: bool = True,
49 ) -> tuple[
50 torch.Tensor | None,
51 torch.Tensor | None,
@@ -70,6 +71,7 @@ class LoRAExpertsMixin:
71 lora_context.w13_num_slices,
72 lora_context.fully_sharded,
73 lora_context.use_tuned_config,
74 + add_inputs=add_inputs,
75 token_lora_mapping=lora_context.local_token_lora_mapping,
76 )
77
@@ -88,6 +90,7 @@ class LoRAExpertsMixin:
90 w1: torch.Tensor,
91 w2: torch.Tensor,
92 top_k_num: int,
93 + add_inputs: bool = True,
94 ) -> None:
95 lora_context.punica_wrapper.add_lora_w2(
96 y,
@@ -109,4 +112,5 @@ class LoRAExpertsMixin:
112 lora_context.fully_sharded,
113 lora_context.tp_rank,
114 lora_context.use_tuned_config,
115 + add_inputs=add_inputs,
116 )
vllm/model_executor/layers/fused_moe/experts/triton_moe.py
+155 -76
@@ -48,6 +48,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
48 )
49 from vllm.platforms import current_platform
50 from vllm.triton_utils import tl
51 +from vllm.utils.multi_stream_utils import maybe_execute_in_parallel
52
53
54 class TritonExperts(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
@@ -247,55 +248,99 @@ class TritonExperts(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
248 )
249 )
250
250 - invoke_fused_moe_triton_kernel(
251 - hidden_states,
252 - w1,
253 - intermediate_cache1,
254 - a1q_scale if a1q_scale is not None else self.a1_scale,
255 - self.w1_scale,
256 - None, # topk_weights
257 - sorted_token_ids,
258 - expert_ids,
259 - num_tokens_post_padded,
260 - False, # mul_routed_weights
261 - top_k_num,
262 - config,
263 - compute_type=compute_type,
264 - use_fp8_w8a8=self.quant_config.use_fp8_w8a8,
265 - use_int8_w8a8=self.quant_config.use_int8_w8a8,
266 - use_int8_w8a16=self.quant_config.use_int8_w8a16,
267 - use_int4_w4a16=self.quant_config.use_int4_w4a16,
268 - per_channel_quant=self.per_act_token_quant,
269 - block_shape=self.block_shape,
270 - B_bias=self.w1_bias,
271 - )
251 + # LoRA w13: applied to intermediate_cache1 before activation. When
252 + # the LoRA layer requested a dual-stream schedule, we run base w13
253 + # GEMM on the default stream and the LoRA fast-path on aux_stream;
254 + # the LoRA writes its delta into a fresh zero buffer (add_inputs=
255 + # False) and we sum it into intermediate_cache1 after both finish.
256
273 - # LoRA w13: applied to intermediate_cache1 before activation, using
274 - # hidden_states as the lora_a input. moe_lora_align_block_size is
275 - # called once here and results reused for the w2 LoRA below.
257 sorted_token_ids_lora = None
258 expert_ids_lora = None
259 num_tokens_post_padded_lora = None
260 token_lora_mapping = None
261 lora_context = self._lora_context
281 - if lora_context is not None:
262 +
263 + def _base_w13_fn():
264 + invoke_fused_moe_triton_kernel(
265 + hidden_states,
266 + w1,
267 + intermediate_cache1,
268 + a1q_scale if a1q_scale is not None else self.a1_scale,
269 + self.w1_scale,
270 + None, # topk_weights
271 + sorted_token_ids,
272 + expert_ids,
273 + num_tokens_post_padded,
274 + False, # mul_routed_weights
275 + top_k_num,
276 + config,
277 + compute_type=compute_type,
278 + use_fp8_w8a8=self.quant_config.use_fp8_w8a8,
279 + use_int8_w8a8=self.quant_config.use_int8_w8a8,
280 + use_int8_w8a16=self.quant_config.use_int8_w8a16,
281 + use_int4_w4a16=self.quant_config.use_int4_w4a16,
282 + per_channel_quant=self.per_act_token_quant,
283 + block_shape=self.block_shape,
284 + B_bias=self.w1_bias,
285 + )
286 +
287 + if lora_context is not None and lora_context.aux_stream is not None:
288 + # add_inputs=False: kernel overwrites lora_delta_w13. zeros (not
289 + # empty) so untouched rows -- e.g. blocks where every program
290 + # early-exits because lora_id<0 -- stay at zero and the trailing
291 + # add_() is a no-op there.
292 + lora_delta_w13 = torch.zeros_like(intermediate_cache1)
293 +
294 + def _lora_w13_fn():
295 + return self.apply_w13_lora(
296 + lora_context,
297 + y=lora_delta_w13,
298 + x=hidden_states,
299 + topk_ids=topk_ids,
300 + topk_weights=topk_weights,
301 + expert_map=expert_map,
302 + w1=w1,
303 + w2=w2,
304 + num_tokens=num_tokens,
305 + top_k_num=top_k_num,
306 + add_inputs=False,
307 + )
308 +
309 + assert lora_context.events is not None
310 + _, lora_meta = maybe_execute_in_parallel(
311 + _base_w13_fn,
312 + _lora_w13_fn,
313 + lora_context.events[0],
314 + lora_context.events[1],
315 + lora_context.aux_stream,
316 + )
317 (
318 sorted_token_ids_lora,
319 expert_ids_lora,
320 num_tokens_post_padded_lora,
321 token_lora_mapping,
287 - ) = self.apply_w13_lora(
288 - lora_context,
289 - y=intermediate_cache1,
290 - x=hidden_states,
291 - topk_ids=topk_ids,
292 - topk_weights=topk_weights,
293 - expert_map=expert_map,
294 - w1=w1,
295 - w2=w2,
296 - num_tokens=num_tokens,
297 - top_k_num=top_k_num,
298 - )
322 + ) = lora_meta
323 + intermediate_cache1.add_(lora_delta_w13)
324 + else:
325 + _base_w13_fn()
326 + if lora_context is not None:
327 + (
328 + sorted_token_ids_lora,
329 + expert_ids_lora,
330 + num_tokens_post_padded_lora,
331 + token_lora_mapping,
332 + ) = self.apply_w13_lora(
333 + lora_context,
334 + y=intermediate_cache1,
335 + x=hidden_states,
336 + topk_ids=topk_ids,
337 + topk_weights=topk_weights,
338 + expert_map=expert_map,
339 + w1=w1,
340 + w2=w2,
341 + num_tokens=num_tokens,
342 + top_k_num=top_k_num,
343 + )
344
345 a2q_scale: torch.Tensor | None = None
346
@@ -328,48 +373,82 @@ class TritonExperts(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
373 quantization_emulation=self.quantization_emulation,
374 )
375
331 - invoke_fused_moe_triton_kernel(
332 - qintermediate_cache2,
333 - w2,
334 - intermediate_cache3,
335 - a2q_scale,
336 - self.w2_scale,
337 - topk_weights,
338 - sorted_token_ids,
339 - expert_ids,
340 - num_tokens_post_padded,
341 - not apply_router_weight_on_input,
342 - 1,
343 - config,
344 - compute_type=compute_type,
345 - use_fp8_w8a8=self.quant_config.use_fp8_w8a8,
346 - use_int8_w8a8=self.quant_config.use_int8_w8a8,
347 - use_int8_w8a16=self.quant_config.use_int8_w8a16,
348 - use_int4_w4a16=self.quant_config.use_int4_w4a16,
349 - per_channel_quant=self.per_act_token_quant,
350 - block_shape=self.block_shape,
351 - B_bias=self.w2_bias,
352 - )
353 -
376 # LoRA w2: applied to intermediate_cache3 before moe_sum, using the
377 # unquantized intermediate_cache2 as the lora_a input. Reuses the
356 - # sorted_token_ids_lora computed above.
357 - if lora_context is not None:
358 - self.apply_w2_lora(
359 - lora_context,
360 - y=intermediate_cache3,
361 - x=intermediate_cache2,
362 - topk_weights=topk_weights,
363 - sorted_token_ids_lora=sorted_token_ids_lora,
364 - expert_ids_lora=expert_ids_lora,
365 - num_tokens_post_padded_lora=num_tokens_post_padded_lora,
366 - token_lora_mapping=token_lora_mapping,
367 - num_tokens=num_tokens,
368 - w1=w1,
369 - w2=w2,
370 - top_k_num=top_k_num,
378 + # sorted_token_ids_lora computed above. Same dual-stream pattern as
379 + # the w13 pair: base GEMM on default stream, LoRA delta on aux,
380 + # join via .add_() into intermediate_cache3.
381 + def _base_w2_fn():
382 + invoke_fused_moe_triton_kernel(
383 + qintermediate_cache2,
384 + w2,
385 + intermediate_cache3,
386 + a2q_scale,
387 + self.w2_scale,
388 + topk_weights,
389 + sorted_token_ids,
390 + expert_ids,
391 + num_tokens_post_padded,
392 + not apply_router_weight_on_input,
393 + 1,
394 + config,
395 + compute_type=compute_type,
396 + use_fp8_w8a8=self.quant_config.use_fp8_w8a8,
397 + use_int8_w8a8=self.quant_config.use_int8_w8a8,
398 + use_int8_w8a16=self.quant_config.use_int8_w8a16,
399 + use_int4_w4a16=self.quant_config.use_int4_w4a16,
400 + per_channel_quant=self.per_act_token_quant,
401 + block_shape=self.block_shape,
402 + B_bias=self.w2_bias,
403 )
404
405 + if lora_context is not None and lora_context.aux_stream is not None:
406 + lora_delta_w2 = torch.zeros_like(intermediate_cache3)
407 +
408 + def _lora_w2_fn():
409 + self.apply_w2_lora(
410 + lora_context,
411 + y=lora_delta_w2,
412 + x=intermediate_cache2,
413 + topk_weights=topk_weights,
414 + sorted_token_ids_lora=sorted_token_ids_lora,
415 + expert_ids_lora=expert_ids_lora,
416 + num_tokens_post_padded_lora=num_tokens_post_padded_lora,
417 + token_lora_mapping=token_lora_mapping,
418 + num_tokens=num_tokens,
419 + w1=w1,
420 + w2=w2,
421 + top_k_num=top_k_num,
422 + add_inputs=False,
423 + )
424 +
425 + assert lora_context.events is not None
426 + maybe_execute_in_parallel(
427 + _base_w2_fn,
428 + _lora_w2_fn,
429 + lora_context.events[2],
430 + lora_context.events[3],
431 + lora_context.aux_stream,
432 + )
433 + intermediate_cache3.add_(lora_delta_w2)
434 + else:
435 + _base_w2_fn()
436 + if lora_context is not None:
437 + self.apply_w2_lora(
438 + lora_context,
439 + y=intermediate_cache3,
440 + x=intermediate_cache2,
441 + topk_weights=topk_weights,
442 + sorted_token_ids_lora=sorted_token_ids_lora,
443 + expert_ids_lora=expert_ids_lora,
444 + num_tokens_post_padded_lora=num_tokens_post_padded_lora,
445 + token_lora_mapping=token_lora_mapping,
446 + num_tokens=num_tokens,
447 + w1=w1,
448 + w2=w2,
449 + top_k_num=top_k_num,
450 + )
451 +
452 # separate function is required for MoE + LoRA
453 self.moe_sum(intermediate_cache3, output)
454
vllm/model_executor/layers/fused_moe/runner/moe_runner.py
+2
@@ -164,6 +164,8 @@ def _moe_forward_shared_fake(
164 return shared_out, fused_out
165
166
167 +# NOTE: `moe_forward` and `moe_forward_shared` being opaque custom ops is a
168 +# load-bearing assumption for the MoE-LoRA dual-stream path.
169 direct_register_custom_op(
170 op_name="moe_forward",
171 op_func=_moe_forward,