@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / ffe1fc7a28
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 9, 02:30 UTC

Reduce the kernel overhead when num of active loras is smaller than max loras. Multiple cuda graphs are captured for each num of active-loras. (#32005)

Reduce the kernel overhead when num of active loras is smaller than max loras. Multiple cuda graphs are captured for each num of active-loras. (#32005) Signed-off-by: Yu Gong <yu3.gong@gmail.com>

yugong333 committed Feb 2, 2026 at 09:30 UTC ffe1fc7a28841973135b981fb68ce515b409a236
15 files changed +323 -66
tests/lora/test_fused_moe_lora_kernel.py
+10
@@ -181,6 +181,10 @@ def use_fused_moe_lora_kernel(
181 expert_ids = expert_ids.view(max_loras, -1)
182 sorted_token_ids = sorted_token_ids.view(max_loras, -1)
183
184 + # num_active_loras is the number of active LoRAs
185 + # (max_loras + 1 to include no-lora case)
186 + num_active_loras = max_loras + 1
187 +
188 fused_moe_lora(
189 output,
190 hidden_states,
@@ -194,6 +198,7 @@ def use_fused_moe_lora_kernel(
198 max_lora_rank,
199 top_k_num,
200 lora_ids,
201 + num_active_loras,
202 adapter_enabled,
203 config["BLOCK_SIZE_M"],
204 config["BLOCK_SIZE_N"],
@@ -376,6 +381,10 @@ def use_fused_moe_lora_kernel_naive(
381 adapter_enabled = torch.ones(max_loras + 1, dtype=torch.int32)
382 lora_ids = torch.arange(max_loras + 2, dtype=torch.int32)
383
384 + # num_active_loras is the number of active LoRAs
385 + # (max_loras + 1 to include no-lora case)
386 + num_active_loras = max_loras + 1
387 +
388 fused_moe_lora(
389 output,
390 hidden_states,
@@ -389,6 +398,7 @@ def use_fused_moe_lora_kernel_naive(
398 max_lora_rank,
399 top_k_num,
400 lora_ids,
401 + num_active_loras,
402 adapter_enabled,
403 config["BLOCK_SIZE_M"],
404 config["BLOCK_SIZE_N"],
tests/lora/test_punica_ops.py
+2 -2
@@ -161,7 +161,7 @@ def check_lora_shrink_kernel(
161 data.inputs_tensor,
162 data.lora_weights,
163 out_tensor,
164 - *lora_meta.meta_args(token_nums=token_nums),
164 + *lora_meta.meta_args(token_nums=token_nums, specialize_active_lora=False),
165 scaling,
166 )
167
@@ -234,7 +234,7 @@ def check_lora_expand_kernel(
234 data.inputs_tensor,
235 data.lora_weights,
236 out_tensor,
237 - *lora_meta.meta_args(token_nums=token_nums),
237 + *lora_meta.meta_args(token_nums=token_nums, specialize_active_lora=False),
238 offset_start=0,
239 add_inputs=add_inputs,
240 )
tests/v1/cudagraph/test_cudagraph_dispatch.py
+13 -2
@@ -17,6 +17,7 @@ from vllm.config import (
17 SchedulerConfig,
18 VllmConfig,
19 )
20 +from vllm.config.lora import LoRAConfig
21 from vllm.forward_context import BatchDescriptor, set_forward_context
22 from vllm.platforms import current_platform
23 from vllm.v1.cudagraph_dispatcher import CudagraphDispatcher
@@ -47,6 +48,12 @@ def _create_vllm_config(
48 mock_config.speculative_config = None # No speculative decoding
49 if not lora_config:
50 mock_config.lora_config = None
51 + else:
52 + # Create a real LoRAConfig with specialize_active_lora enabled
53 + mock_config.lora_config = LoRAConfig(
54 + max_loras=4,
55 + specialize_active_lora=True,
56 + )
57 # Mimic the behavior of VllmConfig.__post_init__()
58 if compilation_config.mode == CompilationMode.VLLM_COMPILE:
59 compilation_config.set_splitting_ops_for_v1(
@@ -106,15 +113,19 @@ class TestCudagraphDispatcher:
113 )
114
115 # Verify the key is initialized correctly
116 + # With LoRA specialization (max_loras=4, specialize_active_lora=True):
117 + # - lora_cases = [0, 1, 2, 4, 5] (no-lora + powers of 2 up to 4 + max_loras+1)
118 + # - capture_sizes = [1, 8]
119 + # - Total keys = 2 sizes × 5 lora_cases = 10
120 if cudagraph_mode_str in ["FULL_AND_PIECEWISE", "PIECEWISE"]:
121 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.PIECEWISE]) == (
111 - 4 if lora_config else 2
122 + 10 if lora_config else 2
123 )
124 else:
125 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.PIECEWISE]) == 0
126 if cudagraph_mode_str not in ["NONE", "PIECEWISE"]:
127 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.FULL]) == (
117 - 4 if lora_config else 2
128 + 10 if lora_config else 2
129 )
130 else:
131 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.FULL]) == 0
vllm/config/lora.py
+7
@@ -60,6 +60,13 @@ class LoRAConfig:
60 of multimodal models will be enabled. This is an experimental feature and
61 currently only supports some MM models such as the Qwen VL series. The default
62 is False."""
63 + specialize_active_lora: bool = False
64 + """Whether to construct lora kernel grid by the number of active LoRA adapters.
65 + When set to True, separate cuda graphs will be captured for different counts
66 + of active LoRAs (powers of 2 up to max_loras), which can improve performance
67 + for variable LoRA usage patterns at the cost of increased startup time and
68 + memory usage. Only takes effect when cudagraph_specialize_lora is True.
69 + """
70
71 def compute_hash(self) -> str:
72 """
vllm/engine/arg_utils.py
+5
@@ -485,6 +485,7 @@ class EngineArgs:
485 max_cpu_loras: int | None = LoRAConfig.max_cpu_loras
486 lora_dtype: str | torch.dtype | None = LoRAConfig.lora_dtype
487 enable_tower_connector_lora: bool = LoRAConfig.enable_tower_connector_lora
488 + specialize_active_lora: bool = LoRAConfig.specialize_active_lora
489
490 ray_workers_use_nsight: bool = ParallelConfig.ray_workers_use_nsight
491 num_gpu_blocks_override: int | None = CacheConfig.num_gpu_blocks_override
@@ -1026,6 +1027,9 @@ class EngineArgs:
1027 "--fully-sharded-loras", **lora_kwargs["fully_sharded_loras"]
1028 )
1029 lora_group.add_argument("--default-mm-loras", **lora_kwargs["default_mm_loras"])
1030 + lora_group.add_argument(
1031 + "--specialize-active-lora", **lora_kwargs["specialize_active_lora"]
1032 + )
1033
1034 # Observability arguments
1035 observability_kwargs = get_kwargs(ObservabilityConfig)
@@ -1657,6 +1661,7 @@ class EngineArgs:
1661 fully_sharded_loras=self.fully_sharded_loras,
1662 lora_dtype=self.lora_dtype,
1663 enable_tower_connector_lora=self.enable_tower_connector_lora,
1664 + specialize_active_lora=self.specialize_active_lora,
1665 max_cpu_loras=self.max_cpu_loras
1666 if self.max_cpu_loras and self.max_cpu_loras > 0
1667 else None,
vllm/forward_context.py
+13 -1
@@ -47,6 +47,14 @@ class BatchDescriptor(NamedTuple):
47 """
48 Whether this batch has active LoRA adapters.
49 """
50 + num_active_loras: int = 0
51 + """
52 + Number of distinct active LoRA adapters in this batch.
53 + When cudagraph_specialize_lora_count is enabled, separate CUDA graphs
54 + are captured for each num_active_loras value. This allows kernels
55 + (like fused_moe_lora) whose grid size depends on num_active_loras
56 + to be properly captured.
57 + """
58
59 def relax_for_mixed_batch_cudagraphs(self) -> "BatchDescriptor":
60 """
@@ -54,7 +62,11 @@ class BatchDescriptor(NamedTuple):
62 with PIECEWISE cudagraphs (or mixed prefill-decode FA cudagraphs).
63 """
64 return BatchDescriptor(
57 - self.num_tokens, num_reqs=None, uniform=False, has_lora=self.has_lora
65 + self.num_tokens,
66 + num_reqs=None,
67 + uniform=False,
68 + has_lora=self.has_lora,
69 + num_active_loras=self.num_active_loras,
70 )
71
72
vllm/lora/ops/triton_ops/fused_moe_lora_op.py
+16 -4
@@ -95,7 +95,7 @@ def _get_ptr(lora_weights: list[torch.Tensor], device: torch.device):
95
96
97 def _adjust_kernel_inputs(
98 - max_loras: int,
98 + num_active_loras: int,
99 sorted_token_ids: torch.Tensor | None,
100 expert_ids: torch.Tensor,
101 ):
@@ -109,7 +109,7 @@ def _adjust_kernel_inputs(
109 else:
110 stride_tl = sorted_token_ids.stride(0)
111 stride_el = expert_ids.stride(0)
112 - grid_lora_dim = max_loras + 1
112 + grid_lora_dim = num_active_loras
113 return grid_lora_dim, stride_tl, stride_el
114
115
@@ -354,6 +354,7 @@ def _fused_moe_lora_shrink(
354 num_warps: int,
355 num_stages: int,
356 split_k: int,
357 + num_active_loras: int,
358 mul_routed_weight: bool = False,
359 use_gdc: bool = False,
360 ) -> None:
@@ -373,7 +374,7 @@ def _fused_moe_lora_shrink(
374 b_ptr = _get_ptr(lora_a_stacked, device)
375
376 grid_lora_dim, stride_tl, stride_el = _adjust_kernel_inputs(
376 - w1_lora_a_stacked.shape[0], sorted_token_ids, expert_ids
377 + num_active_loras, sorted_token_ids, expert_ids
378 )
379 grid = lambda META: (
380 split_k
@@ -457,6 +458,7 @@ def _fused_moe_lora_expand(
458 num_warps: int,
459 num_stages: int,
460 split_k: int,
461 + num_active_loras: int,
462 mul_routed_weight: bool = False,
463 offset: int = 0,
464 use_gdc: bool = False,
@@ -484,7 +486,7 @@ def _fused_moe_lora_expand(
486 }
487
488 grid_lora_dim, stride_tl, stride_el = _adjust_kernel_inputs(
487 - w1_lora_b_stacked.shape[0], sorted_token_ids, expert_ids
489 + num_active_loras, sorted_token_ids, expert_ids
490 )
491
492 grid = lambda META: (
@@ -557,6 +559,7 @@ def _fused_moe_lora(
559 max_lora_rank: int,
560 top_k_num: int,
561 lora_ids: torch.Tensor,
562 + num_active_loras: int,
563 adapter_enabled: torch.Tensor,
564 shrink_block_size_m: int,
565 shrink_block_size_n: int,
@@ -648,6 +651,7 @@ def _fused_moe_lora(
651 shrink_num_warps,
652 shrink_num_stages,
653 shrink_split_k,
654 + num_active_loras,
655 mul_routed_weight,
656 use_gdc=use_gdc,
657 )
@@ -695,6 +699,7 @@ def _fused_moe_lora(
699 expand_num_warps,
700 expand_num_stages,
701 expand_split_k,
702 + num_active_loras,
703 mul_routed_weight,
704 offset,
705 use_gdc=use_gdc,
@@ -714,6 +719,7 @@ def _fused_moe_lora_fake(
719 max_lora_rank: int,
720 top_k_num: int,
721 lora_ids: torch.Tensor,
722 + num_active_loras: int,
723 adapter_enabled: torch.Tensor,
724 shrink_block_size_m: int,
725 shrink_block_size_n: int,
@@ -730,6 +736,8 @@ def _fused_moe_lora_fake(
736 expand_num_stages: int,
737 expand_split_k: int,
738 mul_routed_weight: bool = False,
739 + fully_sharded: bool = False,
740 + offset: int = 0,
741 ) -> None:
742 return
743
@@ -761,6 +769,7 @@ def _fused_moe_lora_shrink_fake(
769 num_warps: int,
770 num_stages: int,
771 split_k: int,
772 + num_active_loras: int,
773 mul_routed_weight: bool = False,
774 use_gdc: bool = False,
775 ) -> None:
@@ -770,6 +779,7 @@ def _fused_moe_lora_shrink_fake(
779 def _fused_moe_lora_expand_fake(
780 output: torch.Tensor,
781 a_intermediate_cache1: torch.Tensor,
782 + b_intermediate_cache1: torch.Tensor,
783 lora_b_stacked: list[torch.Tensor],
784 topk_weights: torch.Tensor,
785 sorted_token_ids: torch.Tensor | None,
@@ -796,7 +806,9 @@ def _fused_moe_lora_expand_fake(
806 num_warps: int,
807 num_stages: int,
808 split_k: int,
809 + num_active_loras: int,
810 mul_routed_weight: bool = False,
811 + offset: int = 0,
812 use_gdc: bool = False,
813 ) -> None:
814 return
vllm/lora/ops/triton_ops/lora_expand_op.py
+3 -4
@@ -138,6 +138,7 @@ def _lora_expand(
138 lora_token_start_loc: torch.Tensor, # shape [max-loras + 2]
139 lora_ids: torch.Tensor, # shape [max-loras + 1]
140 no_lora_flag_cpu: torch.Tensor, # shape [1]
141 + num_active_loras: int, # number of active LoRAs (unused here, for API compat)
142 offset_start: int = 0,
143 add_inputs: bool = False,
144 ) -> None:
@@ -234,10 +235,7 @@ def _lora_expand(
235 grid = (
236 triton.cdiv(M, BLOCK_M) * triton.cdiv(MAX_N, BLOCK_N),
237 NUM_SLICES,
237 - # Each LoRA receives its own set of thread blocks for output
238 - # computation. If some LoRA doesn't have any tokens to process, its
239 - # thread blocks simply exit.
240 - MAX_LORAS,
238 + num_active_loras,
239 )
240 # We disable PDL temporarily because LoRA kernels are not launching back-to-back,
241 # making PDL invalid and affecting the kernel performance.
@@ -291,6 +289,7 @@ def _lora_expand_fake(
289 lora_token_start_loc: torch.Tensor,
290 lora_ids: torch.Tensor,
291 no_lora_flag_cpu: torch.Tensor,
292 + num_active_loras: int,
293 offset_start: int = 0,
294 add_inputs: bool = False,
295 ) -> None:
vllm/lora/ops/triton_ops/lora_kernel_metadata.py
+36 -3
@@ -4,7 +4,8 @@
4 LoRA kernels metadata preparation utilities.
5 """
6
7 -from dataclasses import dataclass
7 +import bisect
8 +from dataclasses import dataclass, field
9
10 import torch
11
@@ -28,9 +29,22 @@ class LoRAKernelMeta:
29 # to early exit from inside the lora_expand / lora_shrink torch operation.
30 no_lora_flag_cpu: torch.Tensor
31
32 + # Number of active LoRAs (unique non-(-1) values in token_lora_mapping)
33 + # Stored as a Python int to avoid GPU->CPU sync during forward pass
34 + num_active_loras: int = 0
35 +
36 + # Captured LoRA counts for cudagraph specialization (sorted list).
37 + # When specialize_active_lora is enabled, num_active_loras is rounded up
38 + # to the nearest value in this list to match cudagraph capture keys.
39 + # Empty list means no specialization (use actual count).
40 + captured_lora_counts: list[int] = field(default_factory=list)
41 +
42 @staticmethod
43 def make(
33 - max_loras: int, max_num_tokens: int, device: torch.device | str
44 + max_loras: int,
45 + max_num_tokens: int,
46 + device: torch.device | str,
47 + captured_lora_counts: list[int] | None = None,
48 ) -> "LoRAKernelMeta":
49 token_lora_mapping = torch.empty(
50 max_num_tokens, dtype=torch.int32, device=device
@@ -66,6 +80,9 @@ class LoRAKernelMeta:
80 num_tokens_per_lora=num_tokens_per_lora,
81 lora_token_start_loc=lora_token_start_loc,
82 no_lora_flag_cpu=no_lora_flag_cpu,
83 + captured_lora_counts=sorted(captured_lora_counts)
84 + if captured_lora_counts
85 + else [],
86 )
87
88 def _reset(self):
@@ -73,6 +90,8 @@ class LoRAKernelMeta:
90 self.num_tokens_per_lora.fill_(0)
91 self.lora_token_start_loc.fill_(0)
92 self.no_lora_flag_cpu.fill_(False)
93 + self.num_active_loras = 0
94 + self.captured_lora_counts = []
95
96 def prepare_tensors(self, token_lora_mapping: torch.Tensor) -> None:
97 """
@@ -118,6 +137,15 @@ class LoRAKernelMeta:
137 num_tokens_per_lora, non_blocking=True
138 )
139
140 + self.num_active_loras = lora_ids.size(0)
141 +
142 + # Round up num_active_loras to match cudagraph capture keys.
143 + # This ensures the kernel grid dimension matches the captured graph.
144 + if self.captured_lora_counts and self.num_active_loras > 0:
145 + idx = bisect.bisect_left(self.captured_lora_counts, self.num_active_loras)
146 + if idx < len(self.captured_lora_counts):
147 + self.num_active_loras = self.captured_lora_counts[idx]
148 +
149 # lora_token_start_loc
150 lora_token_start_loc = torch.cumsum(num_tokens_per_lora, dim=0)
151 self.lora_token_start_loc[1 : 1 + lora_token_start_loc.size(0)].copy_(
@@ -125,7 +153,9 @@ class LoRAKernelMeta:
153 )
154
155 def meta_args(
128 - self, token_nums: int
156 + self,
157 + token_nums: int,
158 + specialize_active_lora: bool,
159 ) -> tuple[
160 torch.Tensor,
161 torch.Tensor,
@@ -133,6 +163,7 @@ class LoRAKernelMeta:
163 torch.Tensor,
164 torch.Tensor,
165 torch.Tensor,
166 + int,
167 ]:
168 """
169 This function returns the kernel metadata required for the current
@@ -144,6 +175,7 @@ class LoRAKernelMeta:
175 token_nums (int): Number of input tokens in the current forward
176 pass of the kernel.
177 """
178 + max_loras = self.active_lora_ids.size(0) - 1
179 return (
180 self.token_lora_mapping[:token_nums],
181 self.token_indices_sorted_by_lora_ids[:token_nums],
@@ -151,4 +183,5 @@ class LoRAKernelMeta:
183 self.lora_token_start_loc,
184 self.active_lora_ids,
185 self.no_lora_flag_cpu,
186 + self.num_active_loras if specialize_active_lora else max_loras + 1,
187 )
vllm/lora/ops/triton_ops/lora_shrink_op.py
+3 -4
@@ -134,6 +134,7 @@ def _lora_shrink(
134 lora_token_start_loc: torch.Tensor, # shape [max-loras + 2]
135 lora_ids: torch.Tensor, # shape [max-loras + 1]
136 no_lora_flag_cpu: torch.Tensor, # shape [1]
137 + num_active_loras: int, # number of active LoRAs (unused here, for API compat)
138 scaling: float,
139 ) -> None:
140 """
@@ -214,10 +215,7 @@ def _lora_shrink(
215 grid = (
216 SPLIT_K * triton.cdiv(M, BLOCK_M) * triton.cdiv(N, BLOCK_N),
217 NUM_SLICES,
217 - # Each LoRA receives its own set of thread blocks for output
218 - # computation. If some LoRA doesn't have any tokens to process, its
219 - # thread blocks exit early.
220 - MAX_LORAS,
218 + num_active_loras,
219 )
220 # We disable PDL temporarily because LoRA kernels are not launching back-to-back,
221 # making PDL invalid and affecting the kernel performance.
@@ -269,6 +267,7 @@ def _lora_shrink_fake(
267 lora_token_start_loc: torch.Tensor,
268 lora_ids: torch.Tensor,
269 no_lora_flag_cpu: torch.Tensor,
270 + num_active_loras: int,
271 scaling: float,
272 ) -> None:
273 return
vllm/lora/punica_wrapper/punica_gpu.py
+38 -10
@@ -12,6 +12,7 @@ from typing import final
12 import torch
13
14 from vllm.lora.layers import LoRAMapping
15 +from vllm.lora.utils import get_captured_lora_counts
16 from vllm.triton_utils import HAS_TRITON, triton
17 from vllm.utils.math_utils import round_up
18
@@ -48,8 +49,16 @@ class PunicaWrapperGPU(PunicaWrapperBase):
49 self.lora_config = kwargs["lora_config"]
50 self.max_loras = self.lora_config.max_loras
51
52 + # Compute captured LoRA counts for cudagraph specialization.
53 + captured_lora_counts = get_captured_lora_counts(
54 + self.max_loras, self.lora_config.specialize_active_lora
55 + )
56 +
57 self.token_mapping_meta = LoRAKernelMeta.make(
52 - self.max_loras, max_num_batched_tokens, device=device
58 + self.max_loras,
59 + max_num_batched_tokens,
60 + device=device,
61 + captured_lora_counts=captured_lora_counts,
62 )
63
64 # When speculative decoding is enabled, max_num_samples is
@@ -57,7 +66,10 @@ class PunicaWrapperGPU(PunicaWrapperBase):
66 # This line can be optimized by replacing max_num_batched_tokens
67 # to max_batches * (num_speculative_decoding_tokens + 1).
68 self.prompt_mapping_meta = LoRAKernelMeta.make(
60 - self.max_loras, max_num_batched_tokens, device=device
69 + self.max_loras,
70 + max_num_batched_tokens,
71 + device=device,
72 + captured_lora_counts=captured_lora_counts,
73 )
74
75 def update_metadata(
@@ -102,7 +114,9 @@ class PunicaWrapperGPU(PunicaWrapperBase):
114 x,
115 lora_a_stacked,
116 y,
105 - *self.token_mapping_meta.meta_args(x.size(0)),
117 + *self.token_mapping_meta.meta_args(
118 + x.size(0), self.lora_config.specialize_active_lora
119 + ),
120 scale,
121 )
122
@@ -143,7 +157,9 @@ class PunicaWrapperGPU(PunicaWrapperBase):
157 x,
158 lora_b_stacked,
159 y,
146 - *self.token_mapping_meta.meta_args(num_tokens),
160 + *self.token_mapping_meta.meta_args(
161 + num_tokens, self.lora_config.specialize_active_lora
162 + ),
163 offset_start=offset_start,
164 add_inputs=True,
165 )
@@ -175,7 +191,9 @@ class PunicaWrapperGPU(PunicaWrapperBase):
191 x.unsqueeze(dim=0),
192 (lora_b_stacked,),
193 y,
178 - *self.token_mapping_meta.meta_args(x.size(0)),
194 + *self.token_mapping_meta.meta_args(
195 + x.size(0), self.lora_config.specialize_active_lora
196 + ),
197 offset_start=0,
198 add_inputs=add_inputs,
199 )
@@ -287,7 +305,9 @@ class PunicaWrapperGPU(PunicaWrapperBase):
305 x,
306 [lora_a_stacked],
307 buffer.unsqueeze(dim=0),
290 - *self.prompt_mapping_meta.meta_args(x.size(0)),
308 + *self.prompt_mapping_meta.meta_args(
309 + x.size(0), self.lora_config.specialize_active_lora
310 + ),
311 scale,
312 )
313
@@ -295,7 +315,9 @@ class PunicaWrapperGPU(PunicaWrapperBase):
315 buffer.unsqueeze(dim=0),
316 [lora_b_stacked],
317 y,
298 - *self.prompt_mapping_meta.meta_args(buffer.size(0)),
318 + *self.prompt_mapping_meta.meta_args(
319 + buffer.size(0), self.lora_config.specialize_active_lora
320 + ),
321 add_inputs=True,
322 )
323 y = y.view_as(y_org)
@@ -316,8 +338,10 @@ class PunicaWrapperGPU(PunicaWrapperBase):
338 Aligns tokens and experts into block-sized chunks for LoRA-based
339 mixture-of-experts (MoE) execution.
340 """
319 - (token_lora_mapping, _, _, _, lora_ids, _) = self.token_mapping_meta.meta_args(
320 - num_tokens
341 + (token_lora_mapping, _, _, _, lora_ids, _, _) = (
342 + self.token_mapping_meta.meta_args(
343 + num_tokens, self.lora_config.specialize_active_lora
344 + )
345 )
346 if naive_block_assignment:
347 expert_ids = topk_ids.reshape(-1)
@@ -392,7 +416,10 @@ class PunicaWrapperGPU(PunicaWrapperBase):
416 _,
417 lora_ids,
418 _,
395 - ) = self.token_mapping_meta.meta_args(x.size(0))
419 + num_active_loras,
420 + ) = self.token_mapping_meta.meta_args(
421 + x.size(0), self.lora_config.specialize_active_lora
422 + )
423 if token_lora_mapping is None:
424 token_lora_mapping = token_lora_mapping_meta
425 fused_moe_lora(
@@ -408,6 +435,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
435 max_lora_rank,
436 top_k_num,
437 lora_ids,
438 + num_active_loras,
439 adapter_enabled,
440 shrink_config.get("BLOCK_SIZE_M", 64),
441 shrink_config.get("BLOCK_SIZE_N", 64),
vllm/lora/utils.py
+19
@@ -44,6 +44,25 @@ if TYPE_CHECKING:
44
45 logger = init_logger(__name__)
46
47 +
48 +def get_captured_lora_counts(max_loras: int, specialize: bool) -> list[int]:
49 + """
50 + Returns num_active_loras values for cudagraph capture.
51 +
52 + When specialize=True: powers of 2 up to max_loras, plus max_loras + 1.
53 + When specialize=False: just [max_loras + 1].
54 +
55 + This is the single source of truth for LoRA capture cases, used by both
56 + CudagraphDispatcher and PunicaWrapperGPU.
57 + """
58 + if not specialize:
59 + return [max_loras + 1]
60 +
61 + return [
62 + n for n in range(1, max_loras + 2) if (n & (n - 1)) == 0 or n == max_loras + 1
63 + ]
64 +
65 +
66 _GLOBAL_LORA_ID = 0
67
68
vllm/v1/cudagraph_dispatcher.py
+65 -14
@@ -5,6 +5,7 @@ from itertools import product
5 from vllm.config import CUDAGraphMode, VllmConfig
6 from vllm.forward_context import BatchDescriptor
7 from vllm.logger import init_logger
8 +from vllm.lora.utils import get_captured_lora_counts
9
10 logger = init_logger(__name__)
11
@@ -57,6 +58,11 @@ class CudagraphDispatcher:
58 )
59
60 self.keys_initialized = False
61 + self.specialize_lora_count = (
62 + self.vllm_config.lora_config.specialize_active_lora
63 + if self.vllm_config.lora_config is not None
64 + else False
65 + )
66 # Default cudagraph_mode to NONE until initialize_cudagraph_keys is called
67 self.cudagraph_mode = CUDAGraphMode.NONE
68
@@ -92,8 +98,33 @@ class CudagraphDispatcher:
98 "Use values from cudagraph_capture_sizes."
99 )
100
101 + def _get_lora_cases(self) -> list[int]:
102 + """
103 + Returns list of has_lora values for CUDA graph capture.
104 + This is the single source of truth for LoRA capture cases.
105 + """
106 + lora_config = self.vllm_config.lora_config
107 + if lora_config is None:
108 + # No LoRA configured - single case with no LoRA
109 + return [0]
110 +
111 + # LoRA is enabled - capture graphs based on cudagraph_specialize_lora
112 + if self.compilation_config.cudagraph_specialize_lora:
113 + captured_counts = get_captured_lora_counts(
114 + lora_config.max_loras, self.specialize_lora_count
115 + )
116 + # Specialize: capture separate graphs for with and without LoRA
117 + return [0] + captured_counts
118 + else:
119 + # No specialization: only capture graphs with LoRA active
120 + return [lora_config.max_loras + 1]
121 +
122 def _create_padded_batch_descriptor(
96 - self, num_tokens: int, uniform_decode: bool, has_lora: bool
123 + self,
124 + num_tokens: int,
125 + uniform_decode: bool,
126 + has_lora: bool,
127 + num_active_loras: int = 0,
128 ) -> BatchDescriptor:
129 max_num_seqs = self.vllm_config.scheduler_config.max_num_seqs
130 uniform_decode_query_len = self.uniform_decode_query_len
@@ -111,6 +142,7 @@ class CudagraphDispatcher:
142 num_reqs=num_reqs,
143 uniform=uniform_decode,
144 has_lora=has_lora,
145 + num_active_loras=num_active_loras,
146 )
147
148 def add_cudagraph_key(
@@ -135,26 +167,23 @@ class CudagraphDispatcher:
167
168 self._compute_bs_to_padded_graph_size()
169
138 - # LoRA activation cases to specialize the cuda graphs on
139 - if self.vllm_config.lora_config:
140 - if self.compilation_config.cudagraph_specialize_lora:
141 - lora_cases = [True, False]
142 - else:
143 - lora_cases = [True]
144 - else:
145 - lora_cases = [False]
170 + # Get LoRA cases to capture
171 + lora_cases = self._get_lora_cases()
172 + self.captured_lora_counts = [
173 + lora_count for lora_count in lora_cases if lora_count
174 + ]
175
176 # Note: we create all valid keys for cudagraph here but do not
177 # guarantee all keys would be used. For example, if we allow lazy
178 # capturing in future PR, some keys may never be triggered.
179 if cudagraph_mode.mixed_mode() != CUDAGraphMode.NONE:
151 - for bs, has_lora in product(
180 + for bs, num_active_loras in product(
181 self.compilation_config.cudagraph_capture_sizes, lora_cases
182 ):
183 self.add_cudagraph_key(
184 cudagraph_mode.mixed_mode(),
185 self._create_padded_batch_descriptor(
157 - bs, False, has_lora
186 + bs, False, num_active_loras > 0, num_active_loras
187 ).relax_for_mixed_batch_cudagraphs(),
188 )
189
@@ -173,10 +202,14 @@ class CudagraphDispatcher:
202 for x in self.compilation_config.cudagraph_capture_sizes
203 if x <= max_num_tokens and x >= uniform_decode_query_len
204 ]
176 - for bs, has_lora in product(cudagraph_capture_sizes_for_decode, lora_cases):
205 + for bs, num_active_loras in product(
206 + cudagraph_capture_sizes_for_decode, lora_cases
207 + ):
208 self.add_cudagraph_key(
209 CUDAGraphMode.FULL,
179 - self._create_padded_batch_descriptor(bs, True, has_lora),
210 + self._create_padded_batch_descriptor(
211 + bs, True, num_active_loras > 0, num_active_loras
212 + ),
213 )
214
215 self.keys_initialized = True
@@ -187,6 +220,7 @@ class CudagraphDispatcher:
220 uniform_decode: bool = False,
221 has_lora: bool = False,
222 disable_full: bool = False,
223 + num_active_loras: int = 0,
224 ) -> tuple[CUDAGraphMode, BatchDescriptor]:
225 """
226 Given conditions(e.g.,batch descriptor and if using piecewise only),
@@ -202,6 +236,7 @@ class CudagraphDispatcher:
236 disable_full: If True, skip FULL cudagraph checks and
237 return PIECEWISE or NONE only. (can be used for features like
238 cascade attention that are not supported by full cudagraphs)
239 + num_active_loras: Number of distinct active LoRA adapters.
240 """
241 if (
242 not self.keys_initialized
@@ -210,8 +245,24 @@ class CudagraphDispatcher:
245 ):
246 return CUDAGraphMode.NONE, BatchDescriptor(num_tokens)
247
248 + effective_num_active_loras = num_active_loras
249 + if has_lora and num_active_loras > 0:
250 + if self.specialize_lora_count:
251 + # Find the smallest captured `num_active_loras` that is >= the current
252 + # `num_active_loras`. This is because we only capture graphs for
253 + # a subset of possible `num_active_loras` values (powers of 2).
254 + import bisect
255 +
256 + idx = bisect.bisect_left(self.captured_lora_counts, num_active_loras)
257 + if idx < len(self.captured_lora_counts):
258 + effective_num_active_loras = self.captured_lora_counts[idx]
259 + else:
260 + # When not specializing, graphs are captured only with max_loras + 1,
261 + # so we must use max_loras + 1 for dispatch to find a matching graph.
262 + effective_num_active_loras = self.vllm_config.lora_config.max_loras + 1
263 +
264 batch_desc = self._create_padded_batch_descriptor(
214 - num_tokens, uniform_decode, has_lora
265 + num_tokens, uniform_decode, has_lora, effective_num_active_loras
266 )
267 relaxed_batch_desc = batch_desc.relax_for_mixed_batch_cudagraphs()
268
vllm/v1/worker/gpu_model_runner.py
+23 -12
@@ -3082,6 +3082,7 @@ class GPUModelRunner(
3082 # be improved in model runner v2)
3083 force_uniform_decode: bool | None = None,
3084 force_has_lora: bool | None = None,
3085 + force_num_active_loras: int | None = None,
3086 num_encoder_reqs: int = 0,
3087 ) -> tuple[
3088 CUDAGraphMode,
@@ -3103,11 +3104,13 @@ class GPUModelRunner(
3104 self.model_config.is_encoder_decoder and num_encoder_reqs > 0
3105 )
3106
3106 - has_lora = (
3107 - len(self.input_batch.lora_id_to_lora_request) > 0
3108 - if force_has_lora is None
3109 - else force_has_lora
3107 + # Compute LoRA state for cudagraph dispatch
3108 + num_active_loras = (
3109 + force_num_active_loras
3110 + if force_num_active_loras is not None
3111 + else len(self.input_batch.lora_id_to_lora_request)
3112 )
3113 + has_lora = num_active_loras > 0 if force_has_lora is None else force_has_lora
3114
3115 num_tokens_padded = self._pad_for_sequence_parallelism(num_tokens)
3116 dispatch_cudagraph = (
@@ -3116,6 +3119,7 @@ class GPUModelRunner(
3119 has_lora=has_lora,
3120 uniform_decode=uniform_decode,
3121 disable_full=disable_full,
3122 + num_active_loras=num_active_loras,
3123 )
3124 if not force_eager
3125 else (CUDAGraphMode.NONE, BatchDescriptor(num_tokens_padded))
@@ -4606,8 +4610,8 @@ class GPUModelRunner(
4610 is_profile: bool = False,
4611 create_mixed_batch: bool = False,
4612 remove_lora: bool = True,
4609 - activate_lora: bool = False,
4613 is_graph_capturing: bool = False,
4614 + num_active_loras: int = 0,
4615 ) -> tuple[torch.Tensor, torch.Tensor]:
4616 """
4617 Run a dummy forward pass to warm up/profile run or capture the
@@ -4630,7 +4634,8 @@ class GPUModelRunner(
4634 create_mixed_batch: If True, create a mixed batch with both decode
4635 (1 token) and prefill (multiple tokens) requests.
4636 remove_lora: If False, dummy LoRAs are not destroyed after the run
4633 - activate_lora: If False, dummy_run is performed without LoRAs.
4637 + num_active_loras: Number of distinct active LoRAs to capture for.
4638 + LoRA is activated when num_active_loras > 0.
4639 """
4640 mm_config = self.vllm_config.model_config.multimodal_config
4641 if mm_config and mm_config.mm_encoder_only:
@@ -4712,7 +4717,10 @@ class GPUModelRunner(
4717 # `force_has_lora` is used for cudagraph capture; because LoRA is
4718 # activated later in the context manager, but we need to know the
4719 # LoRA state when determining the batch descriptor for capture
4715 - force_has_lora=activate_lora,
4720 + force_has_lora=num_active_loras > 0,
4721 + # `force_num_active_loras` is used for cudagraph capture; because we
4722 + # need to capture graphs for specific num_active_loras counts
4723 + force_num_active_loras=num_active_loras,
4724 )
4725 )
4726
@@ -4782,8 +4790,8 @@ class GPUModelRunner(
4790 self.lora_config,
4791 num_scheduled_tokens,
4792 num_sampled_tokens,
4785 - activate_lora,
4793 remove_lora,
4794 + num_active_loras,
4795 ):
4796 # Make sure padding doesn't exceed max_num_tokens
4797 assert num_tokens_padded <= self.max_num_tokens
@@ -4884,7 +4892,10 @@ class GPUModelRunner(
4892 # lora cases when cudagraph_specialize_lora is enabled. This is a
4893 # short term mitigation for issue mentioned in
4894 # https://github.com/vllm-project/vllm/issues/28334
4887 - if self.compilation_config.cudagraph_specialize_lora and activate_lora:
4895 + if (
4896 + self.compilation_config.cudagraph_specialize_lora
4897 + and num_active_loras > 0
4898 + ):
4899 use_cudagraphs = False
4900
4901 self.drafter.dummy_run(
@@ -5259,7 +5270,7 @@ class GPUModelRunner(
5270 # We skip EPLB here since we don't want to record dummy metrics
5271 for batch_desc in batch_descriptors:
5272 num_tokens = batch_desc.num_tokens
5262 - activate_lora = batch_desc.has_lora
5273 + num_active_loras = batch_desc.num_active_loras
5274
5275 # We currently only capture ubatched graphs when its a FULL
5276 # cudagraph, a uniform decode batch, and the number of tokens
@@ -5286,7 +5297,7 @@ class GPUModelRunner(
5297 num_tokens,
5298 cudagraph_runtime_mode=CUDAGraphMode.NONE,
5299 allow_microbatching=allow_microbatching,
5289 - activate_lora=activate_lora,
5300 + num_active_loras=num_active_loras,
5301 )
5302
5303 # Capture run
@@ -5294,7 +5305,7 @@ class GPUModelRunner(
5305 num_tokens,
5306 cudagraph_runtime_mode=cudagraph_runtime_mode,
5307 allow_microbatching=allow_microbatching,
5297 - activate_lora=activate_lora,
5308 + num_active_loras=num_active_loras,
5309 is_graph_capturing=True,
5310 )
5311 self.maybe_remove_all_loras(self.lora_config)
vllm/v1/worker/lora_model_runner_mixin.py
+70 -10
@@ -133,11 +133,23 @@ class LoRAModelRunnerMixin:
133 num_scheduled_tokens: np.ndarray,
134 mapping_type: LoRAMappingType = LoRAMappingType.LANGUAGE,
135 num_sampled_tokens: np.ndarray | None = None,
136 - activate_lora: bool = True,
136 + num_active_loras: int = 0,
137 ):
138 + """
139 + Context manager to select dummy LoRAs for capture/warmup.
140 +
141 + Args:
142 + lora_config: LoRA configuration, or None if LoRA is disabled.
143 + num_scheduled_tokens: Array of scheduled token counts per request.
144 + num_sampled_tokens: Array of sampled token counts per request.
145 + num_active_loras: Number of distinct active LoRAs to use.
146 + - 0: No LoRA active (set up zero mappings).
147 + - >0: Use exactly this many distinct LoRAs.
148 + """
149 if num_sampled_tokens is None:
150 num_sampled_tokens = np.ones_like(num_scheduled_tokens, dtype=np.int32)
151
152 + # Skip LoRA setup entirely only if no LoRA config
153 if lora_config is None:
154 yield
155 else:
@@ -145,15 +157,52 @@ class LoRAModelRunnerMixin:
157 assert self.lora_manager is not None, "LoRA is not enabled"
158
159 num_reqs = len(num_scheduled_tokens)
148 - num_loras = lora_config.max_loras
160 + max_loras = lora_config.max_loras
161 +
162 + # Determine how many distinct LoRAs to use and whether to include
163 + # no-LoRA tokens (-1 entries).
164 + # When num_active_loras > max_loras (e.g., max_loras + 1), we need
165 + # to include -1 entries to simulate batches with both LoRA and
166 + # no-LoRA tokens. This ensures prepare_tensors computes the correct
167 + # num_active_loras that matches the cudagraph capture key.
168 + if num_active_loras == 0:
169 + # No LoRA active - use 0 mappings like the original code
170 + effective_num_loras = 0
171 + include_no_lora = False
172 + elif num_active_loras > max_loras:
173 + # num_active_loras > max_loras means we want max_loras adapters
174 + # PLUS no-LoRA tokens (-1). This is the max_loras + 1 case.
175 + effective_num_loras = max_loras
176 + include_no_lora = True
177 + else:
178 + # Specific number of active LoRAs requested
179 + effective_num_loras = min(num_active_loras, max_loras)
180 + include_no_lora = False
181
182 # Make prompt lora mapping
183 # Assign LoRA IDs cyclically to simulate a worst-case scenario.
152 - if activate_lora:
153 - prompt_lora_mapping = (
154 - np.arange(num_reqs, dtype=np.int32) % num_loras
155 - ) + 1
184 + # LoRA IDs are 1-indexed (1 to max_loras) as required by LoRARequest.
185 + # convert_mapping() will convert these to 0-indexed slot indices.
186 + if effective_num_loras > 0:
187 + if include_no_lora:
188 + # Include -1 (no-LoRA) entries by cycling through
189 + # -1, 1, 2, ..., effective_num_loras
190 + # This ensures prepare_tensors sees both LoRA and no-LoRA
191 + # tokens, computing num_active_loras = effective_num_loras+1
192 + cycle_values = np.array(
193 + list(range(1, effective_num_loras + 1)),
194 + dtype=np.int32,
195 + )
196 + prompt_lora_mapping = cycle_values[
197 + np.arange(num_reqs, dtype=np.int32) % len(cycle_values)
198 + ]
199 + else:
200 + # Use 1 to effective_num_loras (1-indexed lora IDs)
201 + prompt_lora_mapping = (
202 + np.arange(num_reqs, dtype=np.int32) % effective_num_loras
203 + ) + 1
204 else:
205 + # No LoRA active - use 0 for all tokens (original behavior)
206 prompt_lora_mapping = np.zeros(num_reqs, dtype=np.int32)
207
208 # Make sample lora mapping
@@ -162,14 +211,14 @@ class LoRAModelRunnerMixin:
211 # Make token lora mapping
212 token_lora_mapping = np.repeat(prompt_lora_mapping, num_scheduled_tokens)
213
165 - # Make dummy lora requests
214 + # Make dummy lora requests (only for the active LoRAs)
215 lora_requests: set[LoRARequest] = {
216 LoRARequest(
217 lora_name=f"warmup_{lora_id}",
218 lora_int_id=lora_id,
219 lora_path="/not/a/real/path",
220 )
172 - for lora_id in range(1, num_loras + 1)
221 + for lora_id in range(1, effective_num_loras + 1)
222 }
223
224 self._set_active_loras(
@@ -187,10 +236,21 @@ class LoRAModelRunnerMixin:
236 lora_config: LoRAConfig | None,
237 num_scheduled_tokens: np.ndarray,
238 num_sampled_tokens: np.ndarray,
190 - activate_lora: bool = True,
239 remove_lora: bool = True,
240 + num_active_loras: int = 0,
241 mapping_type: LoRAMappingType = LoRAMappingType.LANGUAGE,
242 ):
243 + """
244 + Context manager for dummy runs with LoRA.
245 +
246 + Args:
247 + lora_config: LoRA configuration.
248 + num_scheduled_tokens: Array of scheduled token counts per request.
249 + num_sampled_tokens: Array of sampled token counts per request.
250 + remove_lora: Whether to remove LoRAs after the context exits.
251 + num_active_loras: Number of distinct active LoRAs to use.
252 + LoRA is activated when num_active_loras > 0.
253 + """
254 with (
255 self.maybe_setup_dummy_loras(lora_config, remove_lora),
256 self.maybe_select_dummy_loras(
@@ -198,7 +258,7 @@ class LoRAModelRunnerMixin:
258 num_scheduled_tokens,
259 mapping_type,
260 num_sampled_tokens,
201 - activate_lora,
261 + num_active_loras,
262 ),
263 ):
264 yield