main
py 770 lines 30.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """MLA attention and lightning indexer for HY V4 (NVIDIA).
4
5 NOTE: The reference implementation also offers a fused MLA preprocessing
6 wrapper, which is not available here, so this port keeps the eager MLA path
7 with an FP8 indexer cache. TODO: restore the fused preprocessing once the
8 kernel lands. The HPC gated-MLA GEMM *is* wired up: it replaces the eager
9 output gating when the hpc package is installed, ``VLLM_ENABLE_HPC_OPS=1``
10 and the elementwise/dtype/alignment constraints hold.
11
12 The per-head learnable sink is supported through `.flashmla_sparse`, which
13 subclasses the platform's sparse MLA backend to forward ``attn_sink``.
14 """
15
16 from typing import cast
17
18 import regex as re
19 import torch
20 from torch import nn
21 from transformers import DeepseekV2Config, DeepseekV3Config, PreTrainedConfig
22
23 from vllm.compilation.breakable_cudagraph import eager_break_during_capture
24 from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
25 from vllm.config.cache import CacheDType
26 from vllm.distributed import get_tensor_model_parallel_world_size
27 from vllm.logger import init_logger
28 from vllm.model_executor.layers.attention import MLAAttention
29 from vllm.model_executor.layers.hpc import hpc_gated_mla_gemm, hpc_gated_mla_supported
30 from vllm.model_executor.layers.layernorm import LayerNorm, RMSNorm
31 from vllm.model_executor.layers.linear import (
32 ColumnParallelLinear,
33 MergedColumnParallelLinear,
34 ReplicatedLinear,
35 RowParallelLinear,
36 )
37 from vllm.model_executor.layers.quantization import QuantizationConfig
38 from vllm.model_executor.layers.quantization.utils.fp8_utils import (
39 per_token_group_quant_fp8,
40 )
41 from vllm.model_executor.layers.rotary_embedding import get_rope
42 from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
43 from vllm.model_executor.models.deepseek_v2 import DeepseekV32IndexerCache
44 from vllm.platforms import current_platform
45 from vllm.v1.attention.backend import AttentionBackend, AttentionType
46 from vllm.v1.attention.selector import get_attn_backend
47
48 logger = init_logger(__name__)
49
50 _SPARSE_LAYER_TYPES = ("sparse_attention", "sparse", "deepseek_sparse_attention")
51 _WEIGHT_LAYER_INDEX_RE = re.compile(r"(?:^|\.)layers\.(\d+)(?:\.|$)")
52
53
54 def compute_skip_topk_layers(config: PreTrainedConfig) -> set[int]:
55 """Return the backbone layers that reuse a previous layer's top-k indices.
56
57 A "shared" indexer layer performs sparse attention with the indices computed
58 by the closest preceding "full" indexer layer, so it does not build its own
59 indexer and its checkpoint indexer weights must be skipped.
60
61 Args:
62 config: The model config.
63
64 Returns:
65 The set of layer indices that share another layer's top-k indices.
66
67 Raises:
68 ValueError: If ``indexer_types`` has the wrong length or an unknown
69 entry, or if ``index_topk_freq`` is not a positive integer.
70
71 """
72 if not hasattr(config, "index_topk"):
73 return set()
74
75 num_hidden_layers = config.num_hidden_layers
76 indexer_types = getattr(config, "indexer_types", None)
77 if indexer_types is not None:
78 if len(indexer_types) != num_hidden_layers:
79 raise ValueError(
80 "indexer_types must contain one entry per hidden layer: "
81 f"expected {num_hidden_layers}, got {len(indexer_types)}."
82 )
83 invalid_types = sorted(set(indexer_types) - {"full", "shared"})
84 if invalid_types:
85 raise ValueError(
86 f"indexer_types only supports 'full' and 'shared', got {invalid_types}."
87 )
88 return {
89 layer_idx
90 for layer_idx, indexer_type in enumerate(indexer_types)
91 if indexer_type == "shared"
92 }
93
94 freq = getattr(config, "index_topk_freq", 1)
95 if not isinstance(freq, int) or freq <= 0:
96 raise ValueError(f"index_topk_freq must be a positive integer, got {freq!r}.")
97 pattern = getattr(config, "index_topk_pattern", None)
98 offset = getattr(config, "index_skip_topk_offset", 2)
99 skip_layers: set[int] = set()
100 for layer_idx in range(num_hidden_layers):
101 if pattern is None:
102 if max(layer_idx - offset + 1, 0) % freq != 0:
103 skip_layers.add(layer_idx)
104 elif 0 <= layer_idx < len(pattern) and pattern[layer_idx] == "S":
105 skip_layers.add(layer_idx)
106 return skip_layers
107
108
109 def is_skip_topk_indexer_weight(weight_name: str, skip_topk_layers: set[int]) -> bool:
110 """Return whether an indexer weight belongs to a top-k sharing layer.
111
112 Args:
113 weight_name: Checkpoint weight name.
114 skip_topk_layers: Result of `compute_skip_topk_layers`.
115
116 Returns:
117 True when the weight is an indexer weight of a layer that has no
118 indexer module and therefore must be dropped.
119
120 """
121 if ".indexer." not in weight_name or not skip_topk_layers:
122 return False
123 match = _WEIGHT_LAYER_INDEX_RE.search(weight_name)
124 return match is not None and int(match.group(1)) in skip_topk_layers
125
126
127 class Indexer(nn.Module):
128 """Lightning indexer selecting the top-k tokens for sparse MLA."""
129
130 def __init__(
131 self,
132 vllm_config: VllmConfig,
133 config: DeepseekV2Config | DeepseekV3Config,
134 hidden_size: int,
135 q_lora_rank: int,
136 quant_config: QuantizationConfig | None,
137 cache_config: CacheConfig | None,
138 topk_indices_buffer: torch.Tensor | None,
139 prefix: str = "",
140 ):
141 super().__init__()
142 self.vllm_config = vllm_config
143 self.config = config
144 self.quant_config = quant_config
145 self.topk_tokens = config.index_topk
146 self.n_head = config.index_n_heads
147 self.head_dim = config.index_head_dim
148 self.rope_dim = config.qk_rope_head_dim
149 self.q_lora_rank = q_lora_rank
150
151 # No tensor parallelism, just replicated.
152 self.wq_b = ReplicatedLinear(
153 self.q_lora_rank,
154 self.head_dim * self.n_head,
155 bias=False,
156 quant_config=quant_config,
157 prefix=f"{prefix}.wq_b",
158 )
159 # Fused wk + weights_proj: single GEMM producing [head_dim + n_head].
160 # FP8 wk weights are upcast to BF16 while loading to keep the fusion.
161 self.wk_weights_proj = MergedColumnParallelLinear(
162 hidden_size,
163 [self.head_dim, self.n_head],
164 bias=False,
165 quant_config=None,
166 disable_tp=True,
167 prefix=f"{prefix}.wk_weights_proj",
168 )
169 self.k_norm = LayerNorm(self.head_dim, eps=1e-6, dtype=torch.float32)
170 self.softmax_scale = self.head_dim**-0.5
171
172 self.scale_fmt = "ue8m0"
173 self.quant_block_size = 128
174 self.topk_indices_buffer = topk_indices_buffer
175
176 # FP8 naive cache: values in fp8 plus one fp32 scale per
177 # ``quant_block_size`` elements.
178 assert cache_config is not None, "HYV4 indexer requires cache_config"
179 self.k_cache = DeepseekV32IndexerCache(
180 head_dim=self.head_dim + self.head_dim // self.quant_block_size * 4,
181 dtype=torch.uint8,
182 prefix=f"{prefix}.k_cache",
183 cache_config=cache_config,
184 )
185 self.max_model_len = vllm_config.model_config.max_model_len
186 self.prefix = prefix
187
188 from vllm.v1.attention.backends.mla.indexer import get_max_prefill_buffer_size
189
190 self.max_total_seq_len = get_max_prefill_buffer_size(vllm_config)
191 self.indexer_op = SparseAttnIndexer(
192 self.k_cache,
193 self.quant_block_size,
194 self.scale_fmt,
195 self.topk_tokens,
196 self.head_dim,
197 self.max_model_len,
198 self.max_total_seq_len,
199 self.topk_indices_buffer,
200 )
201
202 def forward(
203 self,
204 hidden_states: torch.Tensor,
205 qr: torch.Tensor,
206 positions: torch.Tensor,
207 rotary_emb: nn.Module,
208 ) -> torch.Tensor:
209 hidden_states, q_quant, k, weights = self.prepare_inputs(
210 hidden_states, qr, positions, rotary_emb
211 )
212 return self.indexer_op(hidden_states, q_quant, k, weights)
213
214 def prepare_inputs(
215 self,
216 hidden_states: torch.Tensor,
217 qr: torch.Tensor,
218 positions: torch.Tensor,
219 rotary_emb: nn.Module,
220 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
221 """Build the quantized query, key and per-head weights of the indexer."""
222 q, _ = self.wq_b(qr)
223 q = q.view(-1, self.n_head, self.head_dim)
224 # Checkpoint (PTM) layout: pe occupies the LAST rope_dim dims.
225 q_nope, q_pe = torch.split(
226 q, [self.head_dim - self.rope_dim, self.rope_dim], dim=-1
227 )
228
229 kw, _ = self.wk_weights_proj(hidden_states)
230 k = kw[:, : self.head_dim]
231 weights = kw[:, self.head_dim :]
232
233 k = self.k_norm(k)
234 k_nope, k_pe = torch.split(
235 k, [self.head_dim - self.rope_dim, self.rope_dim], dim=-1
236 )
237
238 q_pe, k_pe = rotary_emb(positions, q_pe, k_pe.unsqueeze(1))
239 # RoPE (NeoX) can introduce extra leading dims, so flatten back to the
240 # token-major shapes.
241 q_pe = q_pe.reshape(-1, self.n_head, self.rope_dim)
242 k_pe = k_pe.reshape(-1, 1, self.rope_dim)
243
244 # Reassemble with the original physical layout: no_pe first, pe last.
245 q = torch.cat([q_nope, q_pe], dim=-1)
246 # ``k_pe`` is [num_tokens, 1, rope_dim] (MQA).
247 k = torch.cat([k_nope, k_pe.squeeze(-2)], dim=-1)
248
249 # Only q is quantized here; k quantization is fused with cache insertion.
250 q = q.view(-1, self.head_dim)
251 q_fp8, q_scale = per_token_group_quant_fp8(
252 q,
253 self.quant_block_size,
254 column_major_scales=False,
255 use_ue8m0=self.scale_fmt is not None,
256 )
257 q_fp8 = q_fp8.view(-1, self.n_head, self.head_dim)
258 q_scale = q_scale.view(-1, self.n_head, 1)
259
260 weights = (
261 weights.unsqueeze(-1) * q_scale * self.softmax_scale * self.n_head**-0.5
262 )
263 weights = weights.squeeze(-1)
264
265 return hidden_states, q_fp8, k, weights
266
267
268 class HYV4MLAAttention(nn.Module):
269 """Multi-head latent attention with optional sparse lightning indexer.
270
271 Main reference: the DeepSeek-V2 paper and the FlashInfer implementation
272 (https://arxiv.org/abs/2405.04434). HY V4 additionally supports an output
273 gate (``gated_mla``) and a per-head learnable attention sink.
274
275 The sink is applied by binding the sink-capable backend from
276 `.flashmla_sparse`; if no backend on this platform can consume sinks, the
277 weight is still loaded but the bias is disabled with a warning.
278 """
279
280 def __init__(
281 self,
282 vllm_config: VllmConfig,
283 config: PreTrainedConfig,
284 hidden_size: int,
285 num_heads: int,
286 qk_nope_head_dim: int,
287 qk_rope_head_dim: int,
288 v_head_dim: int,
289 q_lora_rank: int | None,
290 kv_lora_rank: int,
291 max_position_embeddings: int = 8192,
292 cache_config: CacheConfig | None = None,
293 quant_config: QuantizationConfig | None = None,
294 prefix: str = "",
295 topk_indices_buffer: torch.Tensor | None = None,
296 layer_idx: int = 0,
297 ) -> None:
298 super().__init__()
299 self.config = config
300 self.hidden_size = hidden_size
301 self.qk_nope_head_dim = qk_nope_head_dim
302 self.qk_rope_head_dim = qk_rope_head_dim
303 self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
304 self.v_head_dim = v_head_dim
305 self.layer_idx = layer_idx
306 self.q_lora_rank = q_lora_rank
307 self.kv_lora_rank = kv_lora_rank
308
309 self.num_heads = num_heads
310 tp_size = get_tensor_model_parallel_world_size()
311 assert num_heads % tp_size == 0
312 self.num_local_heads = num_heads // tp_size
313
314 self.layer_id = int(prefix.split(".")[-2])
315 layer_types = getattr(config, "layer_types", None)
316 requested_sparse = (
317 hasattr(config, "index_topk")
318 and layer_types is not None
319 and self.layer_id < len(layer_types)
320 and layer_types[self.layer_id] in _SPARSE_LAYER_TYPES
321 )
322 # Only actual sparse layers may share another layer's top-k indices.
323 self.skip_topk = requested_sparse and self.layer_id in compute_skip_topk_layers(
324 config
325 )
326 # The skip pattern only governs backbone layers. MTP/nextn layers
327 # (layer_id >= num_hidden_layers) always build a full indexer: they
328 # compute indices at draft step 0 and toggle at runtime.
329 num_hidden_layers = getattr(config, "num_hidden_layers", None)
330 is_mtp_layer = (
331 num_hidden_layers is not None and self.layer_id >= num_hidden_layers
332 )
333 self.create_indexer = requested_sparse and (not self.skip_topk or is_mtp_layer)
334 self.is_sparse = requested_sparse
335
336 # Do not silently degrade sparse layers into dense attention. Probe the
337 # sparse MLA backend directly and fail fast with the real error.
338 kv_cache_dtype = cache_config.cache_dtype if cache_config else "auto"
339 if self.is_sparse:
340 try:
341 get_attn_backend(
342 head_size=self.kv_lora_rank + self.qk_rope_head_dim,
343 dtype=torch.get_default_dtype(),
344 kv_cache_dtype=kv_cache_dtype,
345 use_mla=True,
346 has_sink=False,
347 use_sparse=True,
348 num_heads=self.num_local_heads,
349 )
350 except Exception as exc:
351 raise RuntimeError(
352 "HYV4 sparse attention was requested, but no valid sparse MLA "
353 "backend is available for current runtime/config. "
354 "Refusing to fall back to dense attention."
355 ) from exc
356
357 self.scaling = self.qk_head_dim**-0.5
358 self.max_position_embeddings = max_position_embeddings
359 self.fused_qkv_a_proj = None
360 self.kv_a_proj_with_mqa = None
361 if self.q_lora_rank is not None:
362 # ``q_a_proj`` and ``kv_a_proj_with_mqa`` read the same
363 # ``hidden_states`` and are both TP-replicated, so they run as one
364 # GEMM. The checkpoint keeps them separate; `load_weights` merges
365 # them through ``stacked_params_mapping``.
366 self.fused_qkv_a_proj = MergedColumnParallelLinear(
367 self.hidden_size,
368 [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim],
369 bias=False,
370 quant_config=quant_config,
371 prefix=f"{prefix}.fused_qkv_a_proj",
372 disable_tp=True,
373 )
374 else:
375 self.kv_a_proj_with_mqa = ReplicatedLinear(
376 self.hidden_size,
377 self.kv_lora_rank + self.qk_rope_head_dim,
378 bias=False,
379 quant_config=quant_config,
380 prefix=f"{prefix}.kv_a_proj_with_mqa",
381 )
382
383 self.q_a_layernorm = None
384 self.q_b_proj = None
385 self.q_proj = None
386 if self.q_lora_rank is not None:
387 self.q_a_layernorm = RMSNorm(self.q_lora_rank, eps=config.rms_norm_eps)
388 self.q_b_proj = ColumnParallelLinear(
389 self.q_lora_rank,
390 self.num_heads * self.qk_head_dim,
391 bias=False,
392 quant_config=quant_config,
393 prefix=f"{prefix}.q_b_proj",
394 )
395 else:
396 self.q_proj = ColumnParallelLinear(
397 self.hidden_size,
398 self.num_heads * self.qk_head_dim,
399 bias=False,
400 quant_config=quant_config,
401 prefix=f"{prefix}.q_proj",
402 )
403 self.kv_a_layernorm = RMSNorm(self.kv_lora_rank, eps=config.rms_norm_eps)
404 self.kv_b_proj = ColumnParallelLinear(
405 self.kv_lora_rank,
406 self.num_heads * (self.qk_nope_head_dim + self.v_head_dim),
407 bias=False,
408 quant_config=quant_config,
409 prefix=f"{prefix}.kv_b_proj",
410 )
411 self.o_proj = RowParallelLinear(
412 self.num_heads * self.v_head_dim,
413 self.hidden_size,
414 bias=False,
415 quant_config=quant_config,
416 prefix=f"{prefix}.o_proj",
417 )
418 self.rotary_emb = get_rope(
419 qk_rope_head_dim,
420 max_position=max_position_embeddings,
421 rope_parameters=config.rope_parameters,
422 is_neox_style=False,
423 )
424 self.indexer_rope_emb: nn.Module | None
425 self.indexer: Indexer | None
426 if self.create_indexer:
427 # The checkpoint stores indexer q_pe/k_pe in interleaved
428 # (Megatron/PTM) layout, so the indexer must use interleaved RoPE
429 # (is_neox_style=False) like the main attention path. Using NeoX
430 # here loses the relative-position dependence and corrupts the DSA
431 # top-k selection.
432 self.indexer_rope_emb = get_rope(
433 qk_rope_head_dim,
434 max_position=max_position_embeddings,
435 rope_parameters=config.rope_parameters,
436 is_neox_style=False,
437 )
438 # The indexer projects its queries from the MLA q_lora activations,
439 # so a sparse layer requires a query down-projection.
440 assert q_lora_rank is not None, (
441 "HYV4 sparse attention requires q_lora_rank to be set"
442 )
443 self.indexer = Indexer(
444 vllm_config,
445 config,
446 hidden_size,
447 q_lora_rank,
448 quant_config,
449 cache_config,
450 topk_indices_buffer,
451 f"{prefix}.indexer",
452 )
453 else:
454 self.indexer_rope_emb = None
455 self.indexer = None
456
457 self.gated_mla = bool(getattr(config, "gated_mla", False))
458 self.linear_gate: ColumnParallelLinear | None
459 if self.gated_mla:
460 if config.gating_type == "headwise":
461 self.gate_projection_size_per_head = 1
462 elif config.gating_type == "elementwise":
463 self.gate_projection_size_per_head = self.v_head_dim
464 else:
465 raise ValueError(f"Unknown gating type: {config.gating_type}")
466 self.linear_gate = ColumnParallelLinear(
467 self.hidden_size,
468 self.num_heads * self.gate_projection_size_per_head,
469 bias=False,
470 quant_config=quant_config,
471 prefix=f"{prefix}.linear_gate",
472 )
473 self.use_hpc_gated_mla = hpc_gated_mla_supported(
474 config.gating_type, self.linear_gate
475 )
476 else:
477 self.linear_gate = None
478 self.use_hpc_gated_mla = False
479 self.prefix = prefix
480
481 # Per-head learnable attention sink. Created BEFORE ``MLAAttention`` so
482 # it can be forwarded as the ``sinks`` impl kwarg. The parameter always
483 # holds the local TP shard.
484 self.learnable_sink = bool(getattr(config, "learnable_sink", False))
485 sinks = None
486 sink_backend: type[AttentionBackend] | None = None
487 if self.learnable_sink:
488 sink_backend = self._resolve_sink_backend(kv_cache_dtype)
489 enable_sink = sink_backend is not None
490 self.learnable_sink_param = nn.Parameter(
491 torch.empty(
492 self.num_local_heads,
493 # The kernels require fp32 sinks; the disabled path keeps
494 # the checkpoint dtype since the value is never consumed.
495 dtype=torch.float32 if enable_sink else torch.bfloat16,
496 )
497 )
498 if enable_sink:
499 sinks = self.learnable_sink_param
500 self._force_sparse_mqa()
501
502 extra_impl_args = {} if sinks is None else {"sinks": sinks}
503 self.mla_attn = MLAAttention(
504 num_heads=self.num_local_heads,
505 scale=self.scaling,
506 qk_nope_head_dim=self.qk_nope_head_dim,
507 qk_rope_head_dim=self.qk_rope_head_dim,
508 v_head_dim=self.v_head_dim,
509 q_lora_rank=self.q_lora_rank,
510 kv_lora_rank=self.kv_lora_rank,
511 cache_config=cache_config,
512 quant_config=quant_config,
513 prefix=f"{prefix}.attn",
514 kv_b_proj=self.kv_b_proj,
515 use_sparse=self.is_sparse,
516 indexer=self.indexer,
517 topk_indices_buffer=topk_indices_buffer,
518 attn_backend=sink_backend,
519 **extra_impl_args,
520 )
521
522 @property
523 def topk_indices_buffer(self) -> torch.Tensor | None:
524 """The sparse top-k index buffer this layer reads from.
525
526 The real consumer is ``mla_attn.impl``, an ``AttentionImpl`` rather than
527 an ``nn.Module``, so a plain ``named_modules()`` walk cannot reach it.
528 The MTP proposer rebinds the draft layers onto the target model's buffer
529 through such a walk, so expose it here: without this the draft attention
530 would keep reading its own buffer while the indices it needs are written
531 to the target's.
532 """
533 return getattr(self.mla_attn.impl, "topk_indices_buffer", None)
534
535 @topk_indices_buffer.setter
536 def topk_indices_buffer(self, buffer: torch.Tensor) -> None:
537 self.mla_attn.impl.topk_indices_buffer = buffer # type: ignore[attr-defined]
538
539 def _resolve_sink_backend(
540 self, kv_cache_dtype: str
541 ) -> type[AttentionBackend] | None:
542 """Return an MLA backend that can apply this layer's learnable sink.
543
544 The sink is part of the architecture, so a backend that cannot apply it
545 changes the model's output. Resolution order:
546
547 1. If the backend the selector would pick already advertises
548 `supports_sink`, keep it — this also honours an explicit
549 ``--attention-backend`` choice.
550 2. Otherwise fall back to the sink-capable ``FLASHMLA_SPARSE`` subclass
551 in `.flashmla_sparse`, whose kernels accept ``attn_sink``, provided
552 it validates against the current runtime configuration.
553 3. Otherwise give up on the bias rather than failing the load.
554
555 Args:
556 kv_cache_dtype: The layer's KV cache dtype string.
557
558 Returns:
559 The backend class to bind, or None when no sink-capable backend is
560 available; the caller then loads the sink weight but disables the
561 bias.
562
563 """
564 head_size = self.kv_lora_rank + self.qk_rope_head_dim
565 dtype = torch.get_default_dtype()
566 try:
567 selected_cls = get_attn_backend(
568 head_size=head_size,
569 dtype=dtype,
570 kv_cache_dtype=kv_cache_dtype,
571 use_mla=True,
572 use_sparse=self.is_sparse,
573 num_heads=self.num_local_heads,
574 )
575 except Exception as exc:
576 # Stringify before logging: warning_once dedupes on the arguments,
577 # and a fresh exception object per layer would defeat it.
578 logger.warning_once(
579 "HYV4 failed to select an MLA backend for the learnable sink "
580 "(%s); the sink parameter is loaded but the sink bias is "
581 "disabled.",
582 str(exc),
583 )
584 return None
585
586 if selected_cls.supports_sink():
587 return selected_cls
588
589 from .flashmla_sparse import HYV4FlashMLASparseBackend
590
591 # Mirror how the selector derives the configuration-dependent inputs so
592 # this check accepts exactly what the backend would accept at runtime.
593 capability = current_platform.get_device_capability()
594 if capability is None:
595 logger.warning_once(
596 "HYV4 learnable sink is unavailable: the device compute "
597 "capability is unknown. The sink parameter is loaded but the "
598 "sink bias is disabled."
599 )
600 return None
601 cache_config = get_current_vllm_config().cache_config
602 block_size = (
603 cache_config.block_size
604 if cache_config is not None and cache_config.user_specified_block_size
605 else None
606 )
607 invalid_reasons = HYV4FlashMLASparseBackend.validate_configuration(
608 head_size=head_size,
609 dtype=dtype,
610 kv_cache_dtype=cast(CacheDType, kv_cache_dtype),
611 block_size=block_size,
612 use_mla=True,
613 has_sink=True,
614 use_sparse=self.is_sparse,
615 use_mm_prefix=False,
616 use_per_head_quant_scales=False,
617 device_capability=capability,
618 attn_type=AttentionType.DECODER,
619 )
620 if invalid_reasons:
621 logger.warning_once(
622 "HYV4 learnable sink is unavailable: the selected backend %s "
623 "cannot apply sinks and the sink-capable FLASHMLA_SPARSE path "
624 "is invalid here (%s). The sink parameter is loaded but the "
625 "sink bias is disabled.",
626 selected_cls.get_name(),
627 ", ".join(invalid_reasons),
628 )
629 return None
630
631 logger.info_once(
632 "HYV4 learnable sink enabled: using the sink-capable "
633 "FLASHMLA_SPARSE impl instead of %s, which cannot apply sinks.",
634 selected_cls.get_name(),
635 )
636 return HYV4FlashMLASparseBackend
637
638 def _force_sparse_mqa(self) -> None:
639 """Keep every token on the sink-capable sparse MQA path.
640
641 ``_resolve_sink_backend`` only binds the backend that serves decode.
642 ``MLAAttention`` additionally routes short prefills to a separate dense
643 MLA prefill backend, and none of those accept ``attn_sink``, so prefill
644 would silently drop the sink while decode applies it. Such a partially
645 applied sink is not the trained architecture and corrupts the output, so
646 opt out of the dense split instead.
647
648 Prefills up to ``index_topk`` keep every token inside the sparse top-k,
649 making the sparse path numerically equivalent to the dense one apart from
650 also applying the sink.
651 """
652 attention_config = get_current_vllm_config().attention_config
653 if attention_config.sparse_mla_force_mqa:
654 return
655 attention_config.sparse_mla_force_mqa = True
656 logger.info_once(
657 "HYV4 learnable sink enabled: forcing sparse MQA for prefill too, "
658 "as the dense MLA prefill backends cannot apply sinks."
659 )
660
661 def forward(
662 self,
663 positions: torch.Tensor,
664 hidden_states: torch.Tensor,
665 llama_4_scaling: torch.Tensor | None = None,
666 ) -> torch.Tensor:
667 q_c = None
668 if self.q_lora_rank is not None:
669 assert self.fused_qkv_a_proj is not None
670 assert self.q_a_layernorm is not None
671 assert self.q_b_proj is not None
672 qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
673 q_c, kv_lora = qkv_lora.split(
674 [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim],
675 dim=-1,
676 )
677 q_c = self.q_a_layernorm(q_c)
678 q = self.q_b_proj(q_c)[0]
679 else:
680 assert self.q_proj is not None
681 assert self.kv_a_proj_with_mqa is not None
682 q = self.q_proj(hidden_states)[0]
683 kv_lora = self.kv_a_proj_with_mqa(hidden_states)[0]
684
685 kv_c, k_pe = kv_lora.split([self.kv_lora_rank, self.qk_rope_head_dim], dim=-1)
686 kv_c_normed = self.kv_a_layernorm(kv_c)
687
688 q = q.view(-1, self.num_local_heads, self.qk_head_dim)
689 # Add a head dim of 1 to k_pe.
690 k_pe = k_pe.unsqueeze(1)
691 q[..., self.qk_nope_head_dim :], k_pe = self.rotary_emb(
692 positions, q[..., self.qk_nope_head_dim :], k_pe
693 )
694
695 if llama_4_scaling is not None:
696 q *= llama_4_scaling
697
698 output_shape = (
699 hidden_states.shape[0],
700 self.num_local_heads * self.v_head_dim,
701 )
702 # Single coarse eager break covering the indexer and MLA attention, as
703 # the breakable cudagraph contract requires: everything that reads
704 # per-batch metadata runs in one eager segment, so no tensor has to stay
705 # alive across a capture-segment boundary.
706 attn_out = torch.empty(
707 output_shape, dtype=hidden_states.dtype, device=hidden_states.device
708 )
709 self._indexer_and_attn(
710 hidden_states, q_c, positions, q, kv_c_normed, k_pe, attn_out
711 )
712
713 if self.gated_mla and self.linear_gate is not None:
714 if self.use_hpc_gated_mla:
715 # Projection, sigmoid and the product in one launch. The gate
716 # is column-parallel and unbiased, so its local weight shard
717 # maps straight onto the local attn_out columns.
718 assert hidden_states.is_contiguous() and attn_out.is_contiguous()
719 attn_out = hpc_gated_mla_gemm(
720 hidden_states,
721 self.linear_gate.weight,
722 attn_out,
723 )
724 else:
725 gate_score = self.linear_gate(hidden_states)[0]
726 if self.config.gating_type == "headwise":
727 gate_score = gate_score.unsqueeze(-1)
728 attn_out = attn_out.reshape(
729 *attn_out.shape[:-1], -1, self.v_head_dim
730 )
731 attn_out = attn_out * torch.sigmoid(gate_score)
732 attn_out = attn_out.reshape(*attn_out.shape[:-2], -1)
733 else:
734 attn_out = attn_out * torch.sigmoid(gate_score)
735
736 out, _ = self.o_proj(attn_out)
737 return out
738
739 @eager_break_during_capture
740 def _indexer_and_attn(
741 self,
742 hidden_states: torch.Tensor,
743 q_c: torch.Tensor | None,
744 positions: torch.Tensor,
745 q: torch.Tensor,
746 kv_c_normed: torch.Tensor,
747 k_pe: torch.Tensor,
748 out: torch.Tensor, # [num_tokens, heads * v_head_dim], written in place
749 ) -> None:
750 """Run the lightning indexer and MLA attention in one eager segment.
751
752 Both read per-batch attention metadata, so under the breakable cudagraph
753 they must not be captured. Keeping them in a single break (instead of one
754 break each) also means the attention inputs never have to survive a
755 capture-segment boundary. The nested ``sparse_attn_indexer`` and
756 ``unified_mla_attention_with_output`` breaks short-circuit here, since
757 the capture is no longer active inside an eager segment.
758 """
759 if self.indexer is not None and self.is_sparse and not self.skip_topk:
760 self.indexer(hidden_states, q_c, positions, self.indexer_rope_emb)
761 if self.is_sparse:
762 self.mla_attn.impl.record_logical_topk_ready() # type: ignore[attr-defined]
763 out.copy_(
764 self.mla_attn(
765 q,
766 kv_c_normed,
767 k_pe,
768 output_shape=out.shape,
769 )
770 )