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, 14:04 UTC
[ROCm][MLA] FP8 ASM prefill for AITER dense MLA backend on gfx950 (#42509)
Signed-off-by: Markus Hartikainen <markus.hartikainen@amd.com> Co-authored-by: clintg6 <clint.greene@amd.com> Co-authored-by: frida-andersson <frida.andersson@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Markus Hartikainen committed
May 15, 2026 at 18:56 UTC
46a95815d344e43ebd6b4757632c55d03e90983a
1 file changed
+369
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+369
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import functools
5
from dataclasses import dataclass
6
from typing import ClassVar, Final
7
@@ -9,6 +10,7 @@ import torch
10
from vllm._aiter_ops import rocm_aiter_ops
11
from vllm.config import VllmConfig
12
from vllm.config.cache import CacheDType
13
+from vllm.logger import init_logger
14
from vllm.model_executor.layers.attention.mla_attention import (
15
MLACommonBackend,
16
MLACommonDecodeMetadata,
@@ -26,6 +28,28 @@ from vllm.v1.attention.backend import (
28
)
29
from vllm.v1.kv_cache_interface import AttentionSpec
30
31
+logger = init_logger(__name__)
32
+
33
+
34
+@functools.lru_cache(maxsize=1)
35
+def _fp8_mla_prefill_supported() -> bool:
36
+ """Auto-detect FP8 MLA prefill via mla_prefill_ps_asm_fwd + mla_reduce_v1.
37
+
38
+ Requires gfx950 plus an AITER build that exports both kernels. When
39
+ either is missing we silently fall back to ``flash_attn_varlen_func``.
40
+ """
41
+ try:
42
+ from vllm.platforms.rocm import on_gfx950
43
+ except Exception: # noqa: BLE001
44
+ return False
45
+ if not on_gfx950():
46
+ return False
47
+ try:
48
+ from aiter import mla_prefill_ps_asm_fwd, mla_reduce_v1 # noqa: F401
49
+ except Exception: # noqa: BLE001
50
+ return False
51
+ return True
52
+
53
54
class AiterMLABackend(MLACommonBackend):
55
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16]
@@ -91,6 +115,25 @@ class AiterMLAMetadata(MLACommonMetadata[AiterMLADecodeMetadata]):
115
reduce_final_map: torch.Tensor | None = None
116
reduce_partial_map: torch.Tensor | None = None
117
118
+ # FP8 ASM prefill persistent-scheduling (PS) metadata. Populated by
119
+ # AiterMLAMetadataBuilder._build_fp8_prefill_ps_metadata when prefill
120
+ # tokens are present and FP8 MLA prefill is supported on the device.
121
+ # Left as None on hosts/configs that fall back to flash_attn_varlen_func.
122
+ fp8_prefill_qo_indptr: torch.Tensor | None = None
123
+ fp8_prefill_kv_indptr: torch.Tensor | None = None
124
+ fp8_prefill_kv_indices: torch.Tensor | None = None
125
+ fp8_prefill_work_indptr: torch.Tensor | None = None
126
+ fp8_prefill_work_info_set: torch.Tensor | None = None
127
+ fp8_prefill_reduce_indptr: torch.Tensor | None = None
128
+ fp8_prefill_reduce_final_map: torch.Tensor | None = None
129
+ fp8_prefill_reduce_partial_map: torch.Tensor | None = None
130
+ fp8_prefill_max_q_len: int | None = None
131
+ fp8_prefill_num_partial_tiles: int | None = None
132
+
133
+
134
+# Tile size used by the mla_prefill_ps_asm_fwd assembly kernel.
135
+_FP8_PREFILL_TILE_Q = 256
136
+
137
138
class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
139
# TODO(luka, lucas): audit this as part of:
@@ -193,6 +236,14 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
236
device=device,
237
)
238
239
+ self._fp8_prefill_enabled = _fp8_mla_prefill_supported()
240
+ if self._fp8_prefill_enabled:
241
+ max_prefill_qlen = min(
242
+ vllm_config.model_config.max_model_len,
243
+ vllm_config.scheduler_config.max_num_batched_tokens,
244
+ )
245
+ self._init_fp8_prefill_ps_buffers(max_num_reqs, max_prefill_qlen, device)
246
+
247
if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
248
self.paged_kv_indptr = torch.zeros(
249
max_num_reqs + 1, dtype=torch.int32, device=device
@@ -202,6 +253,162 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
253
max_num_reqs + 1, dtype=torch.int32, device=device
254
)
255
256
+ def _init_fp8_prefill_ps_buffers(
257
+ self,
258
+ max_num_reqs: int,
259
+ max_prefill_qlen: int,
260
+ device: torch.device,
261
+ ) -> None:
262
+ """Pre-allocate persistent buffers for FP8 MLA prefill PS metadata.
263
+
264
+ Uses ``get_ps_metadata_info_v1`` with max values so the buffers are
265
+ large enough for any batch. ``get_ps_metadata_v1`` fills them
266
+ per-batch in ``build()``.
267
+
268
+ Args:
269
+ max_num_reqs: Maximum number of concurrent requests.
270
+ max_prefill_qlen: Maximum Q-length for a single request in one
271
+ prefill batch. Should be ``min(max_model_len,
272
+ max_num_batched_tokens)`` — the chunked-prefill scheduler
273
+ never emits more than ``max_num_batched_tokens`` new tokens
274
+ per batch.
275
+ device: Target device for the buffers.
276
+ """
277
+ from aiter import get_ps_metadata_info_v1
278
+
279
+ # After kv_b_proj decompression, K has num_heads heads (same as Q).
280
+ # So gqa_ratio=1 and num_head_k=num_heads for the PS kernel.
281
+ num_head_k = self.num_heads
282
+ # gqa_ratio = 1
283
+ # qlen_granularity = _FP8_PREFILL_TILE_Q // max(gqa_ratio, 1)
284
+ qlen_granularity = _FP8_PREFILL_TILE_Q
285
+
286
+ (
287
+ (work_metadata_size, work_metadata_dtype),
288
+ (work_indptr_size, work_indptr_dtype),
289
+ (work_info_size, work_info_dtype),
290
+ (reduce_indptr_size, reduce_indptr_dtype),
291
+ (reduce_final_map_size, reduce_final_map_dtype),
292
+ (reduce_partial_map_size, reduce_partial_map_dtype),
293
+ ) = get_ps_metadata_info_v1(
294
+ batch_size=max_num_reqs,
295
+ num_head_k=num_head_k,
296
+ max_qlen=max_prefill_qlen,
297
+ qlen_granularity=qlen_granularity,
298
+ )
299
+
300
+ self.fp8_ps_work_metadata = torch.empty(
301
+ work_metadata_size, dtype=work_metadata_dtype, device=device
302
+ )
303
+ self.fp8_ps_work_indptr = torch.empty(
304
+ work_indptr_size, dtype=work_indptr_dtype, device=device
305
+ )
306
+ self.fp8_ps_work_info = torch.empty(
307
+ *work_info_size, dtype=work_info_dtype, device=device
308
+ )
309
+ self.fp8_ps_reduce_indptr = torch.empty(
310
+ reduce_indptr_size, dtype=reduce_indptr_dtype, device=device
311
+ )
312
+ self.fp8_ps_reduce_final_map = torch.empty(
313
+ *reduce_final_map_size, dtype=reduce_final_map_dtype, device=device
314
+ )
315
+ self.fp8_ps_reduce_partial_map = torch.empty(
316
+ reduce_partial_map_size,
317
+ dtype=reduce_partial_map_dtype,
318
+ device=device,
319
+ )
320
+
321
+ logger.info(
322
+ "FP8 MLA prefill PS buffers allocated "
323
+ "(max_batch=%d, max_qlen=%d, num_head_k=%d)",
324
+ max_num_reqs,
325
+ max_prefill_qlen,
326
+ num_head_k,
327
+ )
328
+
329
+ def _build_fp8_prefill_ps_metadata(
330
+ self,
331
+ metadata: AiterMLAMetadata,
332
+ common_attn_metadata: CommonAttentionMetadata,
333
+ ) -> None:
334
+ """Build per-batch FP8 MLA prefill PS metadata and attach to *metadata*.
335
+
336
+ Called from ``build()`` when prefill tokens are present and
337
+ FP8 MLA prefill is enabled (auto-detected via
338
+ ``_fp8_mla_prefill_supported()``).
339
+ """
340
+ from aiter import get_ps_metadata_v1
341
+
342
+ prefill = metadata.prefill
343
+ # Caller (build()) only invokes this when prefill tokens exist, so
344
+ # metadata.prefill is guaranteed non-None. Assert to narrow for mypy.
345
+ assert prefill is not None
346
+ qo_indptr = prefill.query_start_loc
347
+ kv_indptr = qo_indptr # new tokens: KV length == Q length
348
+
349
+ # Reuse the existing CPU view of query_start_loc instead of forcing a
350
+ # device->host copy. Prefill batches sit at the tail of the request
351
+ # list, so we slice from num_decodes onwards and rebase to zero, the
352
+ # same transform the parent build applies on device tensors.
353
+ num_decodes = metadata.num_decodes
354
+ qsl_cpu = common_attn_metadata.query_start_loc_cpu
355
+ qo_indptr_cpu = (qsl_cpu[num_decodes:] - qsl_cpu[num_decodes]).to(torch.int32)
356
+ kv_indptr_cpu = qo_indptr_cpu.clone()
357
+ seq_lens_cpu = (qo_indptr_cpu[1:] - qo_indptr_cpu[:-1]).to(torch.int32)
358
+
359
+ num_head_k = self.num_heads
360
+ # gqa_ratio = 1
361
+ # qhead_granularity = max(gqa_ratio, 1)
362
+ # qlen_granularity = _FP8_PREFILL_TILE_Q // qhead_granularity
363
+ gqa_ratio = 1
364
+ qhead_granularity = 1
365
+ qlen_granularity = _FP8_PREFILL_TILE_Q
366
+ kvlen_granularity = 128
367
+ block_size = 1 # non-paged: each "page" is one token
368
+
369
+ get_ps_metadata_v1(
370
+ qo_indptr_cpu,
371
+ kv_indptr_cpu,
372
+ seq_lens_cpu,
373
+ gqa_ratio,
374
+ num_head_k,
375
+ self.fp8_ps_work_metadata,
376
+ self.fp8_ps_work_indptr,
377
+ self.fp8_ps_work_info,
378
+ self.fp8_ps_reduce_indptr,
379
+ self.fp8_ps_reduce_final_map,
380
+ self.fp8_ps_reduce_partial_map,
381
+ qhead_granularity=qhead_granularity,
382
+ qlen_granularity=qlen_granularity,
383
+ kvlen_granularity=kvlen_granularity,
384
+ block_size=block_size,
385
+ is_causal=True,
386
+ )
387
+
388
+ total_prefill_tokens = int(qo_indptr_cpu[-1].item())
389
+ kv_indices = torch.arange(
390
+ total_prefill_tokens, device=qo_indptr.device, dtype=torch.int32
391
+ )
392
+
393
+ # The actual number of active partial tiles for this batch is the
394
+ # final value of reduce_indptr. Resolving it here (during metadata
395
+ # build) keeps it off the per-layer forward path where a sync would
396
+ # break CUDA Graph capture. Using the device-side reduce_indptr is
397
+ # acceptable since build is allowed to incur an occasional sync.
398
+ num_partial_tiles = int(self.fp8_ps_reduce_indptr[-1].item())
399
+
400
+ # Attach PS metadata to the metadata object so forward_mha can read it.
401
+ metadata.fp8_prefill_qo_indptr = qo_indptr
402
+ metadata.fp8_prefill_kv_indptr = kv_indptr
403
+ metadata.fp8_prefill_kv_indices = kv_indices
404
+ metadata.fp8_prefill_work_indptr = self.fp8_ps_work_indptr
405
+ metadata.fp8_prefill_work_info_set = self.fp8_ps_work_info
406
+ metadata.fp8_prefill_reduce_indptr = self.fp8_ps_reduce_indptr
407
+ metadata.fp8_prefill_reduce_final_map = self.fp8_ps_reduce_final_map
408
+ metadata.fp8_prefill_reduce_partial_map = self.fp8_ps_reduce_partial_map
409
+ metadata.fp8_prefill_max_q_len = prefill.max_query_len
410
+ metadata.fp8_prefill_num_partial_tiles = num_partial_tiles
411
+
412
def _build_decode(
413
self,
414
block_table_tensor: torch.Tensor,
@@ -335,6 +542,8 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
542
attn_metadata.reduce_indptr = self._mla_reduce_indptr
543
attn_metadata.reduce_final_map = self._mla_reduce_final_map
544
attn_metadata.reduce_partial_map = self._mla_reduce_partial_map
545
+ if self._fp8_prefill_enabled and attn_metadata.prefill is not None:
546
+ self._build_fp8_prefill_ps_metadata(attn_metadata, common_attn_metadata)
547
return attn_metadata
548
549
@@ -479,6 +688,15 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
688
689
self.flash_attn_varlen_func = flash_attn_varlen_func
690
691
+ # FP8 MLA prefill kernel imports (lazy, only when enabled).
692
+ # Auto-enabled on gfx950 when AITER ships the kernels.
693
+ self._fp8_prefill_enabled = _fp8_mla_prefill_supported()
694
+ if self._fp8_prefill_enabled:
695
+ from aiter import mla_prefill_ps_asm_fwd, mla_reduce_v1
696
+
697
+ self._mla_prefill_ps_asm_fwd = mla_prefill_ps_asm_fwd
698
+ self._mla_reduce_v1 = mla_reduce_v1
699
+
700
def _flash_attn_varlen_diff_headdims(
701
self, q, k, v, return_softmax_lse=False, softmax_scale=None, **kwargs
702
):
@@ -493,6 +711,157 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
711
712
return output
713
714
+ def _mla_fp8_prefill_attn(
715
+ self,
716
+ q: torch.Tensor,
717
+ k: torch.Tensor,
718
+ v: torch.Tensor,
719
+ attn_metadata: AiterMLAMetadata,
720
+ out: torch.Tensor,
721
+ ) -> None:
722
+ """Run FP8 MLA prefill via mla_prefill_ps_asm_fwd + mla_reduce_v1.
723
+
724
+ Q, K, V are already decompressed (post-kv_b_proj), so K and V have
725
+ ``num_heads`` heads (same as Q) and gqa_ratio=1. Writes the
726
+ result in-place to ``out``, which is the [total_q, nhead * v_head_dim]
727
+ output buffer supplied by ``forward_mha``; no extra allocation or
728
+ copy is required.
729
+ """
730
+ from vllm.platforms import current_platform
731
+ from vllm.v1.worker.workspace import current_workspace_manager
732
+
733
+ fp8_dtype = current_platform.fp8_dtype()
734
+ total_q = q.shape[0]
735
+ nhead = self.num_heads
736
+ v_head_dim = self.v_head_dim
737
+ tile_q = _FP8_PREFILL_TILE_Q
738
+
739
+ # The FP8 ASM kernel expects FP8 inputs; the q_scale/k_scale/v_scale
740
+ # parameters select per-tensor dequant scales. Q/K/V arrive as
741
+ # bf16 from kv_b_proj, so cast here (one_scale=1.0 disables scaling).
742
+ if q.dtype != fp8_dtype:
743
+ q = q.to(fp8_dtype)
744
+ if k.dtype != fp8_dtype:
745
+ k = k.to(fp8_dtype)
746
+ if v.dtype != fp8_dtype:
747
+ v = v.to(fp8_dtype)
748
+
749
+ one_scale = torch.ones((), dtype=torch.float32, device=q.device)
750
+
751
+ # num_partial_tiles is resolved during metadata build to avoid an
752
+ # in-forward .item() sync that would prevent CUDA Graph capture.
753
+ # forward_mha gates the FP8 path on fp8_prefill_qo_indptr being set,
754
+ # and the builder always sets every fp8_prefill_* field together, so
755
+ # num_partial_tiles is non-None here.
756
+ num_partial_tiles = attn_metadata.fp8_prefill_num_partial_tiles
757
+ assert num_partial_tiles is not None
758
+
759
+ # Reuse the caller's output buffer to skip the per-call alloc + copy.
760
+ # The ASM and reduce kernels both write to a [total_q, nhead, v_head_dim]
761
+ # view, which aliases the [total_q, nhead * v_head_dim] storage of out.
762
+ out_3d = out.view(total_q, nhead, v_head_dim)
763
+
764
+ # Per-call scratch (logits, attn_lse, final_lse) is served from the
765
+ # workspace manager so allocator churn in the prefill hot path is
766
+ # bounded after warmup, matching the pattern in PR #41002.
767
+ logits, attn_lse, final_lse = current_workspace_manager().get_simultaneous(
768
+ ((num_partial_tiles * tile_q, nhead, v_head_dim), torch.float32),
769
+ ((num_partial_tiles * tile_q, nhead), torch.float32),
770
+ ((total_q, nhead), torch.float32),
771
+ )
772
+
773
+ # Phase 1: persistent-scheduling assembly prefill kernel.
774
+ self._mla_prefill_ps_asm_fwd(
775
+ q,
776
+ k,
777
+ v,
778
+ attn_metadata.fp8_prefill_qo_indptr,
779
+ attn_metadata.fp8_prefill_kv_indptr,
780
+ attn_metadata.fp8_prefill_kv_indices,
781
+ attn_metadata.fp8_prefill_work_indptr,
782
+ attn_metadata.fp8_prefill_work_info_set,
783
+ attn_metadata.fp8_prefill_max_q_len,
784
+ self.scale,
785
+ True, # is_causal
786
+ logits,
787
+ attn_lse,
788
+ out_3d,
789
+ one_scale,
790
+ one_scale,
791
+ one_scale,
792
+ )
793
+
794
+ # Phase 2: reduction across KV splits.
795
+ self._mla_reduce_v1(
796
+ logits,
797
+ attn_lse,
798
+ attn_metadata.fp8_prefill_reduce_indptr,
799
+ attn_metadata.fp8_prefill_reduce_final_map,
800
+ attn_metadata.fp8_prefill_reduce_partial_map,
801
+ tile_q,
802
+ out_3d,
803
+ final_lse,
804
+ )
805
+
806
+ def forward_mha(
807
+ self,
808
+ q: torch.Tensor,
809
+ kv_c_normed: torch.Tensor,
810
+ k_pe: torch.Tensor,
811
+ kv_c_and_k_pe_cache: torch.Tensor,
812
+ attn_metadata: MLACommonMetadata,
813
+ k_scale: torch.Tensor,
814
+ output: torch.Tensor,
815
+ ) -> None:
816
+ """Dispatch prefill to the FP8 ASM kernel when available.
817
+
818
+ Falls back to the parent (``flash_attn_varlen_func``) when FP8
819
+ MLA prefill is disabled, PS metadata is missing, or chunked
820
+ context requires two-pass merge.
821
+
822
+ The annotation uses the base ``MLACommonMetadata`` to honour LSP
823
+ with ``MLACommonImpl.forward_mha``; the AITER builder always
824
+ produces ``AiterMLAMetadata`` instances at runtime, so we narrow
825
+ with ``isinstance`` before reading the AITER-specific FP8 fields.
826
+ """
827
+ if (
828
+ not self._fp8_prefill_enabled
829
+ or not isinstance(attn_metadata, AiterMLAMetadata)
830
+ or attn_metadata.fp8_prefill_qo_indptr is None
831
+ ):
832
+ return super().forward_mha(
833
+ q,
834
+ kv_c_normed,
835
+ k_pe,
836
+ kv_c_and_k_pe_cache,
837
+ attn_metadata,
838
+ k_scale,
839
+ output,
840
+ )
841
+
842
+ assert attn_metadata.prefill is not None
843
+ prefill_metadata = attn_metadata.prefill
844
+ has_context = prefill_metadata.chunked_context is not None
845
+
846
+ if has_context:
847
+ return super().forward_mha(
848
+ q,
849
+ kv_c_normed,
850
+ k_pe,
851
+ kv_c_and_k_pe_cache,
852
+ attn_metadata,
853
+ k_scale,
854
+ output,
855
+ )
856
+
857
+ kv_nope = self.kv_b_proj(kv_c_normed)[0].view(
858
+ -1, self.num_heads, self.qk_nope_head_dim + self.v_head_dim
859
+ )
860
+ k_nope, v = kv_nope.split([self.qk_nope_head_dim, self.v_head_dim], dim=-1)
861
+ k = self._concat_k_nope_k_pe(k_nope, k_pe)
862
+
863
+ self._mla_fp8_prefill_attn(q, k, v, attn_metadata, output)
864
+
865
def forward_mqa(
866
self,
867
q: torch.Tensor | tuple[torch.Tensor, torch.Tensor],