[LoRA] Support variable num_labels for sequence classification (#57766)
Signed-off-by: linitra24 <renshuang.zhou@daocloud.io> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
linitra24 committed
Sep 28, 2026 at 22:58 UTC
953f90d25fea2240965878a1aca66cec519685b0
8 files changed
+90
-19
docs/features/lora.md
+8
-1
@@ -428,9 +428,16 @@ vLLM supports PEFT sequence-classification adapters that save a complete, single
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
+To batch adapters with different `num_labels`, set the maximum number of labels:
432
+
433
+```bash
434
+vllm serve model --enable-lora --max-lora-cls-labels 8
435
+```
436
+
437
+The equivalent `LLM` argument is `max_lora_cls_labels`. It defaults to the base model's `num_labels`, and each request returns its adapter's number of labels.
438
+
439
This support has the following limitations:
440
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.
441
- A classification head stored as float32 is converted to the runtime head dtype when it is loaded.
442
- Token-classification adapters are not supported by this feature.
443
tests/lora/test_sequence_classification.py
+2
-1
@@ -120,12 +120,13 @@ def _test_batched_loras(
120
model=MODEL_NAME,
121
runner="pooling",
122
convert="classify",
123
- hf_overrides={"num_labels": 2},
123
+ hf_overrides={"num_labels": 1},
124
dtype="float16",
125
enable_lora=True,
126
max_loras=2,
127
max_cpu_loras=2,
128
max_lora_rank=16,
129
+ max_lora_cls_labels=2,
130
enforce_eager=True,
131
max_model_len=512,
132
gpu_memory_utilization=0.5,
vllm/config/lora.py
+4
@@ -84,6 +84,9 @@ class LoRAConfig:
84
experts (stored once with expert-dim 1) instead of per-expert. The shared
85
factors are broadcast to the expert count at kernel time. Only meaningful for
86
MoE models whose adapters use this layout; ignored otherwise."""
87
+ max_lora_cls_labels: int | None = Field(default=None, ge=1)
88
+ """Maximum output size for LoRA classification heads. Defaults to the
89
+ base classification head size."""
90
91
def compute_hash(self) -> str:
92
"""WARNING: Whenever a new field is added to this config,
@@ -104,6 +107,7 @@ class LoRAConfig:
107
factors.append(self.enable_tower_connector_lora)
108
factors.append(self.enable_mixed_moe_lora_format)
109
factors.append(self.enable_moe_shared_loras)
110
+ factors.append(self.max_lora_cls_labels)
111
# target_modules affects which modules get LoRA applied
112
factors.append(
113
tuple(sorted(self.target_modules)) if self.target_modules else None
vllm/engine/arg_utils.py
+6
@@ -676,6 +676,7 @@ class EngineArgs:
676
specialize_active_lora: bool = LoRAConfig.specialize_active_lora
677
enable_mixed_moe_lora_format: bool = LoRAConfig.enable_mixed_moe_lora_format
678
enable_moe_shared_loras: bool = LoRAConfig.enable_moe_shared_loras
679
+ max_lora_cls_labels: int | None = LoRAConfig.max_lora_cls_labels
680
681
ray_workers_use_nsight: bool = ParallelConfig.ray_workers_use_nsight
682
num_gpu_blocks_override: int | None = CacheConfig.num_gpu_blocks_override
@@ -1571,6 +1572,10 @@ class EngineArgs:
1572
"--enable-moe-shared-loras",
1573
**lora_kwargs["enable_moe_shared_loras"],
1574
)
1575
+ lora_group.add_argument(
1576
+ "--max-lora-cls-labels",
1577
+ **lora_kwargs["max_lora_cls_labels"],
1578
+ )
1579
1580
# Logging arguments
1581
logging_group = parser.add_argument_group(
@@ -2587,6 +2592,7 @@ class EngineArgs:
2592
specialize_active_lora=self.specialize_active_lora,
2593
enable_mixed_moe_lora_format=self.enable_mixed_moe_lora_format,
2594
enable_moe_shared_loras=self.enable_moe_shared_loras,
2595
+ max_lora_cls_labels=self.max_lora_cls_labels,
2596
max_cpu_loras=self.max_cpu_loras
2597
if self.max_cpu_loras and self.max_cpu_loras > 0
2598
else None,
vllm/lora/layers/classifier.py
+39
-9
@@ -20,28 +20,38 @@ class ClassificationHeadWithLoRA(ReplicatedLinearWithLoRA):
20
) -> None:
21
# Preserve ordinary LoRA A/B support for classification heads.
22
super().create_lora_weights(max_loras, lora_config, model_config)
23
+
24
+ self.max_lora_cls_labels = lora_config.max_lora_cls_labels or self.output_size
25
+ self.padded_num_labels = max(self.output_size, self.max_lora_cls_labels)
26
self.full_weight_stacked = torch.zeros(
27
max_loras,
28
1,
26
- self.output_size,
29
+ self.padded_num_labels,
30
self.input_size,
31
dtype=lora_config.lora_dtype,
32
device=self.device,
33
)
34
self.full_bias_stacked = torch.zeros(
35
max_loras,
33
- self.output_size,
36
+ self.padded_num_labels,
37
dtype=self.base_layer.params_dtype,
38
device=self.device,
39
)
40
self.full_module_enabled = torch.zeros(
41
max_loras, dtype=torch.bool, device=self.device
42
)
43
+ # Zero marks an unused slot; positive values are unpadded label counts.
44
+ self.full_module_num_labels = [0] * max_loras
45
+ self._output_lora_indices: tuple[int, ...] = ()
46
+
47
+ def set_output_mapping(self, slot_indices: tuple[int, ...]) -> None:
48
+ self._output_lora_indices = slot_indices
49
50
def reset_module_to_save(self, index: int) -> None:
51
self.full_weight_stacked[index].zero_()
52
self.full_bias_stacked[index].zero_()
53
self.full_module_enabled[index] = False
54
+ self.full_module_num_labels[index] = 0
55
56
def set_module_to_save(
57
self,
@@ -50,17 +60,33 @@ class ClassificationHeadWithLoRA(ReplicatedLinearWithLoRA):
60
bias: torch.Tensor | None,
61
) -> None:
62
self.reset_module_to_save(index)
53
- self.full_weight_stacked[index, 0].copy_(weight, non_blocking=True)
63
+ num_labels = weight.size(0)
64
+ self.full_weight_stacked[index, 0, :num_labels].copy_(weight, non_blocking=True)
65
if bias is not None:
55
- self.full_bias_stacked[index].copy_(bias, non_blocking=True)
66
+ self.full_bias_stacked[index, :num_labels].copy_(bias, non_blocking=True)
67
+ self.full_module_num_labels[index] = num_labels
68
self.full_module_enabled[index] = True
69
58
- def forward(self, input_: torch.Tensor) -> torch.Tensor:
70
+ def forward(self, input_: torch.Tensor) -> torch.Tensor | list[torch.Tensor]:
71
# TODO base_result maybe don't need compute truly.
72
base_result = super().forward(input_)
61
- output = base_result[0] if isinstance(base_result, tuple) else base_result
73
+ base_result = base_result[0] if isinstance(base_result, tuple) else base_result
74
+
75
+ slot_indices = self._output_lora_indices
76
+ assert len(slot_indices) == base_result.size(0), (
77
+ "Classification rows do not match LoRA request mapping"
78
+ )
79
+ num_labels = [
80
+ self.full_module_num_labels[index] if index >= 0 else 0
81
+ for index in slot_indices
82
+ ]
83
+
84
+ if not any(num_labels):
85
+ return base_result
86
63
- full_output = self.punica_wrapper.apply_lora_full_linear(
87
+ output = base_result.new_zeros(base_result.size(0), self.padded_num_labels)
88
+ output[:, : self.output_size].copy_(base_result)
89
+ self.punica_wrapper.apply_lora_full_linear(
90
output,
91
input_.to(self.full_weight_stacked.dtype),
92
self.full_weight_stacked,
@@ -68,8 +94,12 @@ class ClassificationHeadWithLoRA(ReplicatedLinearWithLoRA):
94
self.full_module_enabled,
95
)
96
71
- if full_output is not None:
72
- output = full_output
97
+ # Rows without a full head keep the base classifier output size.
98
+ num_labels = [size or self.output_size for size in num_labels]
99
+ if len(set(num_labels)) == 1:
100
+ return output[:, : num_labels[0]]
101
+
102
+ output = [output[row, :size] for row, size in enumerate(num_labels)]
103
return output
104
105
def _apply_lora_to_output(
vllm/lora/model_manager.py
+19
-4
@@ -451,6 +451,16 @@ class LoRAModelManager:
451
self.vocab_size,
452
)
453
454
+ if self._classification_head is not None:
455
+ _, classification_head = self._classification_head
456
+ if classification_head.punica_wrapper is punica_wrapper:
457
+ classification_head.set_output_mapping(
458
+ tuple(
459
+ self.lora_index_to_id.index(lora_id) if lora_id > 0 else -1
460
+ for lora_id in mapping.prompt_mapping
461
+ )
462
+ )
463
+
464
def remove_all_adapters(self):
465
"""Remove all LoRAModels from the manager."""
466
self._registered_adapters.clear()
@@ -1262,18 +1272,23 @@ class LoRAModelManager:
1272
f"classification head {module_name!r}."
1273
)
1274
1265
- expected_weight_shape = (wrapper.output_size, wrapper.input_size)
1275
received_weight_shape = tuple(full_module.weight.shape)
1267
- if received_weight_shape != expected_weight_shape:
1276
+ if (
1277
+ full_module.weight.ndim != 2
1278
+ or full_module.weight.size(0) < 1
1279
+ or full_module.weight.size(0) > wrapper.max_lora_cls_labels
1280
+ or full_module.weight.size(1) != wrapper.input_size
1281
+ ):
1282
raise ValueError(
1283
f"Full module {saved_module_name!r} for {module_name!r} has "
1284
"an incompatible weight shape: expected "
1271
- f"{expected_weight_shape}, received {received_weight_shape}."
1285
+ f"(1..{wrapper.max_lora_cls_labels}, {wrapper.input_size}), "
1286
+ f"received {received_weight_shape}."
1287
)
1288
1289
if full_module.bias is None:
1290
return
1276
- expected_bias_shape = (wrapper.output_size,)
1291
+ expected_bias_shape = (full_module.weight.size(0),)
1292
received_bias_shape = tuple(full_module.bias.shape)
1293
if received_bias_shape != expected_bias_shape:
1294
raise ValueError(
vllm/model_executor/layers/pooler/seqwise/heads.py
+11
-3
@@ -171,16 +171,24 @@ class ClassifierPoolerHead(SequencePoolerHead):
171
pooled_data = pooled_data.to(self.head_dtype)
172
173
if self.classifier is not None:
174
- logits = self.classifier(pooled_data)
174
+ logits: SequencePoolerHeadOutput = self.classifier(pooled_data)
175
else:
176
logits = pooled_data
177
178
# logits shape: [batchsize, num_labels]
179
# Affine score calibration: activation((logit - mean) / sigma)
180
if self.logit_mean is not None:
181
- logits = logits - self.logit_mean
181
+ logits = (
182
+ [value - self.logit_mean for value in logits]
183
+ if isinstance(logits, list)
184
+ else logits - self.logit_mean
185
+ )
186
if self.logit_sigma is not None:
183
- logits = logits / self.logit_sigma
187
+ logits = (
188
+ [value / self.logit_sigma for value in logits]
189
+ if isinstance(logits, list)
190
+ else logits / self.logit_sigma
191
+ )
192
193
if self.activation is not None:
194
flags = [p.use_activation for p in pooling_params]
vllm/model_executor/layers/pooler/seqwise/poolers.py
+1
-1
@@ -130,7 +130,7 @@ def pooler_for_classify(
130
logit_mean=model_config.pooler_config.logit_mean,
131
logit_sigma=model_config.pooler_config.logit_sigma,
132
activation=resolve_classifier_act_fn(
133
- model_config, static_num_labels=True, act_fn=act_fn
133
+ model_config, static_num_labels=False, act_fn=act_fn
134
),
135
)
136