@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / a250f1bd5f
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 4, 20:00 UTC

[Bugfix] LoRA for DeepSeek V3.2 (#35077)

Signed-off-by: Hollow Man <hollowman@opensuse.org> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>

ℍ𝕠𝕝𝕝𝕠𝕨 𝕄𝕒𝕟 committed Apr 22, 2026 at 14:33 UTC a250f1bd5fd8ac7a7d97bc8b5b2082417b4564d2
13 files changed +614 -37
tests/kernels/moe/test_moe_layer.py
+7 -12
@@ -1620,18 +1620,13 @@ def _parallel_worker(
1620 else:
1621 print("F", end="")
1622 finally:
1623 - # Note: for some reason DeepEP buffers don't seem to be
1624 - # entirely reusable on B200. In order to work around this
1625 - # we clear the all2all manager's cache after each testpoint.
1626 - cap = current_platform.get_device_capability()
1627 - if (
1628 - cap is not None
1629 - and cap.major == 10
1630 - and (
1631 - test_config.backend == "deepep_low_latency"
1632 - or test_config.backend == "deepep_high_throughput"
1633 - )
1634 - ):
1623 + # DeepEP managers are not reliably reusable across many subtests in
1624 + # a single worker process. Tear them down after each DeepEP case so
1625 + # later subtests do not inherit stale communication state.
1626 + if test_config.backend in {
1627 + "deepep_low_latency",
1628 + "deepep_high_throughput",
1629 + }:
1630 torch.accelerator.synchronize()
1631 all2all_manager = get_ep_group().device_communicator.all2all_manager
1632 if all2all_manager is not None:
tests/lora/test_layers.py
+270 -2
@@ -44,6 +44,7 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
44 VocabParallelEmbedding,
45 get_masked_input_and_mask,
46 )
47 +from vllm.model_executor.models.deepseek_v2 import DeepSeekV2FusedQkvAProjLinear
48 from vllm.platforms import current_platform
49 from vllm.utils.torch_utils import set_random_seed
50
@@ -1422,7 +1423,107 @@ def test_variable_slice_lora_class_selection(default_vllm_config, dist_init):
1423 f"for 2 packed modules, got {type(selected_layer_merged).__name__}"
1424 )
1425
1425 - # Case 5: Plain ColumnParallelLinear (not merged) - common in many models
1426 + fully_sharded_tp_lora_config = LoRAConfig(
1427 + max_loras=8,
1428 + max_lora_rank=16,
1429 + lora_dtype=torch.float16,
1430 + fully_sharded_loras=True,
1431 + )
1432 + fully_sharded_tp_layer = MergedColumnParallelLinear(
1433 + 4096, [2048, 2048], bias=False, params_dtype=torch.float16
1434 + )
1435 + fully_sharded_tp_layer.tp_size = 2
1436 +
1437 + assert not MergedColumnParallelLinearWithLoRA.can_replace_layer(
1438 + source_layer=fully_sharded_tp_layer,
1439 + lora_config=fully_sharded_tp_lora_config,
1440 + packed_modules_list=packed_modules_two,
1441 + ), "Generic merged wrapper should reject fully sharded TP layers"
1442 +
1443 + assert MergedColumnParallelLinearWithShardedLoRA.can_replace_layer(
1444 + source_layer=fully_sharded_tp_layer,
1445 + lora_config=fully_sharded_tp_lora_config,
1446 + packed_modules_list=packed_modules_two,
1447 + ), "Sharded merged wrapper should remain eligible for fully sharded TP layers"
1448 +
1449 + selected_fully_sharded_tp_layer = from_layer(
1450 + fully_sharded_tp_layer,
1451 + max_loras=8,
1452 + lora_config=fully_sharded_tp_lora_config,
1453 + packed_modules_list=packed_modules_two,
1454 + )
1455 + assert isinstance(
1456 + selected_fully_sharded_tp_layer,
1457 + MergedColumnParallelLinearWithShardedLoRA,
1458 + ), (
1459 + "from_layer should select MergedColumnParallelLinearWithShardedLoRA "
1460 + "for fully sharded TP merged layers, got "
1461 + f"{type(selected_fully_sharded_tp_layer).__name__}"
1462 + )
1463 +
1464 + # Case 5: DeepSeek's fused_qkv_a_proj should reuse the generic merged
1465 + # wrapper while preserving its custom base forward path.
1466 + deepseek_fused_layer = DeepSeekV2FusedQkvAProjLinear(
1467 + 4096, [2048, 2048], prefix="model.layers.0.self_attn.fused_qkv_a_proj"
1468 + )
1469 + selected_deepseek_layer = from_layer(
1470 + deepseek_fused_layer,
1471 + max_loras=8,
1472 + lora_config=lora_config,
1473 + packed_modules_list=packed_modules_two,
1474 + )
1475 + assert isinstance(selected_deepseek_layer, MergedColumnParallelLinearWithLoRA), (
1476 + "from_layer should select MergedColumnParallelLinearWithLoRA "
1477 + f"for DeepSeek fused_qkv_a_proj, got {type(selected_deepseek_layer).__name__}"
1478 + )
1479 +
1480 + fully_sharded_lora_config = LoRAConfig(
1481 + max_loras=8,
1482 + max_lora_rank=16,
1483 + lora_dtype=torch.float16,
1484 + fully_sharded_loras=True,
1485 + )
1486 + selected_fully_sharded_deepseek_layer = from_layer(
1487 + deepseek_fused_layer,
1488 + max_loras=8,
1489 + lora_config=fully_sharded_lora_config,
1490 + packed_modules_list=packed_modules_two,
1491 + )
1492 + assert isinstance(
1493 + selected_fully_sharded_deepseek_layer,
1494 + MergedColumnParallelLinearWithLoRA,
1495 + ), (
1496 + "from_layer should keep using MergedColumnParallelLinearWithLoRA "
1497 + "for fused_qkv_a_proj when the base layer is effectively unsharded, got "
1498 + f"{type(selected_fully_sharded_deepseek_layer).__name__}"
1499 + )
1500 +
1501 + # Case 6: Generic subclass of MergedColumnParallelLinear with 2 packed
1502 + # modules should still use the generic merged wrapper.
1503 + class CustomMergedColumnParallelLinear(MergedColumnParallelLinear):
1504 + pass
1505 +
1506 + custom_merged_layer = CustomMergedColumnParallelLinear(
1507 + 4096, [2048, 2048], bias=False, params_dtype=torch.float16
1508 + )
1509 + assert MergedColumnParallelLinearWithLoRA.can_replace_layer(
1510 + source_layer=custom_merged_layer,
1511 + lora_config=lora_config,
1512 + packed_modules_list=packed_modules_two,
1513 + ), "MergedColumnParallelLinearWithLoRA should handle subclasses"
1514 +
1515 + selected_custom_layer = from_layer(
1516 + custom_merged_layer,
1517 + max_loras=8,
1518 + lora_config=lora_config,
1519 + packed_modules_list=packed_modules_two,
1520 + )
1521 + assert isinstance(selected_custom_layer, MergedColumnParallelLinearWithLoRA), (
1522 + f"from_layer should select MergedColumnParallelLinearWithLoRA "
1523 + f"for subclassed merged layers, got {type(selected_custom_layer).__name__}"
1524 + )
1525 +
1526 + # Case 7: Plain ColumnParallelLinear (not merged) - common in many models
1527 # -> ColumnParallelLinearWithLoRA should be selected
1528 plain_column_parallel = ColumnParallelLinear(
1529 4096, 4096, bias=False, params_dtype=torch.float16
@@ -1455,7 +1556,7 @@ def test_variable_slice_lora_class_selection(default_vllm_config, dist_init):
1556 f"for plain ColumnParallelLinear, got {type(selected_plain).__name__}"
1557 )
1558
1458 - # Case 6: MergedColumnParallelLinear with exactly 2 output sizes
1559 + # Case 8: MergedColumnParallelLinear with exactly 2 output sizes
1560 # and empty packed_modules_list
1561 # -> ColumnParallelLinearWithLoRA should NOT match (packed_modules_list != 1)
1562 # -> MergedColumnParallelLinearVariableSliceWithLoRA should NOT match (< 3 slices)
@@ -1473,3 +1574,170 @@ def test_variable_slice_lora_class_selection(default_vllm_config, dist_init):
1574 "MergedColumnParallelLinearVariableSliceWithLoRA "
1575 "should NOT handle 2 slices even with empty packed_modules_list"
1576 )
1577 +
1578 +
1579 +@pytest.mark.parametrize(
1580 + "wrapper_cls",
1581 + [ColumnParallelLinearWithLoRA, ColumnParallelLinearWithShardedLoRA],
1582 +)
1583 +def test_get_and_maybe_dequant_weights_accepts_lora_wrappers(dist_init, wrapper_cls):
1584 + from vllm.model_executor.layers.quantization.utils.quant_utils import (
1585 + get_and_maybe_dequant_weights,
1586 + )
1587 +
1588 + linear = ColumnParallelLinear(4096, 4096, bias=False, params_dtype=torch.float16)
1589 + lora_linear = wrapper_cls(linear)
1590 +
1591 + # Should work with LoRA wrappers and return [out, in] weights.
1592 + dequant_weight = get_and_maybe_dequant_weights(lora_linear, out_dtype=torch.float16)
1593 + assert dequant_weight.shape == linear.weight.shape
1594 +
1595 +
1596 +@torch.inference_mode()
1597 +@pytest.mark.parametrize("device", DEVICES)
1598 +@pytest.mark.parametrize("stage", STAGES)
1599 +@pytest.mark.parametrize("fully_sharded", [False, True])
1600 +def test_deepseek_fused_qkv_a_proj_lora_preserves_base_forward(
1601 + default_vllm_config, dist_init, device, stage, fully_sharded
1602 +):
1603 + if current_platform.is_cuda_alike():
1604 + torch.accelerator.set_device_index(device)
1605 +
1606 + torch.set_default_device(device)
1607 + dtype = torch.float16 if current_platform.is_cuda_alike() else torch.float32
1608 + max_loras = 8
1609 + lora_config = LoRAConfig(
1610 + max_loras=max_loras,
1611 + max_lora_rank=8,
1612 + lora_dtype=dtype,
1613 + fully_sharded_loras=fully_sharded,
1614 + )
1615 + punica_wrapper = get_punica_wrapper(8192, 256, device, lora_config=lora_config)
1616 + assert check_punica_wrapper(punica_wrapper)
1617 +
1618 + class OffsetDeepSeekFusedQkvAProjLinear(DeepSeekV2FusedQkvAProjLinear):
1619 + def forward(self, input_):
1620 + output, output_bias = super().forward(input_)
1621 + return output + 1, output_bias
1622 +
1623 + layer = OffsetDeepSeekFusedQkvAProjLinear(
1624 + 32, [16, 16], prefix="model.layers.0.self_attn.fused_qkv_a_proj"
1625 + )
1626 + layer.weight.data = torch.rand_like(layer.weight.data, dtype=dtype)
1627 +
1628 + lora_layer = MergedColumnParallelLinearWithLoRA(layer)
1629 + lora_layer.create_lora_weights(max_loras, lora_config)
1630 + lora_layer.set_mapping(punica_wrapper)
1631 +
1632 + id_to_index = get_random_id_to_index(1, max_loras, log=False)
1633 + active_slot = next(i for i, lora_id in enumerate(id_to_index) if lora_id == 1)
1634 + lora_a = [
1635 + torch.rand(8, 32, dtype=dtype, device=device),
1636 + torch.rand(8, 32, dtype=dtype, device=device),
1637 + ]
1638 + lora_b = [
1639 + torch.rand(16, 8, dtype=dtype, device=device),
1640 + torch.rand(16, 8, dtype=dtype, device=device),
1641 + ]
1642 + lora_layer.set_lora(active_slot, lora_a=lora_a, lora_b=lora_b)
1643 +
1644 + inputs, index_mapping, prompt_mapping = create_random_inputs(
1645 + active_lora_ids=[1],
1646 + num_inputs=4,
1647 + input_size=(1, 32),
1648 + input_range=(0, 1),
1649 + input_type=dtype,
1650 + device=device,
1651 + )
1652 + lora_mapping = LoRAMapping(index_mapping, prompt_mapping, is_prefill=stage)
1653 + punica_wrapper.update_metadata(lora_mapping, id_to_index, max_loras, 512)
1654 +
1655 + lora_result = lora_layer(torch.cat(inputs))[0]
1656 +
1657 + expected_results = []
1658 + for input_ in inputs:
1659 + result = layer(input_)[0]
1660 + result[:, :16] += input_ @ lora_a[0].T @ lora_b[0].T
1661 + result[:, 16:] += input_ @ lora_a[1].T @ lora_b[1].T
1662 + expected_results.append(result)
1663 +
1664 + rtol, atol = TOLERANCES[lora_result.dtype]
1665 + torch.testing.assert_close(
1666 + lora_result, torch.cat(expected_results), rtol=rtol, atol=atol
1667 + )
1668 +
1669 + merged_layer = OffsetDeepSeekFusedQkvAProjLinear(
1670 + 32, [16, 16], prefix="model.layers.0.self_attn.fused_qkv_a_proj"
1671 + )
1672 + merged_layer.weight.data = layer.weight.data.clone()
1673 + merged_layer.weight.data[:16].add_(lora_b[0] @ lora_a[0])
1674 + merged_layer.weight.data[16:].add_(lora_b[1] @ lora_a[1])
1675 + merged_result = merged_layer(torch.cat(inputs))[0]
1676 +
1677 + torch.testing.assert_close(lora_result, merged_result, rtol=rtol, atol=atol)
1678 +
1679 +
1680 +@torch.inference_mode()
1681 +@pytest.mark.parametrize("device", DEVICES)
1682 +@pytest.mark.parametrize("stage", STAGES)
1683 +def test_replicated_lora_preserves_base_forward_for_subclasses(
1684 + default_vllm_config, dist_init, device, stage
1685 +):
1686 + if current_platform.is_cuda_alike():
1687 + torch.accelerator.set_device_index(device)
1688 +
1689 + torch.set_default_device(device)
1690 + dtype = torch.float16 if current_platform.is_cuda_alike() else torch.float32
1691 + max_loras = 8
1692 + lora_config = LoRAConfig(max_loras=max_loras, max_lora_rank=8, lora_dtype=dtype)
1693 + punica_wrapper = get_punica_wrapper(8192, 256, device, lora_config=lora_config)
1694 + assert check_punica_wrapper(punica_wrapper)
1695 +
1696 + class OffsetReplicatedLinear(ReplicatedLinear):
1697 + def forward(self, input_):
1698 + output, output_bias = super().forward(input_)
1699 + return output + 1, output_bias
1700 +
1701 + layer = OffsetReplicatedLinear(32, 16, bias=False, params_dtype=dtype)
1702 + layer.weight.data = torch.rand_like(layer.weight.data, dtype=dtype)
1703 +
1704 + lora_layer = ReplicatedLinearWithLoRA(layer)
1705 + lora_layer.create_lora_weights(max_loras, lora_config)
1706 + lora_layer.set_mapping(punica_wrapper)
1707 +
1708 + id_to_index = get_random_id_to_index(1, max_loras, log=False)
1709 + active_slot = next(i for i, lora_id in enumerate(id_to_index) if lora_id == 1)
1710 + lora_a = torch.rand(8, 32, dtype=dtype, device=device)
1711 + lora_b = torch.rand(16, 8, dtype=dtype, device=device)
1712 + lora_layer.set_lora(active_slot, lora_a=lora_a, lora_b=lora_b)
1713 +
1714 + inputs, index_mapping, prompt_mapping = create_random_inputs(
1715 + active_lora_ids=[1],
1716 + num_inputs=4,
1717 + input_size=(1, 32),
1718 + input_range=(0, 1),
1719 + input_type=dtype,
1720 + device=device,
1721 + )
1722 + lora_mapping = LoRAMapping(index_mapping, prompt_mapping, is_prefill=stage)
1723 + punica_wrapper.update_metadata(lora_mapping, id_to_index, max_loras, 512)
1724 +
1725 + lora_result = lora_layer(torch.cat(inputs))[0]
1726 +
1727 + expected_results = []
1728 + for input_ in inputs:
1729 + result = layer(input_)[0]
1730 + result += input_ @ lora_a.T @ lora_b.T
1731 + expected_results.append(result)
1732 +
1733 + rtol, atol = TOLERANCES[lora_result.dtype]
1734 + torch.testing.assert_close(
1735 + lora_result, torch.cat(expected_results), rtol=rtol, atol=atol
1736 + )
1737 +
1738 + merged_layer = OffsetReplicatedLinear(32, 16, bias=False, params_dtype=dtype)
1739 + merged_layer.weight.data = layer.weight.data.clone()
1740 + merged_layer.weight.data.add_(lora_b @ lora_a)
1741 + merged_result = merged_layer(torch.cat(inputs))[0]
1742 +
1743 + torch.testing.assert_close(lora_result, merged_result, rtol=rtol, atol=atol)
tests/lora/test_lora_manager.py
+150
@@ -13,6 +13,7 @@ from vllm.config.lora import LoRAConfig
13 from vllm.lora.layers import (
14 ColumnParallelLinearWithLoRA,
15 MergedColumnParallelLinearWithLoRA,
16 + ReplicatedLinearWithLoRA,
17 RowParallelLinearWithLoRA,
18 )
19 from vllm.lora.lora_model import LoRAModel
@@ -26,6 +27,7 @@ from vllm.lora.model_manager import (
27 from vllm.lora.peft_helper import PEFTHelper
28 from vllm.lora.request import LoRARequest
29 from vllm.lora.worker_manager import LRUCacheWorkerLoRAManager, WorkerLoRAManager
30 +from vllm.model_executor.layers.fused_moe import GateLinear
31 from vllm.platforms import current_platform
32
33 from .utils import create_peft_lora
@@ -132,6 +134,135 @@ def test_replace_submodules(default_vllm_config, dist_init, dummy_model):
134 assert isinstance(model.get_submodule("layer1.dense2"), RowParallelLinearWithLoRA)
135
136
137 +def test_wrap_replicated_linear_subclasses(default_vllm_config, dist_init, dummy_model):
138 + from vllm.model_executor.layers.linear import ReplicatedLinear
139 +
140 + class CustomReplicatedLinear(ReplicatedLinear):
141 + pass
142 +
143 + model = dummy_model
144 + model.add_module("custom_gate", CustomReplicatedLinear(10, 10, bias=False))
145 +
146 + manager = LoRAModelManager(
147 + model,
148 + 1,
149 + 1,
150 + 1,
151 + LoRAConfig(
152 + max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE
153 + ),
154 + torch.device(DEVICES[0]),
155 + )
156 +
157 + assert isinstance(
158 + manager.model.get_submodule("custom_gate"), ReplicatedLinearWithLoRA
159 + )
160 +
161 +
162 +def test_wrap_gate_linear(default_vllm_config, dist_init, dummy_model):
163 + model = dummy_model
164 + model.add_module("router_gate", GateLinear(10, 4, bias=False))
165 +
166 + manager = LoRAModelManager(
167 + model,
168 + 1,
169 + 1,
170 + 1,
171 + LoRAConfig(
172 + max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE
173 + ),
174 + torch.device(DEVICES[0]),
175 + )
176 +
177 + assert isinstance(
178 + manager.model.get_submodule("router_gate"), ReplicatedLinearWithLoRA
179 + )
180 +
181 +
182 +def test_skip_unsupported_matched_modules(default_vllm_config, dist_init, dummy_model):
183 + class UnsupportedContainer(nn.Module):
184 + def __init__(self):
185 + super().__init__()
186 + # This name matches a supported target suffix ("dense1"),
187 + # but nn.Linear is not currently a LoRA-wrappable layer type.
188 + self.dense1 = nn.Linear(10, 10, bias=False)
189 +
190 + model = dummy_model
191 + model.add_module("unsupported", UnsupportedContainer())
192 +
193 + manager = LoRAModelManager(
194 + model,
195 + 1,
196 + 1,
197 + 1,
198 + LoRAConfig(
199 + max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE
200 + ),
201 + torch.device(DEVICES[0]),
202 + )
203 +
204 + # Should not crash and should keep unsupported matched modules unchanged.
205 + assert isinstance(manager.model.get_submodule("unsupported.dense1"), nn.Linear)
206 + assert "unsupported.dense1" not in manager.modules
207 +
208 +
209 +def test_target_modules_fail_closed_on_unsupported_matched_modules(
210 + default_vllm_config, dist_init, dummy_model
211 +):
212 + class UnsupportedContainer(nn.Module):
213 + def __init__(self):
214 + super().__init__()
215 + self.dense1 = nn.Linear(10, 10, bias=False)
216 +
217 + model = dummy_model
218 + model.add_module("unsupported", UnsupportedContainer())
219 +
220 + with pytest.raises(ValueError, match="unsupported.dense1"):
221 + LoRAModelManager(
222 + model,
223 + 1,
224 + 1,
225 + 1,
226 + LoRAConfig(
227 + max_lora_rank=8,
228 + max_cpu_loras=8,
229 + max_loras=8,
230 + lora_dtype=DEFAULT_DTYPE,
231 + target_modules=["dense1"],
232 + ),
233 + torch.device(DEVICES[0]),
234 + )
235 +
236 +
237 +def test_get_dummy_lora_warmup_rank_for_fully_sharded_moe():
238 + manager = LoRAModelManager.__new__(LoRAModelManager)
239 + manager.lora_config = LoRAConfig(
240 + max_lora_rank=64,
241 + max_cpu_loras=1,
242 + max_loras=1,
243 + lora_dtype=DEFAULT_DTYPE,
244 + fully_sharded_loras=True,
245 + )
246 +
247 + class DummyModule:
248 + def __init__(self, tp_size: int, fully_sharded: bool):
249 + self.tp_size = tp_size
250 + self.fully_sharded = fully_sharded
251 +
252 + manager.modules = {
253 + "model.layers.0.self_attn.q_proj": DummyModule(
254 + tp_size=32,
255 + fully_sharded=True,
256 + ),
257 + "model.layers.0.mlp.experts": DummyModule(
258 + tp_size=32,
259 + fully_sharded=True,
260 + ),
261 + }
262 +
263 + assert manager.get_dummy_lora_warmup_rank(8) == 32
264 +
265 +
266 @pytest.mark.parametrize("device", DEVICES)
267 def test_lora_model_manager(default_vllm_config, dist_init, dummy_model, device):
268 model = dummy_model
@@ -795,6 +926,25 @@ def test_target_modules_none_uses_all(
926 )
927
928
929 +@pytest.mark.parametrize("device", DEVICES)
930 +def test_target_modules_match_packed_runtime_modules(
931 + default_vllm_config, dist_init, dummy_model_gate_up, device
932 +):
933 + """Packed runtime modules should be selected by their adapter-visible names."""
934 + _test_target_modules(
935 + dummy_model_gate_up,
936 + ["gate_proj"],
937 + device,
938 + expected_lora=[("gate_up_proj", MergedColumnParallelLinearWithLoRA)],
939 + expected_no_lora=[
940 + ("dense1", ColumnParallelLinearWithLoRA),
941 + ("dense2", RowParallelLinearWithLoRA),
942 + ("layer1.dense1", ColumnParallelLinearWithLoRA),
943 + ("layer1.dense2", RowParallelLinearWithLoRA),
944 + ],
945 + )
946 +
947 +
948 @pytest.mark.parametrize("device", DEVICES)
949 def test_load_adapter_warns_on_unsupported_modules(
950 default_vllm_config, dist_init, dummy_model_gate_up, device, tmp_path
tests/lora/test_lora_utils.py
+21
@@ -58,3 +58,24 @@ class TestIsInTargetModules:
58
59 def test_exact_name_no_match(self):
60 assert not is_in_target_modules("dense3", ["dense1", "dense2"])
61 +
62 + def test_packed_parent_matches_child_target_modules(self):
63 + assert is_in_target_modules(
64 + "model.layers.0.mlp.gate_up_proj",
65 + ["gate_proj", "up_proj"],
66 + {"gate_up_proj": ["gate_proj", "up_proj"]},
67 + )
68 +
69 + def test_packed_child_matches_parent_target_modules(self):
70 + assert is_in_target_modules(
71 + "model.layers.0.mlp.gate_proj",
72 + ["gate_up_proj"],
73 + {"gate_up_proj": ["gate_proj", "up_proj"]},
74 + )
75 +
76 + def test_fused_parent_matches_child_target_modules(self):
77 + assert is_in_target_modules(
78 + "model.layers.0.self_attn.fused_qkv_a_proj",
79 + ["q_a_proj", "kv_a_proj_with_mqa"],
80 + {"fused_qkv_a_proj": ["q_a_proj", "kv_a_proj_with_mqa"]},
81 + )
vllm/lora/layers/base_linear.py
+9
@@ -203,7 +203,16 @@ class BaseLinearLayerWithLoRA(BaseLayerWithLoRA):
203 self, x: torch.Tensor, bias: torch.Tensor | None = None
204 ) -> torch.Tensor:
205 output = self.base_layer.quant_method.apply(self.base_layer, x, bias)
206 + return self._apply_lora_to_output(x, output)
207
208 + def _apply_base_forward(self, x: torch.Tensor) -> torch.Tensor:
209 + base_output = self.base_layer(x)
210 + output = base_output[0] if isinstance(base_output, tuple) else base_output
211 + return self._apply_lora_to_output(x, output)
212 +
213 + def _apply_lora_to_output(
214 + self, x: torch.Tensor, output: torch.Tensor
215 + ) -> torch.Tensor:
216 original_shape = output.shape if output.ndim == 3 else None
217
218 # In transformers backend, x and output have extra batch dimension like
vllm/lora/layers/column_parallel_linear.py
+42 -10
@@ -40,11 +40,19 @@ def _mcp_apply(x, bias, layer: "ColumnParallelLinearWithLoRA"):
40
41 # Since communication is needed, the buffer is directly initialized as a
42 # tensor rather than a tuple of tensor.
43 - buffers = torch.zeros(
44 - (layer.n_slices, x.shape[0], layer.lora_a_stacked[0].shape[2]),
43 + local_lora_rank = layer.lora_a_stacked[0].shape[2]
44 + buffer_shape = (layer.n_slices, x.shape[0], local_lora_rank)
45 + # Under torch.compile, the local-rank-1 fully-sharded path can otherwise
46 + # get lowered to a reinterpret view with a non-canonical layout. The
47 + # Triton shrink op mutates this buffer in place and expects the standard
48 + # contiguous [slice, token, rank] stride contract.
49 + buffers = torch.empty_strided(
50 + buffer_shape,
51 + (x.shape[0] * local_lora_rank, local_lora_rank, 1),
52 dtype=torch.float32,
53 device=x.device,
54 )
55 + buffers.zero_()
56
57 shrunk_buffers: torch.Tensor | None = layer.punica_wrapper.add_shrink(
58 buffers, x, layer.lora_a_stacked, 1.0
@@ -86,7 +94,7 @@ class ColumnParallelLinearWithLoRA(BaseLinearLayerWithLoRA):
94 # The base_layer type is ColumnParallelLinear or
95 # MergedColumnParallelLinear, their weight sharding logic is
96 # inconsistent when TP is greater than 1.
89 - self.is_merged_col_linear = type(base_layer) is MergedColumnParallelLinear
97 + self.is_merged_col_linear = isinstance(base_layer, MergedColumnParallelLinear)
98 self.output_size = self.base_layer.output_size_per_partition
99 # There is only one LoRA layer
100 self.n_slices = 1
@@ -158,7 +166,7 @@ class ColumnParallelLinearWithLoRA(BaseLinearLayerWithLoRA):
166 ) -> bool:
167 if type(source_layer) is maybe_get_oot_by_class(ColumnParallelLinear):
168 return True
161 - if type(source_layer) is maybe_get_oot_by_class(MergedColumnParallelLinear):
169 + if isinstance(source_layer, maybe_get_oot_by_class(MergedColumnParallelLinear)):
170 if len(packed_modules_list) != 1:
171 return False
172 # Exclude layers with 3+ output sizes - those are handled by
@@ -275,19 +283,41 @@ class MergedColumnParallelLinearWithLoRA(ColumnParallelLinearWithLoRA):
283 index, 0, : lora_b_i.shape[0], : lora_b_i.shape[1]
284 ].copy_(lora_b_i, non_blocking=True)
285
286 + def apply(self, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
287 + merged_cls = maybe_get_oot_by_class(MergedColumnParallelLinear)
288 + # Effectively unsharded subclasses can safely reuse their custom
289 + # forward() implementation before applying the LoRA delta.
290 + if (
291 + self.tp_size == 1
292 + and type(self.base_layer) is not merged_cls
293 + and type(self.base_layer).forward is not merged_cls.forward
294 + ):
295 + return self._apply_base_forward(x)
296 + return _mcp_apply(x, bias, self)
297 +
298 @classmethod
279 - @_not_fully_sharded_can_replace
299 def can_replace_layer(
300 cls,
301 source_layer: nn.Module,
302 lora_config: LoRAConfig,
303 packed_modules_list: list,
304 model_config: PretrainedConfig | None = None,
305 + decorate: bool = True,
306 ) -> bool:
287 - return (
288 - type(source_layer) is MergedColumnParallelLinear
289 - and len(packed_modules_list) == 2
290 - )
307 + merged_cls = maybe_get_oot_by_class(MergedColumnParallelLinear)
308 + if not isinstance(source_layer, merged_cls) or len(packed_modules_list) != 2:
309 + return False
310 +
311 + tp_size = getattr(source_layer, "tp_size", 1)
312 + if type(source_layer) is merged_cls:
313 + if not decorate:
314 + return True
315 + return not lora_config.fully_sharded_loras or tp_size == 1
316 +
317 + # Only support effectively unsharded subclasses here. Sharded
318 + # subclasses may have custom communication semantics that the generic
319 + # merged-column LoRA path does not know how to preserve.
320 + return tp_size == 1
321
322
323 class QKVParallelLinearWithLoRA(ColumnParallelLinearWithLoRA):
@@ -607,7 +637,9 @@ class MergedColumnParallelLinearVariableSliceWithLoRA(
637 ) -> bool:
638 # Support MergedColumnParallelLinear with 3 or more slices
639 # (2 slices are handled by MergedColumnParallelLinearWithLoRA)
610 - if type(source_layer) is not maybe_get_oot_by_class(MergedColumnParallelLinear):
640 + if not isinstance(
641 + source_layer, maybe_get_oot_by_class(MergedColumnParallelLinear)
642 + ):
643 return False
644
645 # If packed_modules_list has 3+ items, use this class
vllm/lora/layers/replicated_linear.py
+7 -1
@@ -46,6 +46,12 @@ class ReplicatedLinearWithLoRA(BaseLinearLayerWithLoRA):
46
47 return output, output_bias
48
49 + def apply(self, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
50 + # ReplicatedLinear subclasses such as GateLinear override forward() to
51 + # dispatch custom kernels and/or adjust the output dtype. Apply LoRA on
52 + # top of the actual base-layer output instead of bypassing that path.
53 + return self._apply_base_forward(x)
54 +
55 # ReplicatedLinear should always be replaced, regardless of the fully
56 # sharded LoRAs setting, because it is, by definition, copied per GPU.
57 @classmethod
@@ -56,7 +62,7 @@ class ReplicatedLinearWithLoRA(BaseLinearLayerWithLoRA):
62 packed_modules_list: list,
63 model_config: PretrainedConfig | None = None,
64 ) -> bool:
59 - return type(source_layer) is maybe_get_oot_by_class(ReplicatedLinear)
65 + return isinstance(source_layer, maybe_get_oot_by_class(ReplicatedLinear))
66
67 def slice_lora_a(
68 self, lora_a: torch.Tensor | list[torch.Tensor | None]
vllm/lora/model_manager.py
+52 -7
@@ -437,12 +437,21 @@ class LoRAModelManager:
437 ),
438 )
439
440 - # In some models, especially multimodal ones, layers with the same
441 - # name may have different types, such as nn.Linear and
442 - # ReplicatedLinear. The nn.Linear layers cannot be replaced with
443 - # LoRA layers, leading to assertion error. The following check
444 - # aims to prevent this error
445 - if self.supports_mm and not isinstance(new_module, BaseLayerWithLoRA):
440 + # Some matched modules can be unsupported by LoRA wrappers
441 + # (e.g. subclasses with specialized forward behavior).
442 + if not isinstance(new_module, BaseLayerWithLoRA):
443 + error_msg = (
444 + "LoRA target module "
445 + f"{module_name} ({type(module).__name__}) matched the "
446 + "deployment configuration but could not be wrapped by any "
447 + "LoRA layer implementation."
448 + )
449 + if self.lora_config.target_modules is not None:
450 + raise ValueError(
451 + f"{error_msg} target_modules="
452 + f"{sorted(self.lora_config.target_modules)}"
453 + )
454 + logger.warning_once("%s It will be ignored.", error_msg)
455 continue
456 self.register_module(module_name, new_module)
457
@@ -578,6 +587,38 @@ class LoRAModelManager:
587 model.loras[module_name] = lora
588 return model
589
590 + def get_dummy_lora_warmup_rank(self, default_rank: int) -> int:
591 + """Return a dummy LoRA rank compatible with wrapped modules.
592 +
593 + Dummy LoRAs keep warmup memory low by using a small rank. Fully
594 + sharded MoE wrappers additionally require the dummy rank to be divisible
595 + by tensor parallel size because they shard W13 along the rank axis.
596 + """
597 + if not self.lora_config.fully_sharded_loras:
598 + return default_rank
599 +
600 + required_multiple = 1
601 + for module in self.modules.values():
602 + if not getattr(module, "fully_sharded", False):
603 + continue
604 + required_multiple = math.lcm(required_multiple, module.tp_size)
605 +
606 + if required_multiple == 1 or default_rank % required_multiple == 0:
607 + return default_rank
608 +
609 + adjusted_rank = (
610 + (default_rank + required_multiple - 1) // required_multiple
611 + ) * required_multiple
612 + if adjusted_rank > self.lora_config.max_lora_rank:
613 + raise ValueError(
614 + "Unable to choose a dummy LoRA warmup rank compatible with "
615 + "fully sharded MoE modules: "
616 + f"default_rank={default_rank}, "
617 + f"required_multiple={required_multiple}, "
618 + f"max_lora_rank={self.lora_config.max_lora_rank}"
619 + )
620 + return adjusted_rank
621 +
622 def _match_target_modules(self, module_name: str) -> bool:
623 """Check if a module should have LoRA applied.
624
@@ -594,7 +635,11 @@ class LoRAModelManager:
635 """
636 if not is_supported_lora_module(module_name, self.supported_lora_modules):
637 return False
597 - return is_in_target_modules(module_name, self.lora_config.target_modules)
638 + return is_in_target_modules(
639 + module_name,
640 + self.lora_config.target_modules,
641 + self.packed_modules_mapping,
642 + )
643
644 def _get_punica_wrapper(self, module_name: str) -> PunicaWrapperBase | None:
645 """
vllm/lora/utils.py
+25 -3
@@ -73,7 +73,9 @@ def get_lora_id():
73 return _GLOBAL_LORA_ID
74
75
76 -_all_lora_classes: set[type[BaseLayerWithLoRA]] = {
76 +# Order matters here: more specific wrappers must be checked before generic
77 +# merged/column-parallel wrappers in from_layer().
78 +_all_lora_classes: tuple[type[BaseLayerWithLoRA], ...] = (
79 VocabParallelEmbeddingWithLoRA,
80 ColumnParallelLinearWithLoRA,
81 MergedColumnParallelLinearWithLoRA,
@@ -90,7 +92,7 @@ _all_lora_classes: set[type[BaseLayerWithLoRA]] = {
92 RowParallelLinearWithShardedLoRA,
93 FusedMoEWithLoRA,
94 FusedMoE3DWithLoRA,
93 -}
95 +)
96
97
98 def is_moe_model(model: nn.Module) -> bool:
@@ -258,6 +260,7 @@ def is_supported_lora_module(
260 def is_in_target_modules(
261 module_name: str,
262 target_modules: list[str] | None,
263 + packed_modules_mapping: dict[str, list[str]] | None = None,
264 ) -> bool:
265 """Check if a module passes the deployment-time target_modules filter.
266
@@ -268,14 +271,33 @@ def is_in_target_modules(
271 module_name: Full dot-separated module name.
272 target_modules: Optional deployment-time restriction list from
273 LoRAConfig.target_modules.
274 + packed_modules_mapping: Optional model-defined mapping from packed
275 + runtime module names to their adapter-visible submodule names
276 + (e.g. ``{"gate_up_proj": ["gate_proj", "up_proj"]}``).
277
278 Returns:
279 True if the module passes the filter, False otherwise.
280 """
281 if target_modules is None:
282 return True
283 + target_module_set = set(target_modules)
284 module_suffix = module_name.split(".")[-1]
278 - return module_suffix in set(target_modules)
285 + if module_suffix in target_module_set or module_name in target_module_set:
286 + return True
287 +
288 + if not packed_modules_mapping:
289 + return False
290 +
291 + # Runtime packed parent matched by deployment-time child targets.
292 + packed_children = packed_modules_mapping.get(module_suffix)
293 + if packed_children and any(child in target_module_set for child in packed_children):
294 + return True
295 +
296 + # Adapter-visible packed child matched by deployment-time parent target.
297 + return any(
298 + module_suffix in children and packed_parent in target_module_set
299 + for packed_parent, children in packed_modules_mapping.items()
300 + )
301
302
303 def get_adapter_absolute_path(lora_path: str) -> str:
vllm/lora/worker_manager.py
+8 -1
@@ -160,7 +160,11 @@ class WorkerLoRAManager:
160 lora_request.lora_path,
161 ", ".join(sorted(expected_lora_modules_lst)),
162 )
163 - elif not is_in_target_modules(module_name, target_modules):
163 + elif not is_in_target_modules(
164 + module_name,
165 + target_modules,
166 + packed_modules_mapping,
167 + ):
168 logger.warning_once(
169 "LoRA module '%s' in adapter '%s' is not in the "
170 "deployment-time target_modules restriction [%s]."
@@ -197,6 +201,9 @@ class WorkerLoRAManager:
201 self._cached_dummy_lora = dummy_lora
202 return self._adapter_manager.add_adapter(dummy_lora)
203
204 + def get_dummy_lora_warmup_rank(self, default_rank: int) -> int:
205 + return self._adapter_manager.get_dummy_lora_warmup_rank(default_rank)
206 +
207 def pin_adapter(self, adapter_id: int) -> bool:
208 return self._adapter_manager.pin_adapter(adapter_id)
209
vllm/model_executor/layers/fused_moe/oracle/unquantized.py
+13
@@ -214,6 +214,19 @@ def select_unquantized_moe_backend(
214 return backend, k_cls
215 raise ValueError(_make_log_unsupported(backend, reason))
216
217 + # LoRA needs Triton's unfused activation/reduction hooks. Selecting the
218 + # backend here ensures weights stay in a LoRA-compatible layout instead of
219 + # being permuted for a backend like FlashInfer or AITER during load.
220 + if moe_config.is_lora_enabled:
221 + backend = UnquantizedMoeBackend.TRITON
222 + if activation_format == mk.FusedMoEActivationFormat.BatchedExperts:
223 + backend = UnquantizedMoeBackend.BATCHED_TRITON
224 + return _return_or_raise(
225 + backend,
226 + moe_config,
227 + activation_format,
228 + )
229 +
230 runner_backend = moe_config.moe_backend
231 if runner_backend != "auto":
232 requested_backend = map_unquantized_backend(runner_backend)
vllm/model_executor/layers/quantization/utils/quant_utils.py
+6
@@ -356,6 +356,12 @@ def get_and_maybe_dequant_weights(
356 from vllm.model_executor.layers.linear import UnquantizedLinearMethod
357 from vllm.model_executor.layers.quantization.fp8 import Fp8LinearMethod
358
359 + # LoRA linear wrappers store quantization metadata on `base_layer`.
360 + # Unwrap here so callers can pass either a raw linear layer or its LoRA
361 + # wrapper without special-casing.
362 + while hasattr(layer, "base_layer") and hasattr(layer.base_layer, "quant_method"):
363 + layer = layer.base_layer
364 +
365 weight = get_attribute_fallback(layer, ["weight", "qweight", "weight_packed"])
366
367 # Unquantized layer: just return base weights
vllm/v1/worker/lora_model_runner_mixin.py
+4 -1
@@ -101,9 +101,12 @@ class LoRAModelRunnerMixin:
101 assert self.lora_manager is not None, "LoRA is not enabled"
102
103 num_loras = lora_config.max_loras
104 - lora_warmup_rank = (
104 + lora_warmup_rank: int = (
105 lora_config.max_lora_rank if lora_config.max_lora_rank < 8 else 8
106 )
107 + lora_warmup_rank = self.lora_manager.get_dummy_lora_warmup_rank(
108 + lora_warmup_rank
109 + )
110 # Make dummy lora requests
111 lora_requests: set[LoRARequest] = {
112 LoRARequest(