[LoRA] Support modules_to_save for sequence classification (#53555)

Signed-off-by: linitra24 <Joy25810@foxmail.com> Signed-off-by: linitra24 <renshuang.zhou@daocloud.io> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>

linitra24 committed Sep 16, 2026 at 22:15 UTC c81eb3e69c40ab17cb3c4cd8350e4a60b1be70d4
20 files changed +681 -21
.buildkite/test_areas/lora.yaml
+2
@@ -54,6 +54,7 @@ steps:
54 - test "$$BUILDKITE_PARALLEL_JOB" != "0" || pytest -v -s -x lora/test_gptoss_tp.py
55 - test "$$BUILDKITE_PARALLEL_JOB" != "1" || pytest -v -s -x lora/test_qwen35_densemodel_lora.py
56 - test "$$BUILDKITE_PARALLEL_JOB" != "2" || pytest -v -s -x lora/test_gemma4_tp.py
57 + - test "$$BUILDKITE_PARALLEL_JOB" != "0" || pytest -v -s -x lora/test_sequence_classification.py::test_batched_loras_tp
58 mirror:
59 amd:
60 label: ":amd: (MI300) LoRA TP (Distributed) %N"
@@ -79,3 +80,4 @@ steps:
80 - test "$$BUILDKITE_PARALLEL_JOB" != "0" || pytest -v -s -x lora/test_gptoss_tp.py
81 - test "$$BUILDKITE_PARALLEL_JOB" != "1" || pytest -v -s -x lora/test_qwen35_densemodel_lora.py
82 - test "$$BUILDKITE_PARALLEL_JOB" != "2" || pytest -v -s -x lora/test_gemma4_tp.py
83 + - test "$$BUILDKITE_PARALLEL_JOB" != "0" || pytest -v -s -x lora/test_sequence_classification.py::test_batched_loras_tp
docs/features/lora.md
+12
@@ -422,6 +422,18 @@ vllm serve ibm-granite/granite-speech-3.3-2b \
422
423 Note: Default multimodal LoRAs are currently only available for `.generate` and chat completions.
424
425 +## Sequence-Classification LoRA Adapters
426 +
427 +vLLM supports PEFT sequence-classification adapters that save a complete, single-layer linear classification head through `modules_to_save`. The saved module must be named `score` or `classifier`.
428 +
429 +See [classification_with_lora_offline.py](../../examples/pooling/classify/classification_with_lora_offline.py) for an offline classification example using a LoRA adapter.
430 +
431 +This support has the following limitations:
432 +
433 +- All adapters in one engine must have the same `num_labels` and hidden size as the base classification head. An incompatible adapter is rejected when it is loaded.
434 +- A classification head stored as float32 is converted to the runtime head dtype when it is loaded.
435 +- Token-classification adapters are not supported by this feature.
436 +
437 ## Using Tips
438
439 ### Configuring `max_lora_rank`
examples/pooling/classify/classification_with_lora_offline.py new
+32
@@ -0,0 +1,32 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Example of offline classification with a LoRA adapter."""
4 +
5 +from vllm import LLM
6 +from vllm.lora.request import LoRARequest
7 +from vllm.transformers_utils.repo_utils import hf_api
8 +
9 +
10 +def main():
11 + lora_path = hf_api().snapshot_download(
12 + repo_id="AmirMohseni/skywork-qwen3-0.6b-reward-lora"
13 + )
14 + llm = LLM(
15 + model="Skywork/Skywork-Reward-V2-Qwen3-0.6B",
16 + runner="pooling",
17 + enable_lora=True,
18 + max_lora_rank=8,
19 + max_model_len=512,
20 + )
21 +
22 + prompt = "Which response is more helpful and correct?"
23 + (output,) = llm.classify(
24 + prompt,
25 + lora_request=LoRARequest("reward-lora", 1, lora_path),
26 + )
27 + print(f"Prompt: {prompt!r}")
28 + print(f"Class Probabilities: {output.outputs.probs}")
29 +
30 +
31 +if __name__ == "__main__":
32 + main()
tests/entrypoints/serve/lora/test_lora_adapters.py
+1 -1
@@ -26,7 +26,7 @@ BADREQUEST_CASES = [
26 (
27 "test_modules_to_save",
28 {"modules_to_save": ["lm_head"]},
29 - "only supports modules_to_save being None",
29 + "Unsupported modules_to_save",
30 ),
31 ]
32
tests/lora/conftest.py
+24
@@ -270,6 +270,30 @@ def qwen3_woofing_lora_files():
270 return hf_api().snapshot_download(repo_id="Jackmin108/Qwen3-0.6B-Woof-LoRA")
271
272
273 +@pytest.fixture(scope="session")
274 +def qwen3_guard_star_trek_lora_files():
275 + return hf_api().snapshot_download(
276 + repo_id="geoffmunn/Qwen3Guard-StarTrek-Classification-0.6B",
277 + allow_patterns=["adapter_config.json", "adapter_model.safetensors"],
278 + )
279 +
280 +
281 +@pytest.fixture(scope="session")
282 +def qwen3_guard_new_zealand_lora_files():
283 + return hf_api().snapshot_download(
284 + repo_id="geoffmunn/Qwen3Guard-NewZealand-Classification-0.6B",
285 + allow_patterns=["adapter_config.json", "adapter_model.safetensors"],
286 + )
287 +
288 +
289 +@pytest.fixture(scope="session")
290 +def skywork_qwen3_reward_lora_files():
291 + return hf_api().snapshot_download(
292 + repo_id="AmirMohseni/skywork-qwen3-0.6b-reward-lora",
293 + allow_patterns=["adapter_config.json", "adapter_model.safetensors"],
294 + )
295 +
296 +
297 @pytest.fixture(scope="session")
298 def deepseekv2_lora_files():
299 return hf_api().snapshot_download(repo_id="wuchen01/DeepSeek-V2-Lite-Chat-All-LoRA")
tests/lora/test_peft_helper.py
+1 -1
@@ -20,7 +20,7 @@ ERROR_CASES = [
20 (
21 "test_modules_to_save",
22 {"modules_to_save": ["lm_head"]},
23 - "only supports modules_to_save being None",
23 + "Unsupported modules_to_save",
24 ),
25 ("test_rank_zero", {"r": 0}, "must be a positive integer"),
26 ("test_rank_negative", {"r": -8}, "must be a positive integer"),
tests/lora/test_sequence_classification.py new
+167
@@ -0,0 +1,167 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from tests.utils import multi_gpu_test
7 +from vllm import LLM, PoolingParams
8 +from vllm.distributed import cleanup_dist_env_and_memory
9 +from vllm.lora.request import LoRARequest
10 +
11 +MODEL_NAME = "Qwen/Qwen3-0.6B"
12 +NATIVE_MODEL_NAME = "Skywork/Skywork-Reward-V2-Qwen3-0.6B"
13 +PROMPTS = {
14 + "star_trek": "Does warp drive appear in Star Trek?",
15 + "new_zealand": "Wellington is the capital of New Zealand.",
16 +}
17 +EXPECTED_OUT = {
18 + "star_trek": [13.6640625, -13.6640625],
19 + "new_zealand": [-4.80078125, 8.6171875],
20 + "native": [-1.52734375],
21 +}
22 +
23 +
24 +def _classify_logits(
25 + llm: LLM,
26 + prompts: list[str],
27 + requests: LoRARequest | list[LoRARequest],
28 +) -> torch.Tensor:
29 + params = [PoolingParams(task="classify", use_activation=False) for _ in prompts]
30 + outputs = llm.classify(
31 + prompts,
32 + pooling_params=params,
33 + lora_request=requests,
34 + use_tqdm=False,
35 + )
36 + return torch.tensor([output.outputs.probs for output in outputs])
37 +
38 +
39 +def test_converted_model_with_modules_to_save(
40 + qwen3_guard_star_trek_lora_files: str,
41 +) -> None:
42 + prompt = PROMPTS["star_trek"]
43 + llm = LLM(
44 + model=MODEL_NAME,
45 + runner="pooling",
46 + convert="classify",
47 + hf_overrides={"num_labels": 2},
48 + dtype="float16",
49 + enable_lora=True,
50 + max_lora_rank=16,
51 + enforce_eager=True,
52 + max_model_len=512,
53 + gpu_memory_utilization=0.5,
54 + )
55 + actual = _classify_logits(
56 + llm,
57 + [prompt],
58 + LoRARequest("star-trek", 1, qwen3_guard_star_trek_lora_files),
59 + )[0]
60 +
61 + torch.testing.assert_close(
62 + actual,
63 + torch.tensor(EXPECTED_OUT["star_trek"]),
64 + atol=2e-2,
65 + rtol=2e-2,
66 + )
67 +
68 + del llm
69 + cleanup_dist_env_and_memory()
70 +
71 +
72 +def test_native_classification_model_with_modules_to_save(
73 + skywork_qwen3_reward_lora_files: str,
74 +) -> None:
75 + prompt = "Which response is more helpful and correct?"
76 + llm = LLM(
77 + model=NATIVE_MODEL_NAME,
78 + runner="pooling",
79 + dtype="float16",
80 + enable_lora=True,
81 + max_lora_rank=8,
82 + enforce_eager=True,
83 + max_model_len=512,
84 + gpu_memory_utilization=0.5,
85 + )
86 + actual = _classify_logits(
87 + llm,
88 + [prompt],
89 + LoRARequest("native", 1, skywork_qwen3_reward_lora_files),
90 + )[0]
91 +
92 + torch.testing.assert_close(
93 + actual,
94 + torch.tensor(EXPECTED_OUT["native"]),
95 + atol=2e-2,
96 + rtol=2e-2,
97 + )
98 +
99 + del llm
100 + cleanup_dist_env_and_memory()
101 +
102 +
103 +def _test_batched_loras(
104 + qwen3_guard_star_trek_lora_files: str,
105 + qwen3_guard_new_zealand_lora_files: str,
106 + tp_size: int,
107 +) -> None:
108 + names = ["star_trek", "new_zealand"]
109 + prompts = [PROMPTS[name] for name in names]
110 + adapter_paths = [
111 + qwen3_guard_star_trek_lora_files,
112 + qwen3_guard_new_zealand_lora_files,
113 + ]
114 + requests = [
115 + LoRARequest(name, index, adapter_path)
116 + for index, (name, adapter_path) in enumerate(zip(names, adapter_paths), start=1)
117 + ]
118 +
119 + llm = LLM(
120 + model=MODEL_NAME,
121 + runner="pooling",
122 + convert="classify",
123 + hf_overrides={"num_labels": 2},
124 + dtype="float16",
125 + enable_lora=True,
126 + max_loras=2,
127 + max_cpu_loras=2,
128 + max_lora_rank=16,
129 + enforce_eager=True,
130 + max_model_len=512,
131 + gpu_memory_utilization=0.5,
132 + tensor_parallel_size=tp_size,
133 + )
134 + actual = _classify_logits(llm, prompts, requests)
135 +
136 + torch.testing.assert_close(
137 + actual,
138 + torch.tensor([EXPECTED_OUT[name] for name in names]),
139 + atol=2e-2,
140 + rtol=2e-2,
141 + )
142 +
143 + del llm
144 + cleanup_dist_env_and_memory()
145 +
146 +
147 +def test_batched_loras(
148 + qwen3_guard_star_trek_lora_files: str,
149 + qwen3_guard_new_zealand_lora_files: str,
150 +) -> None:
151 + _test_batched_loras(
152 + qwen3_guard_star_trek_lora_files,
153 + qwen3_guard_new_zealand_lora_files,
154 + tp_size=1,
155 + )
156 +
157 +
158 +@multi_gpu_test(num_gpus=2)
159 +def test_batched_loras_tp(
160 + qwen3_guard_star_trek_lora_files: str,
161 + qwen3_guard_new_zealand_lora_files: str,
162 +) -> None:
163 + _test_batched_loras(
164 + qwen3_guard_star_trek_lora_files,
165 + qwen3_guard_new_zealand_lora_files,
166 + tp_size=2,
167 + )
vllm/lora/layers/__init__.py
+2
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from vllm.lora.layers.base import BaseLayerWithLoRA
4 +from vllm.lora.layers.classifier import ClassificationHeadWithLoRA
5 from vllm.lora.layers.column_parallel_linear import (
6 ColumnParallelLinearWithLoRA,
7 ColumnParallelLinearWithShardedLoRA,
@@ -42,4 +43,5 @@ __all__ = [
43 "LoRAMappingType",
44 "FusedMoEWithLoRA",
45 "FusedMoE3DWithLoRA",
46 + "ClassificationHeadWithLoRA",
47 ]
vllm/lora/layers/classifier.py new
+97
@@ -0,0 +1,97 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +
5 +import torch
6 +from transformers import PretrainedConfig
7 +
8 +from vllm.config.lora import LoRAConfig
9 +from vllm.platforms import current_platform
10 +
11 +from .replicated_linear import ReplicatedLinearWithLoRA
12 +
13 +
14 +class ClassificationHeadWithLoRA(ReplicatedLinearWithLoRA):
15 + def create_lora_weights(
16 + self,
17 + max_loras: int,
18 + lora_config: LoRAConfig,
19 + model_config: PretrainedConfig | None = None,
20 + ) -> None:
21 + # Preserve ordinary LoRA A/B support for classification heads.
22 + super().create_lora_weights(max_loras, lora_config, model_config)
23 + self.full_weight_stacked = torch.zeros(
24 + max_loras,
25 + 1,
26 + self.output_size,
27 + self.input_size,
28 + dtype=lora_config.lora_dtype,
29 + device=self.device,
30 + )
31 + self.full_bias_stacked = torch.zeros(
32 + max_loras,
33 + self.output_size,
34 + dtype=self.base_layer.params_dtype,
35 + device=self.device,
36 + )
37 + self.full_module_enabled = torch.zeros(
38 + max_loras, dtype=torch.bool, device=self.device
39 + )
40 +
41 + def reset_module_to_save(self, index: int) -> None:
42 + self.full_weight_stacked[index].zero_()
43 + self.full_bias_stacked[index].zero_()
44 + self.full_module_enabled[index] = False
45 +
46 + def set_module_to_save(
47 + self,
48 + index: int,
49 + weight: torch.Tensor,
50 + bias: torch.Tensor | None,
51 + ) -> None:
52 + self.reset_module_to_save(index)
53 + self.full_weight_stacked[index, 0].copy_(weight, non_blocking=True)
54 + if bias is not None:
55 + self.full_bias_stacked[index].copy_(bias, non_blocking=True)
56 + self.full_module_enabled[index] = True
57 +
58 + def forward(self, input_: torch.Tensor) -> torch.Tensor:
59 + # TODO base_result maybe don't need compute truly.
60 + base_result = super().forward(input_)
61 + output = base_result[0] if isinstance(base_result, tuple) else base_result
62 +
63 + full_output = self.punica_wrapper.apply_lora_full_linear(
64 + output,
65 + input_.to(self.full_weight_stacked.dtype),
66 + self.full_weight_stacked,
67 + self.full_bias_stacked,
68 + self.full_module_enabled,
69 + )
70 +
71 + if full_output is not None:
72 + output = full_output
73 + return output
74 +
75 + def _apply_lora_to_output(
76 + self, x: torch.Tensor, output: torch.Tensor
77 + ) -> torch.Tensor:
78 + original_shape = output.shape if output.ndim == 3 else None
79 + if x.ndim == 3 and output.ndim == 3:
80 + output = output.flatten(0, 1)
81 + x = x.flatten(0, 1)
82 +
83 + # Classification outputs are request-level, so use prompt-based LoRA routing.
84 + lora_output: torch.Tensor | None = self.punica_wrapper.add_lora_logits(
85 + output,
86 + x.to(self.lora_a_stacked[0].dtype),
87 + self.lora_a_stacked[0],
88 + self.lora_b_stacked[0],
89 + 1.0,
90 + )
91 + if not current_platform.can_update_inplace():
92 + output = lora_output
93 +
94 + if original_shape is not None:
95 + output = output.reshape(original_shape)
96 +
97 + return output
vllm/lora/lora_model.py
+38 -3
@@ -8,7 +8,7 @@ import safetensors
8 import torch
9
10 from vllm.logger import init_logger
11 -from vllm.lora.lora_weights import LoRALayerWeights
11 +from vllm.lora.lora_weights import LoRAFullModuleWeights, LoRALayerWeights
12 from vllm.lora.peft_helper import PEFTHelper
13 from vllm.lora.utils import (
14 get_lora_id,
@@ -66,6 +66,8 @@ class LoRAModel:
66 rank: int,
67 loras: dict[str, LoRALayerWeights],
68 is_3d_lora_weight: bool = False,
69 + *,
70 + modules_to_save: dict[str, LoRAFullModuleWeights] | None = None,
71 ) -> None:
72 """
73 Args:
@@ -85,6 +87,7 @@ class LoRAModel:
87 )
88 self.rank = rank
89 self.loras: dict[str, LoRALayerWeights] = loras
90 + self.modules_to_save = modules_to_save or {}
91 self.is_3d_lora_weight = is_3d_lora_weight
92
93 def clone(self, lora_model_id: int) -> "LoRAModel":
@@ -96,12 +99,16 @@ class LoRAModel:
99 rank=self.rank,
100 loras=self.loras.copy(),
101 is_3d_lora_weight=self.is_3d_lora_weight,
102 + modules_to_save=self.modules_to_save.copy(),
103 )
104
105 def get_lora(self, module_name: str) -> LoRALayerWeights | None:
106 """Get LoRA for a given module by name"""
107 return self.loras.get(module_name, None)
108
109 + def get_module_to_save(self, module_name: str) -> LoRAFullModuleWeights | None:
110 + return self.modules_to_save.get(module_name)
111 +
112 def check_lora_name(self, lora_name: str) -> bool:
113 return lora_name in self.loras
114
@@ -127,6 +134,9 @@ class LoRAModel:
134 ) -> "LoRAModel":
135 """Create a LoRAModel from a dictionary of tensors."""
136 pin_memory = str(device) == "cpu" and PIN_MEMORY
137 +
138 + modules_to_save_names = peft_helper.modules_to_save or []
139 + full_parameters: dict[str, dict[str, torch.Tensor]] = {}
140 loras: dict[str, LoRALayerWeights] = {}
141 for tensor_name, tensor in tensors.items():
142 if is_base_embedding_weights(tensor_name):
@@ -137,6 +147,12 @@ class LoRAModel:
147 module_name, is_lora_a = parse_fine_tuned_lora_name(
148 tensor_name, weights_mapper
149 )
150 + if module_name in modules_to_save_names:
151 + full_parameters.setdefault(module_name, {})[
152 + tensor_name.split(".")[-1]
153 + ] = tensor.to(device=device)
154 + continue
155 +
156 if module_name not in loras:
157 loras[module_name] = LoRALayerWeights.from_config(
158 module_name, peft_helper
@@ -161,7 +177,23 @@ class LoRAModel:
177 if pin_memory:
178 loras[module_name].lora_b = loras[module_name].lora_b.pin_memory()
179
164 - return cls(lora_model_id, peft_helper.r, loras)
180 + modules_to_save = {}
181 + for module_name, parameters in full_parameters.items():
182 + weight = parameters.get("weight")
183 + if weight is None:
184 + raise ValueError(f"Full module {module_name!r} is missing weight.")
185 + modules_to_save[module_name] = LoRAFullModuleWeights(
186 + module_name=module_name,
187 + weight=weight,
188 + bias=parameters.get("bias"),
189 + )
190 + if len(modules_to_save) > 1:
191 + raise ValueError(
192 + "Only one full classification module is supported per "
193 + f"adapter, received {sorted(modules_to_save)}."
194 + )
195 +
196 + return cls(lora_model_id, peft_helper.r, loras, modules_to_save=modules_to_save)
197
198 @classmethod
199 def from_local_checkpoint(
@@ -223,6 +255,7 @@ class LoRAModel:
255 ):
256 continue
257 module_name, _ = parse_fine_tuned_lora_name(lora_module, weights_mapper)
258 + base_name = module_name.rsplit(".", 1)[-1]
259 # Case for expert lora weights
260 if ".experts" in module_name:
261 expert_idx = module_name.find(".experts")
@@ -230,7 +263,9 @@ class LoRAModel:
263 if expert_suffix not in expected_lora_modules:
264 unexpected_modules.append(module_name)
265
233 - elif module_name.rsplit(".", 1)[-1] not in expected_lora_modules:
266 + elif base_name not in expected_lora_modules and base_name not in (
267 + peft_helper.modules_to_save or ()
268 + ):
269 unexpected_modules.append(module_name)
270
271 if unexpected_modules:
vllm/lora/lora_weights.py
+15
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from collections.abc import Sequence as GenericSequence
5 +from dataclasses import dataclass
6
7 import torch
8 import torch.types
@@ -281,3 +282,17 @@ class PackedLoRALayerWeights(LoRALayerWeights):
282 @property
283 def is_packed(self) -> bool:
284 return True
285 +
286 +
287 +@dataclass
288 +class LoRAFullModuleWeights:
289 + """LoRA weights for classification layers."""
290 +
291 + module_name: str
292 + weight: torch.Tensor
293 + bias: torch.Tensor | None = None
294 +
295 + def pin_memory(self) -> None:
296 + self.weight = self.weight.pin_memory()
297 + if self.bias is not None:
298 + self.bias = self.bias.pin_memory()
vllm/lora/model_manager.py
+148 -8
@@ -13,16 +13,22 @@ from vllm.config.lora import LoRAConfig
13 from vllm.logger import init_logger
14 from vllm.lora.layers import (
15 BaseLayerWithLoRA,
16 + ClassificationHeadWithLoRA,
17 FusedMoE3DWithLoRA,
18 FusedMoEWithLoRA,
19 LoRAMapping,
20 LoRAMappingType,
21 )
22 from vllm.lora.lora_model import LoRAModel, MoEEPLoadSpec
22 -from vllm.lora.lora_weights import LoRALayerWeights, PackedLoRALayerWeights
23 +from vllm.lora.lora_weights import (
24 + LoRAFullModuleWeights,
25 + LoRALayerWeights,
26 + PackedLoRALayerWeights,
27 +)
28 from vllm.lora.punica_wrapper import PunicaWrapperBase, get_punica_wrapper
29 from vllm.lora.utils import (
30 from_layer,
31 + from_layer_classification,
32 from_layer_logits_processor,
33 get_supported_lora_modules,
34 is_in_target_modules,
@@ -114,6 +120,13 @@ class LoRAModelManager:
120 self.vocab_size = vocab_size
121
122 self.is_pooling_model = is_pooling_model(self.model)
123 + self._pooling_task = (
124 + vllm_config.model_config.get_pooling_task(
125 + tuple(self.model.pooler.get_supported_tasks())
126 + )
127 + if self.is_pooling_model
128 + else None
129 + )
130 self.packed_modules: dict[str, list[str]] = {}
131 self.modules: dict[str, BaseLayerWithLoRA] = {}
132 self._last_mapping: LoRAMapping | None = None
@@ -121,6 +134,13 @@ class LoRAModelManager:
134 is_moe = is_moe_model(self.model)
135 self._is_moe = is_moe
136
137 + self.supported_modules_to_save = (
138 + {"score", "classifier"}
139 + if self.is_pooling_model
140 + and vllm_config.model_config.score_type == "cross-encoder"
141 + else set()
142 + )
143 +
144 # When the engine is started with enable_mixed_moe_lora_format=True
145 # we force the universal 2D wrapper (FusedMoEWithLoRA) regardless of
146 # the model's 3D flag, so 2D and 3D adapters can coexist.
@@ -147,6 +167,20 @@ class LoRAModelManager:
167 self._init_punica_wrapper(max_num_batched_tokens, vllm_config)
168 self._create_lora_modules()
169
170 + self._classification_head: tuple[str, ClassificationHeadWithLoRA] | None = None
171 + if self.is_pooling_model:
172 + classification_heads = [
173 + (module_name, module)
174 + for module_name, module in self.modules.items()
175 + if isinstance(module, ClassificationHeadWithLoRA)
176 + ]
177 + if classification_heads:
178 + assert len(classification_heads) == 1, (
179 + "Expected 1 classification head, but found "
180 + f"{len(classification_heads)}."
181 + )
182 + self._classification_head = classification_heads[0]
183 +
184 self.moe_ep_load_spec: MoEEPLoadSpec | None = self._build_moe_ep_load_spec()
185
186 self.model.lora_manager = self
@@ -338,6 +372,15 @@ class LoRAModelManager:
372 self.lora_index_to_id[index] = lora_model.id
373 num_lora_weights_applied = 0
374 for module_name, module in self.modules.items():
375 + if isinstance(module, ClassificationHeadWithLoRA):
376 + full_module = self._get_module_to_save_weights(lora_model, module_name)
377 + if full_module is None:
378 + module.reset_module_to_save(index)
379 + else:
380 + module.set_module_to_save(
381 + index, full_module.weight, full_module.bias
382 + )
383 +
384 module_lora = self._get_lora_layer_weights(lora_model, module_name)
385 if not module_lora:
386 module.reset_lora(index)
@@ -427,6 +470,8 @@ class LoRAModelManager:
470 if isinstance(module, PPMissingLayer):
471 continue
472
473 + is_classifier_head = module_name in self.supported_modules_to_save
474 +
475 if self.lora_config.target_modules is None:
476 if not is_supported_lora_module(
477 module_name,
@@ -434,7 +479,8 @@ class LoRAModelManager:
479 self.supported_lora_modules,
480 ):
481 continue
437 - elif not self._match_target_modules(module_name):
482 + elif not self._match_target_modules(module_name) and not is_classifier_head:
483 + # Full classification heads bypass the A/B LoRA target filter.
484 continue
485
486 punica_wrapper = self._get_punica_wrapper(module_name)
@@ -486,17 +532,25 @@ class LoRAModelManager:
532 # LoRA weights of w1 and w3 have already been fused on disk.
533
534 packed_moduled_lst = ["w13"] if self._is_3d_moe_model else ["w1", "w3"]
489 - new_module = replace_submodule(
490 - self.model,
491 - module_name,
492 - from_layer(
535 + if is_classifier_head:
536 + new_module = from_layer_classification(
537 + module,
538 + self.lora_slots,
539 + self.lora_config,
540 + self.model.config,
541 + )
542 + else:
543 + new_module = from_layer(
544 module,
545 self.lora_slots,
546 self.lora_config,
547 packed_moduled_lst,
548 self.model.config,
498 - ),
499 - )
549 + )
550 + new_module = replace_submodule(self.model, module_name, new_module)
551 +
552 + if is_classifier_head:
553 + self.model.pooler.replace_classifier(new_module)
554 if isinstance(new_module, BaseLayerWithLoRA):
555 wrapped_by_id[id(module)] = new_module
556 wrapped_by_id[id(new_module)] = new_module
@@ -746,6 +800,12 @@ class LoRAModelManager:
800 if not self.supports_mm:
801 return self.punica_wrapper_mapping[DEFAULT_LANGUAGE_WRAPPER_KEY]
802
803 + if module_name in self.supported_modules_to_save:
804 + lm_wrapper = self.punica_wrapper_mapping.get(
805 + self.mm_mapping.language_model[0]
806 + )
807 + return lm_wrapper
808 +
809 # For multimodal model
810 # NOTE Sort by prefix length (descending) to match the longest prefix first
811 # e.g., 'visual.merger' should match 'visual.merger' instead of 'visual.'
@@ -871,6 +931,10 @@ class LoRAModelManager:
931 lora.lora_a = lora.lora_a.pin_memory()
932 lora.lora_b = lora.lora_b.pin_memory()
933
934 + if lora_model.modules_to_save:
935 + for full_module in lora_model.modules_to_save.values():
936 + full_module.pin_memory()
937 +
938 def _stack_moe_lora_weights(
939 self, lora_model: LoRAModel, module: FusedMoE3DWithLoRA, module_name: str
940 ):
@@ -1164,6 +1228,82 @@ class LoRAModelManager:
1228 )
1229 return lora_model.get_lora(org_module_name)
1230
1231 + def _get_module_to_save_weights(
1232 + self, lora_model: LoRAModel, module_name: str
1233 + ) -> LoRAFullModuleWeights | None:
1234 + weights = lora_model.get_module_to_save(module_name)
1235 + if weights is not None:
1236 + return weights
1237 +
1238 + unprefixed_module_name = module_name.removeprefix("model.")
1239 + weights = lora_model.get_module_to_save(unprefixed_module_name)
1240 + if weights is not None and unprefixed_module_name != module_name:
1241 + logger.info_once(
1242 + "For the pool model, successfully loaded the full module weights "
1243 + "after removing the prefix 'model.'."
1244 + )
1245 + return weights
1246 +
1247 + def _validate_modules_to_save(self, lora_model: LoRAModel) -> None:
1248 + if not lora_model.modules_to_save:
1249 + return
1250 + saved_module_name = next(iter(lora_model.modules_to_save))
1251 + if self._classification_head is None:
1252 + raise ValueError(
1253 + f"Cannot load full module {saved_module_name!r}: the model does "
1254 + "not expose a unique ClassificationHeadWithLoRA."
1255 + )
1256 +
1257 + module_name, wrapper = self._classification_head
1258 + full_module = self._get_module_to_save_weights(lora_model, module_name)
1259 + if full_module is None:
1260 + raise ValueError(
1261 + f"Full module {saved_module_name!r} does not match the model's "
1262 + f"classification head {module_name!r}."
1263 + )
1264 +
1265 + expected_weight_shape = (wrapper.output_size, wrapper.input_size)
1266 + received_weight_shape = tuple(full_module.weight.shape)
1267 + if received_weight_shape != expected_weight_shape:
1268 + raise ValueError(
1269 + f"Full module {saved_module_name!r} for {module_name!r} has "
1270 + "an incompatible weight shape: expected "
1271 + f"{expected_weight_shape}, received {received_weight_shape}."
1272 + )
1273 +
1274 + if full_module.bias is None:
1275 + return
1276 + expected_bias_shape = (wrapper.output_size,)
1277 + received_bias_shape = tuple(full_module.bias.shape)
1278 + if received_bias_shape != expected_bias_shape:
1279 + raise ValueError(
1280 + f"Full module {saved_module_name!r} for {module_name!r} has "
1281 + "an incompatible bias shape: expected "
1282 + f"{expected_bias_shape}, received {received_bias_shape}."
1283 + )
1284 +
1285 + def _validate_token_classification_lora(self, lora_model: LoRAModel) -> None:
1286 + if self._pooling_task != "token_classify":
1287 + return
1288 + if self._classification_head is None:
1289 + return
1290 +
1291 + module_name, _ = self._classification_head
1292 + has_full_module = (
1293 + self._get_module_to_save_weights(lora_model, module_name) is not None
1294 + )
1295 + has_lora_weights = (
1296 + self._get_lora_layer_weights(lora_model, module_name) is not None
1297 + )
1298 + if not has_full_module and not has_lora_weights:
1299 + return
1300 +
1301 + raise ValueError(
1302 + f"LoRA adapter {lora_model.id} contains weights for classification "
1303 + f"head {module_name!r}, but token_classify only supports LoRA on "
1304 + "the model backbone."
1305 + )
1306 +
1307 def deactivate_adapter(self, adapter_id: int) -> bool:
1308 if adapter_id not in self._active_adapters:
1309 return False
vllm/lora/peft_helper.py
+9 -1
@@ -45,7 +45,15 @@ class PEFTHelper:
45 """
46 error_msg = []
47 if self.modules_to_save:
48 - error_msg.append("vLLM only supports modules_to_save being None.")
48 + unsupported_modules = [
49 + m for m in self.modules_to_save if m not in ["classifier", "score"]
50 + ]
51 + if unsupported_modules:
52 + error_msg.append(
53 + "vLLM only supports modules_to_save being either None "
54 + 'or ["classifier", "score"] for classification models. '
55 + f"Unsupported modules_to_save: {unsupported_modules}"
56 + )
57 if self.use_dora:
58 error_msg.append("vLLM does not yet support DoRA.")
59 return error_msg
vllm/lora/punica_wrapper/punica_base.py
+28
@@ -120,6 +120,18 @@ class PunicaWrapperABC(ABC):
120 """
121 raise NotImplementedError
122
123 + @abstractmethod
124 + def apply_lora_full_linear(
125 + self,
126 + y: torch.Tensor,
127 + x: torch.Tensor,
128 + weight_stacked: torch.Tensor,
129 + bias_stacked: torch.Tensor,
130 + module_enabled: torch.Tensor,
131 + ) -> torch.Tensor | None:
132 + """Apply request-routed full linear weights to selected rows."""
133 + raise NotImplementedError
134 +
135
136 class PunicaWrapperBase(PunicaWrapperABC):
137 """
@@ -217,6 +229,22 @@ class PunicaWrapperBase(PunicaWrapperABC):
229 self.token_nums = token_nums
230 self.no_lora = no_lora
231
232 + def _select_full_linear_output(
233 + self,
234 + y: torch.Tensor,
235 + adapter_y: torch.Tensor,
236 + bias_stacked: torch.Tensor,
237 + module_enabled: torch.Tensor,
238 + ) -> torch.Tensor:
239 + indices = self.sampler_indices
240 + assert indices.size(0) == y.size(0), (
241 + "Full linear rows do not match LoRA request mapping"
242 + )
243 + safe_indices = indices.clamp_min(0)
244 + use_full = (indices >= 0) & module_enabled[safe_indices]
245 + adapter_y = adapter_y.to(y.dtype) + bias_stacked[safe_indices].to(y.dtype)
246 + return torch.where(use_full.unsqueeze(-1), adapter_y, y)
247 +
248 @property
249 def prefill_metadata(
250 self,
vllm/lora/punica_wrapper/punica_cpu.py
+20
@@ -349,3 +349,23 @@ class PunicaWrapperCPU(PunicaWrapperBase):
349 bgmv_shrink(x, lora_a_stacked, buffer, self.sampler_indices, scale)
350 bgmv_expand(buffer, lora_b_stacked, y, self.sampler_indices, add_inputs=True)
351 y = y.view_as(y_org)
352 +
353 + def apply_lora_full_linear(
354 + self,
355 + y: torch.Tensor,
356 + x: torch.Tensor,
357 + weight_stacked: torch.Tensor,
358 + bias_stacked: torch.Tensor,
359 + module_enabled: torch.Tensor,
360 + ) -> None:
361 + indices = self.sampler_indices
362 + adapter_y = torch.zeros(
363 + (x.size(0), weight_stacked.size(-2)),
364 + dtype=torch.float32,
365 + device=x.device,
366 + )
367 + bgmv_shrink(x, weight_stacked, adapter_y, indices, 1.0)
368 + result = self._select_full_linear_output(
369 + y, adapter_y, bias_stacked, module_enabled
370 + )
371 + y.copy_(result)
vllm/lora/punica_wrapper/punica_gpu.py
+27
@@ -327,6 +327,33 @@ class PunicaWrapperGPU(PunicaWrapperBase):
327 )
328 y = y.view_as(y_org)
329
330 + def apply_lora_full_linear(
331 + self,
332 + y: torch.Tensor,
333 + x: torch.Tensor,
334 + weight_stacked: torch.Tensor,
335 + bias_stacked: torch.Tensor,
336 + module_enabled: torch.Tensor,
337 + ) -> None:
338 + adapter_y = torch.empty(
339 + (x.size(0), weight_stacked.size(-2)),
340 + dtype=torch.float32,
341 + device=x.device,
342 + )
343 + lora_shrink(
344 + x,
345 + [weight_stacked],
346 + adapter_y.unsqueeze(0),
347 + *self.prompt_mapping_meta.meta_args(
348 + x.size(0), self.lora_config.specialize_active_lora
349 + ),
350 + 1.0,
351 + )
352 + result = self._select_full_linear_output(
353 + y, adapter_y, bias_stacked, module_enabled
354 + )
355 + y.copy_(result)
356 +
357 def moe_lora_align_block_size(
358 self,
359 topk_ids: torch.Tensor,
vllm/lora/punica_wrapper/punica_xpu.py
+20
@@ -333,6 +333,26 @@ class PunicaWrapperXPU(PunicaWrapperBase):
333 bgmv_expand(buffer, lora_b_stacked, y, sampler_indices, add_inputs=True)
334 y = y.view_as(y_org)
335
336 + def apply_lora_full_linear(
337 + self,
338 + y: torch.Tensor,
339 + x: torch.Tensor,
340 + weight_stacked: torch.Tensor,
341 + bias_stacked: torch.Tensor,
342 + module_enabled: torch.Tensor,
343 + ) -> None:
344 + indices = self.sampler_indices
345 + adapter_y = torch.zeros(
346 + (x.size(0), weight_stacked.size(-2)),
347 + dtype=torch.float32,
348 + device=x.device,
349 + )
350 + bgmv_shrink(x, weight_stacked, adapter_y, indices, 1.0)
351 + result = self._select_full_linear_output(
352 + y, adapter_y, bias_stacked, module_enabled
353 + )
354 + y.copy_(result)
355 +
356 def moe_lora_align_block_size(
357 self,
358 topk_ids: torch.Tensor,
vllm/lora/utils.py
+20 -7
@@ -15,6 +15,7 @@ from vllm.logger import init_logger
15 # being imported for _all_lora_classes below
16 from vllm.lora.layers import (
17 BaseLayerWithLoRA,
18 + ClassificationHeadWithLoRA,
19 ColumnParallelLinearWithLoRA,
20 ColumnParallelLinearWithShardedLoRA,
21 FusedMoE3DWithLoRA,
@@ -143,6 +144,17 @@ def from_layer_logits_processor(
144 return ret
145
146
147 +def from_layer_classification(
148 + layer: nn.Module,
149 + max_loras: int,
150 + lora_config: LoRAConfig,
151 + model_config: PretrainedConfig | None = None,
152 +) -> ClassificationHeadWithLoRA:
153 + instance_layer = ClassificationHeadWithLoRA(layer)
154 + instance_layer.create_lora_weights(max_loras, lora_config, model_config)
155 + return instance_layer
156 +
157 +
158 def replace_submodule(
159 model: nn.Module, module_name: str, new_module: nn.Module
160 ) -> nn.Module:
@@ -193,13 +205,14 @@ def parse_fine_tuned_lora_name(
205 start_index = 2 if name.startswith("base_model.model.") else 0
206
207 parts = name.split(".")
196 - if (
197 - parts[-1] == "weight"
198 - and len(parts) >= 2
199 - and (parts[-2] == "lora_A" or parts[-2] == "lora_B")
200 - ):
201 - new_name = ".".join(parts[start_index:-2])
202 - return new_name, parts[-2] == "lora_A"
208 + if (parts[-1] == "weight" or parts[-1] == "bias") and len(parts) >= 2:
209 + if parts[-2] in ["lora_A", "lora_B"]:
210 + new_name = ".".join(parts[start_index:-2])
211 + return new_name, parts[-2] == "lora_A"
212 + # For modules_to_save in classification.
213 + elif parts[-2] in ["score", "classifier"]:
214 + new_name = parts[-2]
215 + return new_name, False
216
217 if parts[-1] == "lora_embedding_A" or parts[-1] == "lora_embedding_B":
218 new_name = ".".join(parts[start_index:-1])
vllm/lora/worker_manager.py
+4
@@ -157,6 +157,10 @@ class WorkerLoRAManager:
157 # 3D->2D conversion when running under the universal 2D wrapper.
158 lora.is_3d_lora_weight = lora_request.is_3d_lora_weight
159
160 + # Validate classification-head weights.
161 + self._adapter_manager._validate_modules_to_save(lora)
162 + self._adapter_manager._validate_token_classification_lora(lora)
163 +
164 except FileNotFoundError as e:
165 # FileNotFoundError should be raised if both
166 # - No adapter found to download from huggingface (or in
vllm/model_executor/layers/pooler/special.py
+14
@@ -74,6 +74,20 @@ class DispatchPooler(Pooler):
74 def get_pooling_updates(self, task: PoolingTask) -> PoolingParamsUpdate:
75 return self.poolers_by_task[task].get_pooling_updates(task)
76
77 + def replace_classifier(
78 + self,
79 + new_classifier: ClassifierFn,
80 + ) -> None:
81 + """Replaces the classifier to the LoRA-wrapped version."""
82 + for task, pooler in self.poolers_by_task.items():
83 + if task not in {"classify"}:
84 + # Now LoRA only supports classification tasks,
85 + # so we only replace the classifier for "classify" task.
86 + continue
87 + head = getattr(pooler, "head", None)
88 + if head is not None and hasattr(head, "classifier"):
89 + head.classifier = new_classifier
90 +
91 def forward(
92 self,
93 hidden_states: torch.Tensor,