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 3, 13:00 UTC
[LoRA][Bugfix] Dedup LoRA wrapping for modules referenced from multiple attribute paths (MoE gate) (#42757)
Signed-off-by: Jee Jee Li <pandaleefree@gmail.com>
Jee Jee Li committed
May 16, 2026 at 11:22 UTC
32b7177909d1c9928bcedd81de7de5a1fa21d2b3
2 files changed
+115
tests/lora/test_lora_manager.py
+94
@@ -179,6 +179,100 @@ def test_wrap_gate_linear(default_vllm_config, dist_init, dummy_model):
179
)
180
181
182
+def test_dedup_shared_module_across_paths(default_vllm_config, dist_init, dummy_model):
183
+ """A module reachable from two attribute paths (e.g. a MoE gate held
184
+ both directly on the block and inside its inner runner) must produce a
185
+ single LoRA wrapper. Both paths must end up pointing to that same
186
+ wrapper instance, and only the canonical path should live in
187
+ `manager.modules` — otherwise activate_adapter would call `reset_lora`
188
+ on the alias and clobber weights set under the canonical name.
189
+ """
190
+ from vllm.model_executor.layers.linear import ReplicatedLinear
191
+
192
+ class AliasContainer(nn.Module):
193
+ def __init__(self, gate: nn.Module):
194
+ super().__init__()
195
+ self.gate = gate # canonical path: "moe.gate"
196
+
197
+ # Inner submodule holding the SAME gate instance under another
198
+ # path. This mirrors how FusedMoE.runner.gate references the
199
+ # block's gate in qwen3_moe.
200
+ class _Runner(nn.Module):
201
+ def __init__(self, g):
202
+ super().__init__()
203
+ self.gate = g # alias path: "moe.runner.gate"
204
+
205
+ self.runner = _Runner(gate)
206
+
207
+ gate = ReplicatedLinear(10, 4, bias=False)
208
+ model = dummy_model
209
+ model.add_module("moe", AliasContainer(gate))
210
+
211
+ assert model.moe.gate is model.moe.runner.gate
212
+
213
+ manager = LoRAModelManager(
214
+ model,
215
+ 1,
216
+ 1,
217
+ 1,
218
+ LoRAConfig(
219
+ max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE
220
+ ),
221
+ torch.device(DEVICES[0]),
222
+ )
223
+
224
+ canonical = manager.model.get_submodule("moe.gate")
225
+ alias = manager.model.get_submodule("moe.runner.gate")
226
+
227
+ # Same wrapper instance on both paths so forward through either side
228
+ # sees the LoRA-augmented module.
229
+ assert isinstance(canonical, ReplicatedLinearWithLoRA)
230
+ assert alias is canonical
231
+
232
+ # Only the canonical path is tracked as a LoRA target. Tracking the
233
+ # alias would cause activate_adapter to reset_lora on it after the
234
+ # canonical entry already populated the weights.
235
+ assert "moe.gate" in manager.modules
236
+ assert "moe.runner.gate" not in manager.modules
237
+
238
+
239
+def test_lm_head_exempt_from_dedup(default_vllm_config, dist_init, dummy_model):
240
+ """The dedup logic must NOT collapse `lm_head` even when it is reachable
241
+ from another attribute path (tied-embedding models do
242
+ `self.lm_head = self.model.embed_tokens`, sharing the same nn.Module
243
+ instance). The lm_head branch additionally rewires `logits_processor`
244
+ into a `LogitsProcessorWithLoRA`, so skipping it would silently break
245
+ LoRA on lm_head.
246
+ """
247
+ from vllm.lora.layers import LogitsProcessorWithLoRA
248
+
249
+ # Add a non-lm_head alias to the same module instance as lm_head. The
250
+ # dedup keys on id(module); without the lm_head exemption the alias
251
+ # would consume the wrapped_by_id slot first and lm_head would be
252
+ # silently skipped, so logits_processor would never be wrapped.
253
+ model = dummy_model
254
+ model.add_module("embed_tokens", model.lm_head)
255
+ assert model.embed_tokens is model.lm_head
256
+
257
+ manager = LoRAModelManager(
258
+ model,
259
+ 1,
260
+ 1,
261
+ 1,
262
+ LoRAConfig(
263
+ max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE
264
+ ),
265
+ torch.device(DEVICES[0]),
266
+ )
267
+
268
+ # lm_head's special handling still ran: logits_processor got wrapped
269
+ # and the lm_head entry is tracked under self.modules.
270
+ assert isinstance(
271
+ manager.model.get_submodule("logits_processor"), LogitsProcessorWithLoRA
272
+ )
273
+ assert "lm_head" in manager.modules
274
+
275
+
276
def test_skip_unsupported_matched_modules(default_vllm_config, dist_init, dummy_model):
277
class UnsupportedContainer(nn.Module):
278
def __init__(self):
vllm/lora/model_manager.py
+21
@@ -361,6 +361,8 @@ class LoRAModelManager:
361
# - given an input 'x' return ''
362
return module_name.rpartition(".")[0]
363
364
+ wrapped_by_id: dict[int, BaseLayerWithLoRA] = {}
365
+
366
for module_name, module in self.model.named_modules(remove_duplicate=False):
367
if isinstance(module, PPMissingLayer):
368
continue
@@ -391,6 +393,23 @@ class LoRAModelManager:
393
)
394
continue
395
396
+ existing_wrapper = wrapped_by_id.get(id(module))
397
+ if existing_wrapper is not None and "lm_head" not in module_name:
398
+ # Same underlying module was already wrapped under another
399
+ # path (e.g. a MoE gate held both directly on the block and
400
+ # inside the MoE runner). The adapter targets the canonical
401
+ # path (`mlp.gate`); rewire the alias attribute
402
+ # (`runner.gate`) to the SAME wrapper so the forward path
403
+ # through the alias still applies LoRA, but do NOT add a
404
+ # second entry to self.modules — otherwise `activate_adapter`
405
+ # would call `reset_lora` on the alias and wipe the weights
406
+ # just set under the canonical name, because the alias can't
407
+ # load LoRA weights due to name mismatch.
408
+ parent = self.model.get_submodule(_parent_module(module_name))
409
+ # reference
410
+ setattr(parent, module_name.rpartition(".")[-1], existing_wrapper)
411
+ continue
412
+
413
parts = module_name.split(".")[-1]
414
packed_moduled_lst = self.packed_modules_mapping.get(parts, [])
415
if isinstance(module, FusedMoE):
@@ -411,6 +430,8 @@ class LoRAModelManager:
430
self.model.config,
431
),
432
)
433
+ if isinstance(new_module, BaseLayerWithLoRA):
434
+ wrapped_by_id[id(module)] = new_module
435
436
# (yard1): TODO make this more robust
437
if "lm_head" in module_name: