main
py 282 lines 10.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """AMD-specific MLA wrapper for Kimi-K3."""
4
5 from typing import cast
6
7 import torch
8
9 from vllm._aiter_ops import rocm_aiter_ops
10 from vllm.model_executor.layers.attention.attention import get_attention_context
11 from vllm.model_executor.layers.layernorm import RMSNorm
12 from vllm.model_executor.layers.mla import MultiHeadLatentAttentionWrapper
13 from vllm.platforms import current_platform
14
15 _OPT_KV_LORA_RANK = 512
16 _OPT_ROT_DIM = 64
17 _OPT_MIN_SIZE = 2048
18
19
20 class KimiK3MultiHeadLatentAttentionWrapper(MultiHeadLatentAttentionWrapper):
21 """Kimi-K3 MLA wrapper with eager AITER q/kv RMSNorm fusion and a fused
22 decode Q-prep path."""
23
24 def __init__(self, *args, **kwargs) -> None:
25 super().__init__(*args, **kwargs)
26 self._use_eager_qk_rmsnorm_fusion = bool(rocm_aiter_ops.is_enabled())
27 self._fused_qk_prep = self._fused_qk_prep_supported()
28 self._identity_rope: tuple[torch.Tensor, torch.Tensor] | None = None
29
30 def _fused_qk_prep_supported(self) -> bool:
31 attn = self.mla_attn
32 if not (
33 rocm_aiter_ops.is_mla_enabled()
34 and attn.kv_cache_dtype.startswith("fp8")
35 and self.kv_lora_rank == _OPT_KV_LORA_RANK
36 and self.qk_rope_head_dim == _OPT_ROT_DIM
37 and self.kv_lora_rank * self.num_heads >= _OPT_MIN_SIZE
38 ):
39 return False
40 # The fused kernel writes q_out only for slot >= 0, so padded rows would
41 # be garbage after a DCP query all-gather. PCP and HiSparse rewrite the
42 # KV inputs/slots before the standalone write, which this path bypasses.
43 if attn.impl.dcp_world_size > 1 or attn.use_pcp:
44 return False
45 if attn.hisparse_cache is not None or attn.q_pad_num_heads is not None:
46 return False
47 # Only the W_UK variants reproduced in _fused_decode are supported.
48 return attn.is_aiter_triton_fp4_bmm_enabled or (
49 not attn.is_aiter_triton_fp8_bmm_enabled and attn.W_UK_T is not None
50 )
51
52 def _normalize_q_kv(
53 self,
54 q_c: torch.Tensor,
55 kv_c: torch.Tensor,
56 ) -> tuple[torch.Tensor, torch.Tensor]:
57 q_layernorm = cast(RMSNorm, self.q_a_layernorm)
58 kv_layernorm = cast(RMSNorm, self.kv_a_layernorm)
59
60 if self._use_eager_qk_rmsnorm_fusion and not torch.compiler.is_compiling():
61 return torch.ops.vllm.fused_mla_dual_rms_norm(
62 q_c,
63 q_layernorm.weight,
64 kv_c,
65 kv_layernorm.weight,
66 q_layernorm.variance_epsilon,
67 kv_layernorm.variance_epsilon,
68 )
69
70 return q_layernorm(q_c), kv_layernorm(kv_c)
71
72 def _fused_decode(
73 self,
74 q: torch.Tensor,
75 kv_c_normed: torch.Tensor,
76 k_pe: torch.Tensor,
77 positions: torch.Tensor,
78 output_shape: tuple[int, int],
79 attn_metadata,
80 layer,
81 kv_cache: torch.Tensor,
82 slot_mapping: torch.Tensor,
83 ) -> torch.Tensor:
84 """Decode-only MLA through one fused AITER launch.
85
86 Replaces the fp8 KV-cache write plus the ``[ql_nope | q_pe]`` concat and
87 that query's static fp8 quant with a single
88 ``fused_qk_rope_concat_and_cache_mla``. On an untouched upstream build
89 the concat and quant already arrive as one inductor-fused kernel, so
90 this collapses two launches into one.
91 """
92 from aiter.ops.cache import fused_qk_rope_concat_and_cache_mla
93
94 attn = self.mla_attn
95 q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
96 # (B, N, P) -> (N, B, P)
97 q_nope_t = q_nope.transpose(0, 1)
98
99 # Mirror MLAAttention.forward_impl's W_UK dispatch, *including its
100 # precedence* -- fp4 is checked before fp8 there, and both flags can be
101 # set at once. ql_nope must come out bit-identical to what the unfused
102 # path feeds its concat+quant, because this only replaces what happens
103 # after the bmm. Both variants return bf16, which the kernel consumes.
104 if attn.is_aiter_triton_fp4_bmm_enabled:
105 from aiter.ops.triton.batched_gemm_a16wfp4 import batched_gemm_a16wfp4
106
107 ql_nope = batched_gemm_a16wfp4(
108 q_nope_t,
109 attn.W_K,
110 attn.W_K_scale,
111 transpose_bm=True,
112 prequant=True,
113 y_scale=layer._q_scale,
114 )
115 else:
116 assert attn.W_UK_T is not None # guaranteed by _fused_qk_prep_supported
117 B, N = q_nope.shape[0], q_nope.shape[1]
118 L = attn.W_UK_T.shape[-1]
119 ql_nope = q_nope_t.new_empty((B, N, L))
120 torch.bmm(q_nope_t, attn.W_UK_T, out=ql_nope.transpose(0, 1))
121
122 if self._identity_rope is None:
123 # cos = 1, sin = 0 makes the kernel's RoPE the identity, which is
124 # what a NoPE model needs. One row suffices because the `_opt`
125 # kernel clamps `pos` into [0, cos_cache.size(0)); a full-length
126 # cache would cost ~268 MB at K3's 1M max_position_embeddings.
127 half = self.qk_rope_head_dim // 2
128 opts = {"dtype": kv_c_normed.dtype, "device": kv_c_normed.device}
129 self._identity_rope = (
130 torch.ones(1, half, **opts),
131 torch.zeros(1, half, **opts),
132 )
133 cos_cache, sin_cache = self._identity_rope
134
135 # An fp8 KV cache is allocated as uint8 and re-viewed as fp8 before use;
136 # the AITER kernel rejects the raw uint8 dtype outright.
137 fp8_dtype = current_platform.fp8_dtype()
138 if kv_cache.dtype != fp8_dtype:
139 kv_cache = kv_cache.view(fp8_dtype)
140
141 head_size = attn.kv_lora_rank + self.qk_rope_head_dim
142 num_tokens, num_heads = ql_nope.shape[:2]
143 q_out = torch.empty(
144 (num_tokens, num_heads, head_size),
145 dtype=fp8_dtype,
146 device=ql_nope.device,
147 )
148 fused_qk_rope_concat_and_cache_mla(
149 # the W_UK bmm hands back a transposed view; the kernel reads dense
150 ql_nope.contiguous(),
151 q_pe,
152 kv_c_normed,
153 k_pe.squeeze(1),
154 kv_cache.view(kv_cache.shape[0], -1, head_size),
155 q_out,
156 slot_mapping.flatten(),
157 layer._k_scale,
158 layer._q_scale,
159 positions,
160 cos_cache,
161 sin_cache,
162 is_neox=True,
163 is_nope_first=True,
164 )
165
166 # forward_mqa is MLA-specific and not on AttentionImpl; MLAAttention
167 # annotates its own call to it the same way.
168 attn_out, _ = attn.impl.forward_mqa(q_out, kv_cache, attn_metadata, attn) # type: ignore[attr-defined]
169 output = q.new_empty(output_shape)
170 attn._v_up_proj(attn_out, out=output)
171 return output
172
173 def forward(
174 self,
175 positions: torch.Tensor,
176 hidden_states: torch.Tensor,
177 llama_4_scaling: torch.Tensor | None = None,
178 ) -> torch.Tensor:
179 q_c = None
180
181 if self.q_lora_rank is not None:
182 assert self.fused_qkv_a_proj is not None, (
183 "fused_qkv_a_proj is required when q_lora_rank is not None"
184 )
185 assert self.q_a_layernorm is not None, (
186 "q_a_layernorm is required when q_lora_rank is not None"
187 )
188 assert self.q_b_proj is not None, (
189 "q_b_proj is required when q_lora_rank is not None"
190 )
191
192 qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
193 q_c, kv_lora = qkv_lora.split(
194 [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim],
195 dim=-1,
196 )
197 kv_c, k_pe = kv_lora.split(
198 [self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
199 )
200 q_proj_input, kv_c_normed = self._normalize_q_kv(q_c, kv_c)
201 q_proj_layer = self.q_b_proj
202 else:
203 assert self.kv_a_proj_with_mqa is not None, (
204 "kv_a_proj_with_mqa is required when q_lora_rank is None"
205 )
206 assert self.q_proj is not None, (
207 "q_proj is required when q_lora_rank is None"
208 )
209 kv_lora = self.kv_a_proj_with_mqa(hidden_states)[0]
210 kv_c, k_pe = kv_lora.split(
211 [self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
212 )
213 kv_c_normed = self.kv_a_layernorm(kv_c)
214 q_proj_layer = self.q_proj
215 q_proj_input = hidden_states
216
217 # Add head dim of 1 to k_pe.
218 k_pe = k_pe.unsqueeze(1)
219
220 q = q_proj_layer(q_proj_input)[0]
221 heads = self.num_heads
222 if self.dcp_q_replicate:
223 heads *= q_proj_layer.group_size
224 q = q.view(-1, heads, self.qk_head_dim)
225
226 if self.rotary_emb is not None:
227 q[..., self.qk_nope_head_dim :], k_pe = self.rotary_emb(
228 positions, q[..., self.qk_nope_head_dim :], k_pe
229 )
230
231 if self.indexer and self.is_sparse and not self.skip_topk:
232 self.indexer(hidden_states, q_c, positions, self.indexer_rope_emb)
233
234 if llama_4_scaling is not None:
235 q *= llama_4_scaling
236
237 q_dcp_replicated = None
238 if self.dcp_q_replicate:
239 q_dcp_replicated, q = q, q_proj_layer._local_view(q)
240
241 output_shape = (hidden_states.shape[0], self.num_heads * self.v_head_dim)
242
243 # The fused decode path covers only the MQA slice, so it is taken for
244 # decode-only batches; anything else falls through to MLAAttention.
245 attn_metadata = layer = kv_cache = slot_mapping = None
246 fuse = self._fused_qk_prep and q_dcp_replicated is None
247 if fuse:
248 attn_metadata, layer, kv_cache, slot_mapping = get_attention_context(
249 self.mla_attn.layer_name
250 )
251 fuse = (
252 attn_metadata is not None
253 and slot_mapping is not None
254 and kv_cache.numel() > 0
255 and attn_metadata.num_actual_tokens == attn_metadata.num_decode_tokens
256 )
257
258 if fuse:
259 attn_out = self._fused_decode(
260 q,
261 kv_c_normed,
262 k_pe,
263 positions,
264 output_shape,
265 attn_metadata,
266 layer,
267 kv_cache,
268 slot_mapping,
269 )
270 else:
271 attn_out = self.mla_attn(
272 q,
273 kv_c_normed,
274 k_pe,
275 output_shape=output_shape,
276 q_dcp_replicated=q_dcp_replicated,
277 )
278
279 if self.g_proj is not None:
280 attn_out = attn_out * self.g_proj(hidden_states)[0].sigmoid()
281
282 return self.o_proj(attn_out)[0]