Support MLA properly in the Transformers modeling backend (#48250)

Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>

Harry Mellor committed Aug 4, 2026 at 13:24 UTC 59b2fdfc4e237794c2b26f0781054ced73f5b8df
9 files changed +699 -52
tests/models/transformers/fusers/test_mla.py new
+161
@@ -0,0 +1,161 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for the Transformers modeling backend's MLA fuser.
4 +
5 +The fuser must discover every MLA submodule structurally (never by assuming the
6 +Transformers attribute names) and, when the query is low-rank, merge the checkpoint's
7 +separate `q_a_proj`/`kv_a_proj_with_mqa` weights into the single fused down-projection.
8 +"""
9 +
10 +import pytest
11 +import torch
12 +import torch.nn as nn
13 +import torch.nn.functional as F
14 +
15 +from vllm.model_executor.models.transformers.fuser import get_fuser
16 +from vllm.model_executor.models.transformers.fusers import MLAFuser
17 +from vllm.model_executor.models.transformers.fx_utils import trace
18 +
19 +_FUSED_QKV_A_PROJ = MLAFuser.merged_name
20 +
21 +
22 +def _match(q_lora_rank: int | None) -> MLAFuser | None:
23 + """Match a meta `DeepseekV2Attention` directly (bypassing the per-class
24 + `get_fuser` cache, so both q_lora variants of the same class are seen)."""
25 + pytest.importorskip("transformers.models.deepseek_v2.modeling_deepseek_v2")
26 + from transformers.models.deepseek_v2.configuration_deepseek_v2 import (
27 + DeepseekV2Config,
28 + )
29 + from transformers.models.deepseek_v2.modeling_deepseek_v2 import (
30 + DeepseekV2Attention,
31 + )
32 +
33 + cfg = DeepseekV2Config(
34 + hidden_size=256,
35 + num_attention_heads=16,
36 + kv_lora_rank=128,
37 + qk_rope_head_dim=32,
38 + qk_nope_head_dim=32,
39 + v_head_dim=32,
40 + q_lora_rank=q_lora_rank,
41 + num_hidden_layers=1,
42 + )
43 + with torch.device("meta"):
44 + attn = DeepseekV2Attention(cfg, layer_idx=0)
45 + return MLAFuser.match(trace(attn), attn)
46 +
47 +
48 +def test_discovers_modules_without_q_lora():
49 + fuser = _match(q_lora_rank=None)
50 + assert isinstance(fuser, MLAFuser)
51 + assert not fuser.has_q_lora
52 + assert fuser.q_proj_name == "q_proj"
53 + assert fuser.kv_a_proj_name == "kv_a_proj_with_mqa"
54 + assert fuser.kv_a_layernorm_name == "kv_a_layernorm"
55 + assert fuser.kv_b_proj_name == "kv_b_proj"
56 + assert fuser.o_proj_name == "o_proj"
57 + assert fuser.q_a_proj_name is None
58 + # Nothing is stacked without a query LoRA.
59 + assert fuser.packed_modules_mapping == {}
60 + assert fuser.orig_to_new_stacked("model.layers.0.self_attn") == {}
61 +
62 +
63 +def test_discovers_modules_with_q_lora():
64 + fuser = _match(q_lora_rank=64)
65 + assert isinstance(fuser, MLAFuser)
66 + assert fuser.has_q_lora
67 + assert fuser.q_a_proj_name == "q_a_proj"
68 + assert fuser.q_a_layernorm_name == "q_a_layernorm"
69 + assert fuser.q_b_proj_name == "q_b_proj"
70 + assert fuser.kv_a_proj_name == "kv_a_proj_with_mqa"
71 + assert fuser.kv_a_layernorm_name == "kv_a_layernorm"
72 + assert fuser.kv_b_proj_name == "kv_b_proj"
73 + assert fuser.o_proj_name == "o_proj"
74 + assert fuser.q_proj_name is None
75 +
76 +
77 +def test_q_lora_stacks_qkv_a_proj():
78 + """The MLA layer reads `q_a_proj` and `kv_a_proj_with_mqa` fused into one
79 + down-projection, so both checkpoint weights must remap into it."""
80 + fuser = _match(q_lora_rank=64)
81 + assert isinstance(fuser, MLAFuser)
82 + prefix = "model.layers.0.self_attn"
83 + merged = f"{prefix}.{_FUSED_QKV_A_PROJ}"
84 + assert fuser.packed_modules_mapping == {
85 + _FUSED_QKV_A_PROJ: ["q_a_proj", "kv_a_proj_with_mqa"]
86 + }
87 + assert fuser.orig_to_new_stacked(prefix) == {
88 + f"{prefix}.q_a_proj": (merged, 0),
89 + f"{prefix}.kv_a_proj_with_mqa": (merged, 1),
90 + }
91 +
92 +
93 +class _Norm(nn.Module):
94 + """A real RMSNorm computation: `match` verifies chain norms via `RMSNormFuser`."""
95 +
96 + def __init__(self, n: int):
97 + super().__init__()
98 + self.weight = nn.Parameter(torch.ones(n))
99 +
100 + def forward(self, x):
101 + variance = x.pow(2).mean(-1, keepdim=True)
102 + return self.weight * (x * torch.rsqrt(variance + 1e-6))
103 +
104 +
105 +class RenamedMLA(nn.Module):
106 + """An MLA-shaped attention whose children have non-standard names, proving
107 + discovery is by structure and not attribute name."""
108 +
109 + def __init__(self):
110 + super().__init__()
111 + heads, kv_lora, rope, nope, v, hidden = 4, 32, 8, 8, 16, 64
112 + self.alpha = nn.Linear(hidden, heads * (nope + rope)) # q_proj
113 + self.beta = nn.Linear(hidden, kv_lora + rope) # kv_a_proj_with_mqa
114 + self.gamma = _Norm(kv_lora) # kv_a_layernorm
115 + self.delta = nn.Linear(kv_lora, heads * (nope + v)) # kv_b_proj
116 + self.omega = nn.Linear(heads * v, hidden) # o_proj (uncalled)
117 + self.kv_lora, self.rope = kv_lora, rope
118 +
119 + def forward(self, hidden_states):
120 + q = self.alpha(hidden_states)
121 + kv_lora, k_pe = torch.split(
122 + self.beta(hidden_states), [self.kv_lora, self.rope], dim=-1
123 + )
124 + expanded = self.delta(self.gamma(kv_lora))
125 + # Stand-in for the attention interface; `match` finds `o_proj` (omega) from
126 + # the source as the Linear producing the returned value.
127 + attn_output = expanded.sum() + q.sum() + k_pe.sum()
128 + attn_output = self.omega(attn_output)
129 + return attn_output
130 +
131 +
132 +def test_discovers_modules_under_arbitrary_names():
133 + """Discovery is purely structural: `RenamedMLA` gives its children non-standard
134 + names, and `match` still locates each projection by dataflow."""
135 + with torch.device("meta"):
136 + module = RenamedMLA()
137 + fuser = MLAFuser.match(trace(module), module)
138 + assert isinstance(fuser, MLAFuser)
139 + assert not fuser.has_q_lora
140 + assert fuser.q_proj_name == "alpha"
141 + assert fuser.kv_a_proj_name == "beta"
142 + assert fuser.kv_a_layernorm_name == "gamma"
143 + assert fuser.kv_b_proj_name == "delta"
144 + assert fuser.o_proj_name == "omega"
145 +
146 +
147 +class GLU(nn.Module):
148 + """A gated MLP: matches the GLU fuser, never MLA."""
149 +
150 + def __init__(self):
151 + super().__init__()
152 + self.gate = nn.Linear(16, 16)
153 + self.up = nn.Linear(16, 16)
154 +
155 + def forward(self, x):
156 + return self.up(F.silu(self.gate(x)))
157 +
158 +
159 +def test_non_mla_is_not_matched():
160 + with torch.device("meta"):
161 + assert not isinstance(get_fuser(GLU()), MLAFuser)
tests/models/transformers/test_backend.py
+42 -11
@@ -35,6 +35,12 @@ def get_num_fused(model) -> tuple[int, int]:
35 return glu, qkv
36
37
38 +def count_mla_layers(model) -> int:
39 + from vllm.model_executor.layers.attention import MLAAttention
40 +
41 + return sum(isinstance(m, MLAAttention) for m in model.attention_instances.values())
42 +
43 +
44 def check_implementation(
45 runner_ref: type[HfRunner | VllmRunner],
46 runner_test: type[VllmRunner],
@@ -97,17 +103,6 @@ def test_models(
103 model_impl: str,
104 num_fused: tuple[int, int],
105 ) -> None:
100 - import transformers
101 - from packaging.version import Version
102 -
103 - installed = Version(transformers.__version__)
104 - required = Version("5.0.0")
105 - if model == "allenai/OLMoE-1B-7B-0924" and installed < required:
106 - pytest.skip(
107 - "MoE models with the Transformers modeling backend require "
108 - f"transformers>={required}, but got {installed}"
109 - )
110 -
106 check_implementation(
107 hf_runner,
108 vllm_runner,
@@ -132,6 +127,42 @@ def test_hybrid_attention(vllm_runner: type[VllmRunner]) -> None:
127 )
128
129
130 +def test_mla(vllm_runner: type[VllmRunner], example_prompts: list[str]) -> None:
131 + import transformers
132 + from packaging.version import Version
133 +
134 + installed = Version(transformers.__version__)
135 + required = Version("5.15.0.dev0")
136 + if installed < required:
137 + pytest.skip(
138 + "MLA models with the Transformers modeling backend require "
139 + f"transformers>={required}, but got {installed}"
140 + )
141 +
142 + model = get_model("DeepseekV2ForCausalLM") # DeepSeek-V2-Lite, MLA + MoE
143 + args = (example_prompts, 32, 5)
144 + kwargs: dict[str, Any] = {"max_model_len": 2048, "enforce_eager": True}
145 +
146 + with vllm_runner(
147 + model, model_impl="transformers", trust_remote_code=False, **kwargs
148 + ) as model_test:
149 + model_config = model_test.llm.llm_engine.model_config
150 + assert model_config.using_transformers_backend()
151 + num_layers = model_config.hf_config.get_text_config().num_hidden_layers
152 + assert model_test.apply_model(count_mla_layers) == [num_layers]
153 + outputs_test = model_test.generate_greedy_logprobs(*args)
154 +
155 + with vllm_runner(model, model_impl="auto") as model_ref:
156 + outputs_ref = model_ref.generate_greedy_logprobs(*args)
157 +
158 + check_logprobs_close(
159 + outputs_0_lst=outputs_ref,
160 + outputs_1_lst=outputs_test,
161 + name_0="native",
162 + name_1="transformers",
163 + )
164 +
165 +
166 @multi_gpu_test(num_gpus=2)
167 def test_distributed(
168 hf_runner: type[HfRunner],
vllm/config/model.py
+7 -1
@@ -1789,7 +1789,13 @@ class ModelConfig:
1789
1790 @property
1791 def use_mla(self) -> bool:
1792 - return self.is_deepseek_mla and not envs.VLLM_MLA_DISABLE
1792 + if envs.VLLM_MLA_DISABLE:
1793 + return False
1794 + if self.using_transformers_backend():
1795 + # kv_lora_rank indicates that a Transformers model implementation uses MLA
1796 + return getattr(self.hf_text_config, "kv_lora_rank", None) is not None
1797 + # Manually maintained list of model types for vLLM model implementations
1798 + return self.is_deepseek_mla
1799
1800 @property
1801 def is_matryoshka(self) -> bool:
vllm/model_executor/models/transformers/__init__.py
+34 -3
@@ -40,7 +40,7 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
40 if TYPE_CHECKING:
41 import torch
42
43 - from vllm.model_executor.layers.attention import Attention
43 + from vllm.model_executor.layers.attention import Attention, MLAAttention
44
45
46 def vllm_attention_forward(
@@ -53,7 +53,7 @@ def vllm_attention_forward(
53 # Transformers kwargs
54 scaling: float | None = None,
55 # vLLM kwargs
56 - attention_instances: dict[int, "Attention"] | None = None,
56 + attention_instances: "dict[int, Attention] | None" = None,
57 **kwargs,
58 ):
59 self_attn = attention_instances[module.layer_idx]
@@ -78,7 +78,38 @@ def vllm_attention_forward(
78 return attn_output, None
79
80
81 -ALL_ATTENTION_FUNCTIONS["vllm"] = vllm_attention_forward
81 +def vllm_mla_attention_forward(
82 + # Transformers args
83 + module: "torch.nn.Module",
84 + query: "torch.Tensor",
85 + kv_c_normed: "torch.Tensor",
86 + k_pe: "torch.Tensor",
87 + attention_mask: "torch.Tensor",
88 + # Transformers kwargs
89 + scaling: float | None = None,
90 + # vLLM kwargs
91 + attention_instances: "dict[int, MLAAttention] | None" = None,
92 + **kwargs,
93 +):
94 + self_attn = attention_instances[module.layer_idx]
95 + # [batch=1, heads, num_tokens, qk_head_dim] -> [num_tokens, heads, qk_head_dim]
96 + query = query.transpose(1, 2).flatten(0, 1)
97 + num_tokens, num_heads = query.shape[:2]
98 + # [batch=1, num_tokens, kv_lora_rank] -> [num_tokens, kv_lora_rank]
99 + kv_c_normed = kv_c_normed.reshape(-1, kv_c_normed.shape[-1])
100 + # [batch=1, heads=1, num_tokens, qk_rope] -> [num_tokens, 1, qk_rope]
101 + k_pe = k_pe.reshape(-1, 1, k_pe.shape[-1])
102 + attn_output = self_attn.forward(
103 + query,
104 + kv_c_normed,
105 + k_pe,
106 + output_shape=(num_tokens, num_heads * self_attn.v_head_dim),
107 + )
108 + return attn_output, None
109 +
110 +
111 +ALL_ATTENTION_FUNCTIONS.register("vllm", vllm_attention_forward)
112 +ALL_ATTENTION_FUNCTIONS.register("vllm_mla", vllm_mla_attention_forward)
113
114
115 # Text only models
vllm/model_executor/models/transformers/base.py
+104 -33
@@ -16,6 +16,7 @@
16 # limitations under the License.
17 """Transformers modeling backend base class."""
18
19 +import os
20 from collections.abc import Callable, Iterable
21 from itertools import chain
22 from operator import attrgetter
@@ -40,7 +41,10 @@ from vllm.logger import init_logger
41 from vllm.model_executor.layers.attention import (
42 Attention,
43 EncoderOnlyAttention,
44 + MLAAttention,
45 )
46 +from vllm.model_executor.layers.attention.mla_attention import get_mla_dims
47 +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
48 from vllm.model_executor.layers.fused_moe import MoERunner
49 from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
50 from vllm.model_executor.models.interfaces import (
@@ -52,11 +56,13 @@ from vllm.model_executor.models.interfaces import (
56 )
57 from vllm.model_executor.models.interfaces_base import VllmModel
58 from vllm.model_executor.models.transformers.fuser import BaseFuser, Fusers
59 +from vllm.model_executor.models.transformers.fusers import MLAFuser
60 from vllm.model_executor.models.transformers.utils import (
61 can_enable_torch_compile,
62 get_feature_request_tip,
63 init_on_device_without_buffers,
64 log_replacement,
65 + named_state,
66 replace_conv_class,
67 replace_linear_class,
68 )
@@ -64,11 +70,11 @@ from vllm.model_executor.models.utils import (
70 AutoWeightsLoader,
71 PPMissingLayer,
72 WeightsMapper,
73 + extract_layer_index,
74 make_empty_intermediate_tensors_factory,
75 maybe_prefix,
76 )
77 from vllm.sequence import IntermediateTensors
71 -from vllm.v1.attention.backend import AttentionType
78
79 if TYPE_CHECKING:
80 from transformers import PreTrainedModel
@@ -129,6 +135,9 @@ class Base(
135 self.packed_modules_mapping: dict[str, list[str]] = {}
136 """Fused module -> constituent projections, populated by `recursive_replace`
137 for the quantization machinery and loaders (e.g. bitsandbytes)."""
138 + self.fusers: dict[str, BaseFuser] = {}
139 + """Module qualname -> the fuser applied to it, populated
140 + by `recursive_replace` for `create_attention_instances`."""
141
142 # Attrs for Eagle3 (see self.set_aux_hidden_state_layers)
143 self._target_class: type[nn.Module] = nn.Module
@@ -192,6 +201,9 @@ class Base(
201 # Initialize any parameters that have not had their modules replaced
202 self.init_parameters(self.model)
203
204 + # Upcast weights Transformers always keeps in fp32
205 + self.keep_in_fp32(self.model)
206 +
207 # Pipeline parallel intermediate tensors
208 self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
209 ["hidden_states"], self.text_config.hidden_size
@@ -457,6 +469,8 @@ class Base(
469
470 def register_fusion(fuser: BaseFuser, prefix: str):
471 """Register a fused layer's mappings just before it is built."""
472 + self.fusers[prefix] = fuser
473 +
474 orig_to_new_stacked = fuser.orig_to_new_stacked(prefix)
475 self.hf_to_vllm_mapper.orig_to_new_stacked.update(orig_to_new_stacked)
476
@@ -522,60 +536,105 @@ class Base(
536 """
537 Create `Attention` instances to inform KV cache allocation.
538 """
539 + mla_fusers = {}
540 + attention_instances = {}
541 text_config = self.text_config
542 + attn_cls = self._get_attn_cls()
543 +
544 + # kv_lora_rank indicates that this is an MLA model
545 + if getattr(text_config, "kv_lora_rank", None) is not None:
546 + mla_fusers = {
547 + extract_layer_index(prefix): (prefix, fuser)
548 + for prefix, fuser in self.fusers.items()
549 + if isinstance(fuser, MLAFuser)
550 + }
551 + if attn_cls is MLAAttention:
552 + text_config._attn_implementation = "vllm_mla"
553 + else:
554 + # MLA model not using MLAAttention: recompute head_size for full attn
555 + qk_nope_head_dim = getattr(text_config, "qk_nope_head_dim", 0)
556 + qk_rope_head_dim = getattr(text_config, "qk_rope_head_dim", 0)
557 + if qk_head_dim := qk_nope_head_dim + qk_rope_head_dim:
558 + self.model_config.model_arch_config.head_size = qk_head_dim
559
560 num_heads = self.model_config.get_num_attention_heads(self.parallel_config)
561 head_size = self.model_config.get_head_size()
562 num_kv_heads = self.model_config.get_num_kv_heads(self.parallel_config)
563 logits_soft_cap = getattr(text_config, "attn_logit_softcapping", None)
564
532 - # In encoder models, the attention layers will have `is_causal=False`
533 - is_encoder = lambda module: not getattr(module, "is_causal", True)
534 - has_encoder = lambda model: any(is_encoder(m) for m in model.modules())
535 - is_multimodal = lambda config: config != config.get_text_config()
536 - # vLLM does not support encoder-decoder models, so if any encoder layer is
537 - # found in a text only model, we assume the whole model is an encoder model
538 - if has_encoder(self.model) and not is_multimodal(self.config):
539 - self.check_version("5.0.0", "encoder models support")
540 - attn_type = AttentionType.ENCODER_ONLY
541 - else:
542 - attn_type = AttentionType.DECODER
543 -
565 pp_rank = self.pp_group.rank_in_group
566 pp_size = self.pp_group.world_size
567 start, end = get_pp_indices(text_config.num_hidden_layers, pp_rank, pp_size)
568
548 - attention_instances = {}
569 for i in range(start, end):
550 - # Handle interleaved sliding window attention
551 - per_layer_sliding_window = None
552 - if (
553 - hasattr(self.config, "layer_types")
554 - and self.config.layer_types[i] == "sliding_attention"
555 - ):
556 - per_layer_sliding_window = self.config.sliding_window
557 -
558 - attn_cls = (
559 - EncoderOnlyAttention
560 - if attn_type == AttentionType.ENCODER_ONLY
561 - else Attention
562 - )
563 - attention_instances[i] = attn_cls(
570 + kwargs = dict(
571 num_heads=num_heads,
565 - head_size=head_size,
572 # NOTE: We use Llama scale as default, if it's set by
573 # Transformers, it's updated in vllm_attention_forward
574 scale=head_size**-0.5,
569 - num_kv_heads=num_kv_heads,
575 cache_config=self.cache_config,
576 quant_config=self.quant_config,
572 - logits_soft_cap=logits_soft_cap,
573 - per_layer_sliding_window=per_layer_sliding_window,
577 prefix=f"{i}.attn",
575 - attn_type=attn_type,
578 )
579 +
580 + if attn_cls is MLAAttention:
581 + prefix, fuser = mla_fusers[i]
582 + mla_module = self.get_submodule(prefix)
583 + dims = get_mla_dims(self.model_config)
584 + kwargs.update(
585 + scale=mla_module.scaling,
586 + qk_nope_head_dim=dims.qk_nope_head_dim,
587 + qk_rope_head_dim=dims.qk_rope_head_dim,
588 + v_head_dim=dims.v_head_dim,
589 + q_lora_rank=dims.q_lora_rank,
590 + kv_lora_rank=dims.kv_lora_rank,
591 + kv_b_proj=mla_module.get_submodule(fuser.kv_b_proj_name),
592 + )
593 + else:
594 + kwargs.update(
595 + head_size=head_size,
596 + num_kv_heads=num_kv_heads,
597 + logits_soft_cap=logits_soft_cap,
598 + )
599 +
600 + # Handle interleaved sliding window attention
601 + if (
602 + hasattr(text_config, "layer_types")
603 + and text_config.layer_types[i] == "sliding_attention"
604 + ):
605 + kwargs["per_layer_sliding_window"] = text_config.sliding_window
606 +
607 + attn_instance = attn_cls(**kwargs)
608 + if attn_cls is MLAAttention:
609 + # Attach MLA attn_instance to mla_module so it appears in
610 + # model.named_modules() and runs its process_weights_after_loading
611 + mla_module._vllm_mla_attn = attn_instance
612 + attention_instances[i] = attn_instance
613 return attention_instances
614
615 + def _get_attn_cls(self) -> type[AttentionLayerBase]:
616 + """Return the `Attention` class to use for this model's layers."""
617 + # In encoder models, the attention layers will have `is_causal=False`
618 + is_encoder = lambda module: not getattr(module, "is_causal", True)
619 + has_encoder = lambda model: any(is_encoder(m) for m in model.modules())
620 + is_multimodal = lambda config: config != config.get_text_config()
621 + # vLLM does not support encoder-decoder models, so if any encoder layer is
622 + # found in a text only model, we assume the whole model is an encoder model
623 + if has_encoder(self.model) and not is_multimodal(self.config):
624 + self.check_version("5.0.0", "encoder models support")
625 + return EncoderOnlyAttention
626 + if self.model_config.use_mla:
627 + self.check_version("5.15.0.dev0", "optimized MLA support")
628 + if any(isinstance(fuser, MLAFuser) for fuser in self.fusers.values()):
629 + return MLAAttention
630 + logger.warning_once(
631 + "This model uses MLA but `MLAFuser` failed to match and/or fuse any "
632 + "MLA attention layers. Falling back to full attention with a padded "
633 + "`value` head dimension."
634 + )
635 + os.environ["VLLM_MLA_DISABLE"] = "1"
636 + return Attention
637 +
638 def init_parameters(self, module: nn.Module, dtype: torch.dtype | None = None):
639 """
640 If a `parameter` is on the `meta` device, then its parent
@@ -604,6 +663,18 @@ class Base(
663
664 _init_parameters(module)
665
666 + def keep_in_fp32(self, module: nn.Module):
667 + """Honor `_keep_in_fp32_modules_strict` as `from_pretrained` would."""
668 + if self.model_config.dtype not in (torch.float16, torch.bfloat16):
669 + return
670 + fragments = getattr(module, "_keep_in_fp32_modules_strict", None)
671 + if not fragments:
672 + return
673 + pattern = re.compile("|".join(re.escape(f) for f in fragments))
674 + for name, tensor in named_state(module):
675 + if not hasattr(tensor, "weight_loader") and pattern.search(name):
676 + tensor.data = tensor.data.to(torch.float32)
677 +
678 def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
679 return self.model.get_input_embeddings()(input_ids)
680
vllm/model_executor/models/transformers/fuser.py
+5 -4
@@ -18,6 +18,7 @@ from vllm.logger import init_logger
18 from vllm.model_executor.models.transformers.fusers import (
19 BaseFuser,
20 GLUFuser,
21 + MLAFuser,
22 PackedQKVFuser,
23 QKVFuser,
24 RewriteFuser,
@@ -48,7 +49,7 @@ def get_fuser(module: nn.Module) -> BaseFuser | None:
49 return None
50 if (graph := trace(module)) is None:
51 return None
51 - for fuser_cls in (GLUFuser, QKVFuser, PackedQKVFuser, RMSNormFuser):
52 + for fuser_cls in (MLAFuser, GLUFuser, QKVFuser, PackedQKVFuser, RMSNormFuser):
53 if (fuser := fuser_cls.match(graph, module)) is not None:
54 if isinstance(fuser, RewriteFuser):
55 try:
@@ -74,14 +75,14 @@ def get_fuser(module: nn.Module) -> BaseFuser | None:
75
76
77 class Fusers(UserDict):
77 - """Mapping from module class to fuser, for all fusable classes in a model."""
78 + """Mapping from module class and shape to fuser, for all fusable modules."""
79
80 def __init__(self, model: nn.Module, vllm_config: "VllmConfig"):
81 self.vllm_config = vllm_config
81 - super().__init__({type(m): get_fuser(m) for m in model.modules()})
82 + super().__init__({key(m): get_fuser(m) for m in model.modules()})
83
84 def __getitem__(self, m: nn.Module) -> BaseFuser | None:
84 - fuser = self.data.get(type(m))
85 + fuser = self.data.get(key(m))
86 if fuser is not None and fuser.validate(m, self.vllm_config):
87 return fuser
88 return None
vllm/model_executor/models/transformers/fusers/__init__.py
+2
@@ -8,6 +8,7 @@ from vllm.model_executor.models.transformers.fusers.base import (
8 StackedFuser,
9 )
10 from vllm.model_executor.models.transformers.fusers.glu import GLUFuser
11 +from vllm.model_executor.models.transformers.fusers.mla import MLAFuser
12 from vllm.model_executor.models.transformers.fusers.moe import MoEBlockFuser
13 from vllm.model_executor.models.transformers.fusers.packed_qkv import PackedQKVFuser
14 from vllm.model_executor.models.transformers.fusers.qkv import QKVFuser
@@ -18,6 +19,7 @@ __all__ = [
19 "RewriteFuser",
20 "StackedFuser",
21 "GLUFuser",
22 + "MLAFuser",
23 "MoEBlockFuser",
24 "PackedQKVFuser",
25 "QKVFuser",
vllm/model_executor/models/transformers/fusers/mla.py new
+325
@@ -0,0 +1,325 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""MLA fuser: adapt a Transformers MLA attention module for vLLM's MLA layer."""
4 +
5 +import ast
6 +import inspect
7 +import textwrap
8 +from dataclasses import dataclass
9 +from typing import TYPE_CHECKING, ClassVar
10 +
11 +from torch import fx, nn
12 +
13 +from vllm.logger import init_logger
14 +from vllm.model_executor.layers.linear import MergedColumnParallelLinear
15 +from vllm.model_executor.models.transformers.fusers.base import StackedFuser
16 +from vllm.model_executor.models.transformers.fusers.rms_norm import RMSNormFuser
17 +from vllm.model_executor.models.transformers.fx_utils import (
18 + compile_forward,
19 + downstream_linear,
20 + is_linear,
21 + recover_forward,
22 + replace_expr,
23 + returned_linear,
24 + single_self_call,
25 + trace,
26 + upstream_linear,
27 +)
28 +from vllm.model_executor.models.transformers.utils import (
29 + log_replacement,
30 + replace_linear_class,
31 +)
32 +from vllm.model_executor.models.utils import ShardId, maybe_prefix
33 +
34 +if TYPE_CHECKING:
35 + from vllm.config import VllmConfig
36 +
37 +logger = init_logger(__name__)
38 +
39 +# Temporaries the fused down-projection binds in the rewritten forward.
40 +_Q_A_TEMP = "__q_a_fused"
41 +_KV_A_TEMP = "__kv_a_fused"
42 +
43 +
44 +def _consumes_placeholder(node: fx.Node) -> bool:
45 + """Whether `node` is a linear applied directly to a `forward` input."""
46 + return (
47 + len(node.args) == 1
48 + and isinstance(node.args[0], fx.Node)
49 + and node.args[0].op == "placeholder"
50 + )
51 +
52 +
53 +def _norm_size(norm: nn.Module) -> int:
54 + weight = getattr(norm, "weight", None)
55 + return weight.numel() if weight is not None else -1
56 +
57 +
58 +def _is_rms_norm(module: nn.Module) -> bool:
59 + """Whether `module` computes an RMSNorm, verified by `RMSNormFuser`'s matcher."""
60 + graph = trace(module)
61 + return graph is not None and RMSNormFuser.match(graph, module) is not None
62 +
63 +
64 +def _top_level_index(funcdef: ast.FunctionDef, node: ast.AST) -> int:
65 + """Index in `funcdef.body` of the top-level statement containing `node`."""
66 + for index, stmt in enumerate(funcdef.body):
67 + if any(child is node for child in ast.walk(stmt)):
68 + return index
69 + raise ValueError("node is not in the function body")
70 +
71 +
72 +def _single_expand_call(
73 + funcdef: ast.FunctionDef, module: nn.Module, kv_b_proj_name: str
74 +) -> ast.Call:
75 + """The KV expansion call `self.<method>(kv_c_normed, k_pe)`: the unique
76 + two-argument call to a method of `module` with the expansion's signature.
77 +
78 + The expansion is `kv_c_normed, k_pe -> key, value`: two inputs, every
79 + `return` a pair, and it applies `kv_b_proj` -- the projection `MLAAttention`
80 + owns after the bypass, absorbed into the attention computation.
81 + """
82 +
83 + def is_expansion_method(name: str) -> bool:
84 + method = getattr(type(module), name, None)
85 + if method is None:
86 + return False
87 + code = getattr(inspect.unwrap(method), "__code__", None)
88 + if (
89 + code is None
90 + or code.co_argcount != 3 # self, kv_c_normed, k_pe
91 + or kv_b_proj_name not in code.co_names
92 + ):
93 + return False
94 + try:
95 + tree = ast.parse(textwrap.dedent(inspect.getsource(method)))
96 + except (OSError, TypeError):
97 + return False
98 + returns = [node for node in ast.walk(tree) if isinstance(node, ast.Return)]
99 + return bool(returns) and all(
100 + isinstance(ret.value, ast.Tuple) and len(ret.value.elts) == 2
101 + for ret in returns
102 + )
103 +
104 + calls = [
105 + node
106 + for node in ast.walk(funcdef)
107 + if isinstance(node, ast.Call)
108 + and isinstance(node.func, ast.Attribute)
109 + and isinstance(node.func.value, ast.Name)
110 + and node.func.value.id == "self"
111 + and len(node.args) == 2
112 + and not node.keywords
113 + and is_expansion_method(node.func.attr)
114 + ]
115 + if len(calls) != 1:
116 + raise ValueError("expected exactly one KV expansion method call")
117 + return calls[0]
118 +
119 +
120 +@dataclass
121 +class MLAFuser(StackedFuser):
122 + """Fuser for the MLA attention pattern."""
123 +
124 + q_proj_name: str | None
125 + q_a_proj_name: str | None
126 + q_a_layernorm_name: str | None
127 + q_b_proj_name: str | None
128 + kv_a_proj_name: str
129 + kv_a_layernorm_name: str
130 + kv_b_proj_name: str
131 + o_proj_name: str | None
132 + merged_name: ClassVar[str] = "fused_qkv_a_proj"
133 + merged_cls: ClassVar[str] = "MergedColumnParallelLinear"
134 +
135 + @property
136 + def has_q_lora(self) -> bool:
137 + return self.q_a_proj_name is not None
138 +
139 + def info(self, name: str) -> str:
140 + info_str = (
141 + f"Fused: {name} ({self.source_cls}) -> MLAAttention (attention interface)"
142 + )
143 + if self.has_q_lora:
144 + info_str += "; " + super().info(name).removeprefix("Fused: ")
145 + return info_str
146 +
147 + @property
148 + def shards(self) -> list[tuple[str, ShardId]]:
149 + """`q_a_proj` and `kv_a_proj_with_mqa` stack into one down-projection."""
150 + if self.has_q_lora:
151 + return [(self.q_a_proj_name, 0), (self.kv_a_proj_name, 1)]
152 + return []
153 +
154 + @property
155 + def packed_modules_mapping(self) -> dict[str, list[str]]:
156 + if self.has_q_lora:
157 + return super().packed_modules_mapping
158 + return {}
159 +
160 + @classmethod
161 + def match(cls, graph: fx.Graph, module: nn.Module) -> "MLAFuser | None":
162 + # Find all `rms_norm(linear(placeholder))` chains.
163 + chains = []
164 + for node in graph.nodes:
165 + if node.op != "call_module" or is_linear(node, module) or not node.args:
166 + continue
167 + source = upstream_linear(node.args[0], module)
168 + if source is None or not _consumes_placeholder(source):
169 + continue
170 + if not _is_rms_norm(module.get_submodule(node.target)):
171 + continue
172 + chains.append((source, node))
173 +
174 + # Tell the chains apart by width.
175 + def is_kv_a(chain) -> bool:
176 + source, rms_norm = chain
177 + source_mod = module.get_submodule(source.target)
178 + rms_norm_mod = module.get_submodule(rms_norm.target)
179 + return source_mod.out_features != _norm_size(rms_norm_mod)
180 +
181 + kv_a_chains = [chain for chain in chains if is_kv_a(chain)]
182 + q_a_chains = [chain for chain in chains if not is_kv_a(chain)]
183 + # Exactly one KV chain is MLA's signature. The query chain is optional.
184 + if len(kv_a_chains) != 1 or len(q_a_chains) > 1:
185 + return None
186 + kv_a_proj, kv_a_layernorm = kv_a_chains[0]
187 +
188 + # Linear children claimed for a role so far; the rest resolve by elimination.
189 + claimed_linears = {kv_a_proj.target}
190 + q_proj_name = q_a_proj = q_a_layernorm = q_b_proj = None
191 + if q_a_chains:
192 + # Find `q_b_proj(q_a_layernorm(q_a_proj(placeholder)))`.
193 + q_a_proj, q_a_layernorm = q_a_chains[0]
194 + q_b_proj = downstream_linear(q_a_layernorm, module)
195 + if q_b_proj is None:
196 + return None
197 + claimed_linears |= {q_a_proj.target, q_b_proj.target}
198 + else:
199 + # Find `q_proj(placeholder)`.
200 + placeholder_linears = {
201 + node.target
202 + for node in graph.nodes
203 + if is_linear(node, module) and _consumes_placeholder(node)
204 + }
205 + if len(q_proj_candidates := placeholder_linears - claimed_linears) != 1:
206 + return None
207 + q_proj_name = next(iter(q_proj_candidates))
208 + claimed_linears.add(q_proj_name)
209 +
210 + # Find `kv_b_proj(kv_a_layernorm(...))`.
211 + kv_b_proj = downstream_linear(kv_a_layernorm, module)
212 + if kv_b_proj is None or kv_b_proj.target in claimed_linears:
213 + return None
214 + claimed_linears.add(kv_b_proj.target)
215 +
216 + # Find `o_proj` if it is returned by the forward graph.
217 + o_proj_name = returned_linear(graph, module)
218 + if o_proj_name in claimed_linears:
219 + o_proj_name = None
220 +
221 + return cls(
222 + source_cls=type(module).__name__,
223 + q_proj_name=q_proj_name,
224 + q_a_proj_name=q_a_proj.target if q_a_proj else None,
225 + q_a_layernorm_name=q_a_layernorm.target if q_a_layernorm else None,
226 + q_b_proj_name=q_b_proj.target if q_b_proj else None,
227 + kv_a_proj_name=kv_a_proj.target,
228 + kv_a_layernorm_name=kv_a_layernorm.target,
229 + kv_b_proj_name=kv_b_proj.target,
230 + o_proj_name=o_proj_name,
231 + )
232 +
233 + def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
234 + return vllm_config.model_config.use_mla
235 +
236 + def update_forward(self, module: nn.Module) -> None:
237 + """Merge `q_a_proj` and `kv_a_proj` into one fused down proj then split.
238 + Bypass the KV expansion method so the compressed latent reaches the `vllm_mla`
239 + attention interface unexpanded."""
240 + funcdef, fn = recover_forward(type(module))
241 + if self.has_q_lora:
242 + # q_a_proj is usually inside the `else` of `if self.q_lora_rank is None`.
243 + # The fused call is inserted at the top-level statement preceding both.
244 + q_call = single_self_call(funcdef, self.q_a_proj_name)
245 + kv_call = single_self_call(funcdef, self.kv_a_proj_name)
246 + if ast.dump(q_call.args[0]) != ast.dump(kv_call.args[0]):
247 + raise ValueError("down-projections read different inputs")
248 + names = {n.id for n in ast.walk(funcdef) if isinstance(n, ast.Name)}
249 + if names & {_Q_A_TEMP, _KV_A_TEMP}:
250 + raise ValueError("fused temporaries would shadow existing names")
251 +
252 + merged = f"self.{self.merged_name}"
253 + targets = f"{_Q_A_TEMP}, {_KV_A_TEMP}"
254 + sections = f"[s // {merged}.tp_size for s in {merged}.output_sizes]"
255 + source = f"{targets} = {merged}(__arg__).split({sections}, -1)"
256 + assign = ast.parse(source=source).body[0]
257 + placeholder = next(
258 + node
259 + for node in ast.walk(assign)
260 + if isinstance(node, ast.Name) and node.id == "__arg__"
261 + )
262 + replace_expr(assign, placeholder, q_call.args[0])
263 +
264 + index = min(_top_level_index(funcdef, call) for call in (q_call, kv_call))
265 + ast.copy_location(assign, funcdef.body[index])
266 + funcdef.body.insert(index, assign)
267 + replace_expr(funcdef, q_call, ast.Name(id=_Q_A_TEMP, ctx=ast.Load()))
268 + replace_expr(funcdef, kv_call, ast.Name(id=_KV_A_TEMP, ctx=ast.Load()))
269 +
270 + # Transformers expands the latent into full key/value in a dedicated method.
271 + # `MLAAttention` consumes the latent directly (absorbing `kv_b_proj`),
272 + # so replace the expansion call with its own arguments so `kv_c_normed, k_pe`
273 + # flow to the interface in place of `key, value`.
274 + expand_call = _single_expand_call(funcdef, module, self.kv_b_proj_name)
275 + replace_expr(
276 + funcdef, expand_call, ast.Tuple(elts=list(expand_call.args), ctx=ast.Load())
277 + )
278 + self.fused_forward = compile_forward(funcdef, fn)
279 +
280 + def update_attrs(self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"):
281 + quant_config = vllm_config.quant_config
282 +
283 + def replace_linear_by_name(name: str, style: str):
284 + linear = module.get_submodule(name)
285 + replacement = replace_linear_class(
286 + linear, style, quant_config, prefix=maybe_prefix(prefix, name)
287 + )
288 + setattr(module, name, replacement)
289 + log_replacement(maybe_prefix(prefix, name), linear, replacement)
290 +
291 + if self.has_q_lora:
292 + q_a = module.get_submodule(self.q_a_proj_name)
293 + kv_a = module.get_submodule(self.kv_a_proj_name)
294 + merged = MergedColumnParallelLinear(
295 + input_size=q_a.in_features,
296 + output_sizes=[q_a.out_features, kv_a.out_features],
297 + bias=q_a.bias is not None,
298 + quant_config=quant_config,
299 + prefix=maybe_prefix(prefix, self.merged_name),
300 + return_bias=False,
301 + disable_tp=True,
302 + )
303 + logger.debug(
304 + "%s: %s, %s: %s -> %s: %s",
305 + self.q_a_proj_name,
306 + q_a,
307 + self.kv_a_proj_name,
308 + kv_a,
309 + self.merged_name,
310 + merged,
311 + )
312 + setattr(module, self.merged_name, merged)
313 + # The rewritten forward calls the merged projection instead.
314 + delattr(module, self.q_a_proj_name)
315 + delattr(module, self.kv_a_proj_name)
316 + replace_linear_by_name(self.q_b_proj_name, "colwise")
317 + else:
318 + replace_linear_by_name(self.kv_a_proj_name, "replicate")
319 + replace_linear_by_name(self.q_proj_name, "colwise")
320 +
321 + replace_linear_by_name(self.kv_b_proj_name, "colwise")
322 + # MLAAttention calls kv_b_proj and expects vLLM's default return_bias=True
323 + module.get_submodule(self.kv_b_proj_name).return_bias = True
324 + if self.o_proj_name is not None:
325 + replace_linear_by_name(self.o_proj_name, "rowwise")
vllm/model_executor/models/transformers/fx_utils.py
+19
@@ -416,6 +416,25 @@ def upstream_linear(node: object, module: nn.Module) -> fx.Node | None:
416 return None
417
418
419 +def downstream_linear(node: fx.Node, module: nn.Module) -> fx.Node | None:
420 + """Nearest linear consuming `node`'s output, walking through casts/scalings.
421 +
422 + Never walks through a leaf call (e.g. an attention interface): what crosses
423 + it is consumed by the attention computation, not projected."""
424 + queue = list(node.users)
425 + seen: set[fx.Node] = set()
426 + while queue:
427 + current = queue.pop(0)
428 + if current in seen:
429 + continue
430 + seen.add(current)
431 + if is_linear(current, module):
432 + return current
433 + if current.op in ("call_function", "call_method") and not is_leaf_call(current):
434 + queue.extend(current.users)
435 + return None
436 +
437 +
438 def returned_linear(graph: fx.Graph, module: nn.Module) -> str | None:
439 """Name of the Linear producing the graph's (first) output value."""
440 value = output_value(graph)