[Bugfix][LoRA] Guard None group members in expand_packed_lora (partial LoRA on Qwen3.5/3.6 GatedDeltaNet) (#47640)

Signed-off-by: Eilam C <eilamc14@gmail.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>

Eilam committed Aug 19, 2026 at 16:24 UTC c2e7242ab7287cc9e2bdbea5901c560fc2d17bff
1 file changed +25 -6
vllm/lora/layers/column_parallel_linear.py
+25 -6
@@ -265,17 +265,36 @@ class MergedColumnParallelLinearWithLoRA(ColumnParallelLinearWithLoRA):
265
266 def expand_packed_lora(
267 self,
268 - lora_a: list[torch.Tensor],
269 - lora_b: list[torch.Tensor],
270 - ) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
268 + lora_a: list[torch.Tensor | None],
269 + lora_b: list[torch.Tensor | None],
270 + ) -> tuple[list[torch.Tensor | None], list[torch.Tensor | None]]:
271 """
272 Expand packed adapter groups when they don't match n_slices.
273 - E.g. in_proj_qkv (covers Q+K+V) + in_proj_z
273 + E.g. in_proj_qkv (covers Q+K+V) + in_proj_z.
274 +
275 + A None group member means that member was not adapted; the slice(s)
276 + it covers are emitted as None placeholders so subsequent groups stay
277 + aligned and those slices are left at base weights. This matches the
278 + None-tolerance already present in slice_lora_b() and the set_lora()
279 + stacking loop.
280 """
275 - expanded_a: list[torch.Tensor] = []
276 - expanded_b: list[torch.Tensor] = []
281 + expanded_a: list[torch.Tensor | None] = []
282 + expanded_b: list[torch.Tensor | None] = []
283 start_idx = 0
284 for a_i, b_i in zip(lora_a, lora_b):
285 + if b_i is None:
286 + # Unadapted group member: its row count is unknown (the tensor
287 + # is missing), so infer its coverage as the remaining slices.
288 + # This is exact for the only layout that reaches this path,
289 + # the fused GDN in_proj_qkvz group, whose sole multi-slice
290 + # member (in_proj_qkv, Q+K+V) leads and whose only optional
291 + # member (in_proj_z) trails.
292 + covered = self.n_slices - start_idx
293 + for _ in range(covered):
294 + expanded_a.append(None)
295 + expanded_b.append(None)
296 + start_idx += covered
297 + continue
298 # Determine which output slices this b_i covers.
299 b_rows, cu_rows, covered = b_i.shape[0], 0, 0
300 for i in range(start_idx, self.n_slices):