main
py 181 lines 6.71 KB
Raw
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 _attention(q_lora_rank: int | None):
23 """A meta `DeepseekV2Attention` in the requested q_lora configuration."""
24 pytest.importorskip("transformers.models.deepseek_v2.modeling_deepseek_v2")
25 from transformers.models.deepseek_v2.configuration_deepseek_v2 import (
26 DeepseekV2Config,
27 )
28 from transformers.models.deepseek_v2.modeling_deepseek_v2 import (
29 DeepseekV2Attention,
30 )
31
32 cfg = DeepseekV2Config(
33 hidden_size=256,
34 num_attention_heads=16,
35 kv_lora_rank=128,
36 qk_rope_head_dim=32,
37 qk_nope_head_dim=32,
38 v_head_dim=32,
39 q_lora_rank=q_lora_rank,
40 num_hidden_layers=1,
41 )
42 with torch.device("meta"):
43 return DeepseekV2Attention(cfg, layer_idx=0)
44
45
46 def _match(q_lora_rank: int | None) -> MLAFuser | None:
47 """Match a meta `DeepseekV2Attention` directly (bypassing the per-class
48 `get_fuser` cache, so both q_lora variants of the same class are seen)."""
49 attn = _attention(q_lora_rank)
50 return MLAFuser.match(trace(attn), attn)
51
52
53 def test_discovers_modules_without_q_lora():
54 fuser = _match(q_lora_rank=None)
55 assert isinstance(fuser, MLAFuser)
56 assert fuser.q_proj_name == "q_proj"
57 assert fuser.kv_a_proj_name == "kv_a_proj_with_mqa"
58 assert fuser.kv_a_layernorm_name == "kv_a_layernorm"
59 assert fuser.kv_b_proj_name == "kv_b_proj"
60 assert fuser.o_proj_name == "o_proj"
61 assert fuser.q_a_proj_name is None
62 # Nothing is stacked without a query LoRA.
63 assert fuser.packed_modules_mapping == {}
64 assert fuser.orig_to_new_stacked("model.layers.0.self_attn") == {}
65
66
67 def test_discovers_modules_with_q_lora():
68 fuser = _match(q_lora_rank=64)
69 assert isinstance(fuser, MLAFuser)
70 assert fuser.q_a_proj_name == "q_a_proj"
71 assert fuser.q_a_layernorm_name == "q_a_layernorm"
72 assert fuser.q_b_proj_name == "q_b_proj"
73 assert fuser.kv_a_proj_name == "kv_a_proj_with_mqa"
74 assert fuser.kv_a_layernorm_name == "kv_a_layernorm"
75 assert fuser.kv_b_proj_name == "kv_b_proj"
76 assert fuser.o_proj_name == "o_proj"
77 assert fuser.q_proj_name is None
78
79
80 def test_q_lora_stacks_qkv_a_proj():
81 """The MLA layer reads `q_a_proj` and `kv_a_proj_with_mqa` fused into one
82 down-projection, so both checkpoint weights must remap into it."""
83 fuser = _match(q_lora_rank=64)
84 assert isinstance(fuser, MLAFuser)
85 prefix = "model.layers.0.self_attn"
86 merged = f"{prefix}.{_FUSED_QKV_A_PROJ}"
87 assert fuser.packed_modules_mapping == {
88 _FUSED_QKV_A_PROJ: ["q_a_proj", "kv_a_proj_with_mqa"]
89 }
90 assert fuser.orig_to_new_stacked(prefix) == {
91 f"{prefix}.q_a_proj": (merged, 0),
92 f"{prefix}.kv_a_proj_with_mqa": (merged, 1),
93 }
94
95
96 @pytest.mark.parametrize("q_lora_rank", [64, None])
97 def test_update_forward_rewrites_real_attention(q_lora_rank):
98 """The source rewrite must survive the input-stability check.
99
100 The q-LoRA path hoists the merged down-projection to a top-level statement
101 while its calls sit inside the `q_lora_rank` branches, so the region it
102 spans is wide; a check that over-approximates aliasing would silently refuse
103 here and cost the fusion rather than fail loudly."""
104 fuser = _match(q_lora_rank)
105 assert isinstance(fuser, MLAFuser)
106 fuser.update_forward(_attention(q_lora_rank))
107 names = set(fuser.fused_forward.__code__.co_names)
108 if fuser.q_a_proj_name is not None:
109 assert _FUSED_QKV_A_PROJ in names
110 assert not {"q_a_proj", "kv_a_proj_with_mqa"} & names
111
112
113 class _Norm(nn.Module):
114 """A real RMSNorm computation: `match` verifies chain norms via `RMSNormFuser`."""
115
116 def __init__(self, n: int):
117 super().__init__()
118 self.weight = nn.Parameter(torch.ones(n))
119
120 def forward(self, x):
121 variance = x.pow(2).mean(-1, keepdim=True)
122 return self.weight * (x * torch.rsqrt(variance + 1e-6))
123
124
125 class RenamedMLA(nn.Module):
126 """An MLA-shaped attention whose children have non-standard names, proving
127 discovery is by structure and not attribute name."""
128
129 def __init__(self):
130 super().__init__()
131 heads, kv_lora, rope, nope, v, hidden = 4, 32, 8, 8, 16, 64
132 self.alpha = nn.Linear(hidden, heads * (nope + rope)) # q_proj
133 self.beta = nn.Linear(hidden, kv_lora + rope) # kv_a_proj_with_mqa
134 self.gamma = _Norm(kv_lora) # kv_a_layernorm
135 self.delta = nn.Linear(kv_lora, heads * (nope + v)) # kv_b_proj
136 self.omega = nn.Linear(heads * v, hidden) # o_proj (uncalled)
137 self.kv_lora, self.rope = kv_lora, rope
138
139 def forward(self, hidden_states):
140 q = self.alpha(hidden_states)
141 kv_lora, k_pe = torch.split(
142 self.beta(hidden_states), [self.kv_lora, self.rope], dim=-1
143 )
144 expanded = self.delta(self.gamma(kv_lora))
145 # Stand-in for the attention interface; `match` finds `o_proj` (omega) from
146 # the source as the Linear producing the returned value.
147 attn_output = expanded.sum() + q.sum() + k_pe.sum()
148 attn_output = self.omega(attn_output)
149 return attn_output
150
151
152 def test_discovers_modules_under_arbitrary_names():
153 """Discovery is purely structural: `RenamedMLA` gives its children non-standard
154 names, and `match` still locates each projection by dataflow."""
155 with torch.device("meta"):
156 module = RenamedMLA()
157 fuser = MLAFuser.match(trace(module), module)
158 assert isinstance(fuser, MLAFuser)
159 assert fuser.q_a_proj_name is None
160 assert fuser.q_proj_name == "alpha"
161 assert fuser.kv_a_proj_name == "beta"
162 assert fuser.kv_a_layernorm_name == "gamma"
163 assert fuser.kv_b_proj_name == "delta"
164 assert fuser.o_proj_name == "omega"
165
166
167 class GLU(nn.Module):
168 """A gated MLP: matches the GLU fuser, never MLA."""
169
170 def __init__(self):
171 super().__init__()
172 self.gate = nn.Linear(16, 16)
173 self.up = nn.Linear(16, 16)
174
175 def forward(self, x):
176 return self.up(F.silu(self.gate(x)))
177
178
179 def test_non_mla_is_not_matched():
180 with torch.device("meta"):
181 assert get_fuser(GLU(), MLAFuser) is None