| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Regression test for AITER MLA persistent decode metadata dtypes. |
| 4 | |
| 5 | For the gfx950 fp8/fp8 nhead=32 qlen=1 fold path, the split/reduce metadata |
| 6 | layout depends on the q/kv element size. The builder must forward dtype_q/dtype_kv |
| 7 | to ``get_mla_metadata_v1``; omitting them lays out the work for the wrong dtype |
| 8 | and corrupts decode output. The test pins the builder's metadata to a golden |
| 9 | recomputed at runtime with the explicit correct dtypes. |
| 10 | """ |
| 11 | |
| 12 | import types |
| 13 | from unittest.mock import patch |
| 14 | |
| 15 | import pytest |
| 16 | import torch |
| 17 | |
| 18 | from vllm._aiter_ops import is_aiter_found |
| 19 | from vllm.platforms import current_platform |
| 20 | |
| 21 | |
| 22 | def _on_gfx950() -> bool: |
| 23 | if not (current_platform.is_rocm() and is_aiter_found()): |
| 24 | return False |
| 25 | from vllm.platforms.rocm import on_gfx950 |
| 26 | |
| 27 | return on_gfx950() |
| 28 | |
| 29 | |
| 30 | pytestmark = pytest.mark.skipif( |
| 31 | not _on_gfx950(), |
| 32 | reason="AITER MLA fp8 persistent decode metadata is gfx950-only", |
| 33 | ) |
| 34 | |
| 35 | # The fold path that the bug corrupted: fp8 query + fp8 KV-cache, 32 query |
| 36 | # heads, single-token decode, batch 128, context 8192, page_size 1. |
| 37 | NUM_QUERY_HEADS = 32 |
| 38 | DECODE_QLEN = 1 |
| 39 | BATCH_SIZE = 128 |
| 40 | CONTEXT_LEN = 8192 |
| 41 | PAGE_SIZE = 1 |
| 42 | |
| 43 | # On the fp8 KV-cache path the builder forwards the platform fp8 dtype (aiter |
| 44 | # dtypes.fp8) for both q and kv. Mirror it via current_platform.fp8_dtype() |
| 45 | # instead of hardcoding a literal (see #47276). |
| 46 | EXPECTED_Q_DTYPE = current_platform.fp8_dtype() |
| 47 | EXPECTED_KV_DTYPE = current_platform.fp8_dtype() |
| 48 | |
| 49 | # The split/reduce content tensors filled by get_mla_metadata_v1. work_meta_data |
| 50 | # is excluded: it holds raw device pointers, never equal across allocations. |
| 51 | _CONTENT_METADATA_FIELDS = ( |
| 52 | "work_indptr", |
| 53 | "work_info_set", |
| 54 | "reduce_indptr", |
| 55 | "reduce_final_map", |
| 56 | "reduce_partial_map", |
| 57 | ) |
| 58 | |
| 59 | # The builder's get_mla_metadata_v1 call passes 6 input args then 6 output |
| 60 | # buffers (see AiterMLAMetadataBuilder._build_decode). Output order -> field. |
| 61 | _NUM_INPUT_ARGS = 6 |
| 62 | _OUTPUT_ARG_FIELDS = ( |
| 63 | "work_meta_data", |
| 64 | "work_info_set", |
| 65 | "work_indptr", |
| 66 | "reduce_indptr", |
| 67 | "reduce_final_map", |
| 68 | "reduce_partial_map", |
| 69 | ) |
| 70 | |
| 71 | |
| 72 | def _build_decode_metadata(): |
| 73 | """Build AITER MLA decode metadata for the fp8/fp8 nhead=32 fold path. |
| 74 | |
| 75 | Returns ``(metadata, captured)`` where ``captured`` records the positional |
| 76 | args/kwargs the builder passed to ``get_mla_metadata_v1``, so the golden can |
| 77 | be recomputed from the identical inputs. |
| 78 | """ |
| 79 | from tests.v1.attention.utils import ( |
| 80 | BatchSpec, |
| 81 | create_common_attn_metadata, |
| 82 | create_vllm_config, |
| 83 | ) |
| 84 | from vllm.config.vllm import set_current_vllm_config |
| 85 | from vllm.model_executor.layers.attention.mla_attention import get_mla_dims |
| 86 | from vllm.v1.attention.backends.registry import AttentionBackendEnum |
| 87 | from vllm.v1.kv_cache_interface import MLAAttentionSpec |
| 88 | from vllm.v1.worker.workspace import init_workspace_manager |
| 89 | |
| 90 | device = torch.device("cuda:0") |
| 91 | |
| 92 | vllm_config = create_vllm_config( |
| 93 | model_name="deepseek-ai/DeepSeek-R1", |
| 94 | max_model_len=CONTEXT_LEN, |
| 95 | # One flat page per token (page_size=1); +buffer for the null block. |
| 96 | num_gpu_blocks=BATCH_SIZE * CONTEXT_LEN + 200, |
| 97 | block_size=PAGE_SIZE, |
| 98 | max_num_seqs=BATCH_SIZE, |
| 99 | max_num_batched_tokens=8192, |
| 100 | hf_config_override={"num_attention_heads": NUM_QUERY_HEADS}, |
| 101 | ) |
| 102 | vllm_config.cache_config.cache_dtype = "fp8" |
| 103 | |
| 104 | spec = MLAAttentionSpec( |
| 105 | block_size=PAGE_SIZE, |
| 106 | num_kv_heads=1, |
| 107 | head_size=vllm_config.model_config.get_head_size(), |
| 108 | dtype=vllm_config.model_config.dtype, |
| 109 | cache_dtype_str="fp8", |
| 110 | ) |
| 111 | |
| 112 | builder_cls = AttentionBackendEnum.ROCM_AITER_MLA.get_class().get_builder_cls() |
| 113 | |
| 114 | # The builder reads prefill_backend and the MLA latent dims off the layer, |
| 115 | # so the stub carries both; the dims come from the model config to stay in |
| 116 | # step with the checkpoint. |
| 117 | mla_dims = get_mla_dims(vllm_config.model_config) |
| 118 | layer_name = "placeholder" |
| 119 | vllm_config.compilation_config.static_forward_context[layer_name] = ( |
| 120 | types.SimpleNamespace( |
| 121 | prefill_backend=torch.empty((1,)), |
| 122 | q_lora_rank=mla_dims.q_lora_rank, |
| 123 | kv_lora_rank=mla_dims.kv_lora_rank, |
| 124 | qk_nope_head_dim=mla_dims.qk_nope_head_dim, |
| 125 | qk_rope_head_dim=mla_dims.qk_rope_head_dim, |
| 126 | v_head_dim=mla_dims.v_head_dim, |
| 127 | ) |
| 128 | ) |
| 129 | |
| 130 | init_workspace_manager(device) |
| 131 | |
| 132 | batch_spec = BatchSpec( |
| 133 | seq_lens=[CONTEXT_LEN] * BATCH_SIZE, |
| 134 | query_lens=[DECODE_QLEN] * BATCH_SIZE, |
| 135 | ) |
| 136 | |
| 137 | captured: dict = {} |
| 138 | |
| 139 | with set_current_vllm_config(vllm_config): |
| 140 | builder = builder_cls(spec, [layer_name], vllm_config, device) |
| 141 | common_attn_metadata = create_common_attn_metadata( |
| 142 | batch_spec, PAGE_SIZE, device, arange_block_indices=True |
| 143 | ) |
| 144 | |
| 145 | import aiter |
| 146 | |
| 147 | real_get_mla_metadata_v1 = aiter.get_mla_metadata_v1 |
| 148 | |
| 149 | def spy(*args, **kwargs): |
| 150 | captured["args"] = args |
| 151 | captured["kwargs"] = dict(kwargs) |
| 152 | return real_get_mla_metadata_v1(*args, **kwargs) |
| 153 | |
| 154 | with patch("aiter.get_mla_metadata_v1", spy): |
| 155 | metadata = builder.build( |
| 156 | common_prefix_len=0, |
| 157 | common_attn_metadata=common_attn_metadata, |
| 158 | ) |
| 159 | |
| 160 | return metadata, captured |
| 161 | |
| 162 | |
| 163 | def _compute_golden_metadata(captured: dict) -> dict[str, torch.Tensor]: |
| 164 | """Recompute the persistent metadata with explicit fp8/bf16 dtypes. |
| 165 | |
| 166 | Replays ``get_mla_metadata_v1`` on the builder's exact input tensors with |
| 167 | fresh output buffers and the explicitly-correct dtypes. This reference must |
| 168 | match the builder's output when the fix is in place. |
| 169 | """ |
| 170 | import aiter |
| 171 | |
| 172 | args = captured["args"] |
| 173 | inputs = args[:_NUM_INPUT_ARGS] |
| 174 | # Fresh copies so the golden does not alias the builder's persistent buffers. |
| 175 | fresh_outputs = [arg.clone() for arg in args[_NUM_INPUT_ARGS:]] |
| 176 | |
| 177 | golden_kwargs = dict(captured["kwargs"]) |
| 178 | golden_kwargs["dtype_q"] = EXPECTED_Q_DTYPE |
| 179 | golden_kwargs["dtype_kv"] = EXPECTED_KV_DTYPE |
| 180 | |
| 181 | aiter.get_mla_metadata_v1(*inputs, *fresh_outputs, **golden_kwargs) |
| 182 | |
| 183 | return dict(zip(_OUTPUT_ARG_FIELDS, fresh_outputs)) |
| 184 | |
| 185 | |
| 186 | def test_persistent_decode_metadata_matches_fp8_golden(): |
| 187 | """The builder's metadata must match the dtype-correct golden. |
| 188 | |
| 189 | Regression guard: the fixed builder forwards fp8/bf16 dtypes so its |
| 190 | split/reduce metadata matches the golden recomputed with those explicit |
| 191 | dtypes. Dropping the dtypes (the original bug) produces a different layout |
| 192 | and fails this test. |
| 193 | """ |
| 194 | metadata, captured = _build_decode_metadata() |
| 195 | |
| 196 | # qlen=1 must take the persistent-metadata path for this to be meaningful. |
| 197 | assert metadata.decode is not None |
| 198 | assert metadata.decode.has_persistent_metadata |
| 199 | assert metadata.work_meta_data is not None |
| 200 | |
| 201 | golden = _compute_golden_metadata(captured) |
| 202 | |
| 203 | mismatched = [ |
| 204 | name |
| 205 | for name in _CONTENT_METADATA_FIELDS |
| 206 | if getattr(metadata, name).shape != golden[name].shape |
| 207 | or not torch.equal(getattr(metadata, name), golden[name]) |
| 208 | ] |
| 209 | assert not mismatched, ( |
| 210 | "AITER MLA persistent decode metadata does not match the fp8/bf16 " |
| 211 | f"golden for fields {mismatched}; the builder must forward " |
| 212 | "dtype_q/dtype_kv to get_mla_metadata_v1." |
| 213 | ) |