| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import pytest |
| 5 | import torch |
| 6 | |
| 7 | from vllm.v1.attention.ops.xpu_mla_sparse import triton_bf16_mla_sparse_interface |
| 8 | |
| 9 | |
| 10 | # https://github.com/deepseek-ai/FlashMLA/blob/main/tests/ref.py#L7 |
| 11 | def _merge_two_lse( |
| 12 | lse0: torch.Tensor, lse1: torch.Tensor | None, s_q: int, h_q: int |
| 13 | ) -> torch.Tensor: |
| 14 | if lse1 is None: |
| 15 | return lse0 |
| 16 | else: |
| 17 | return torch.logsumexp( |
| 18 | torch.stack([lse0.view(s_q, h_q), lse1.broadcast_to(s_q, h_q)], dim=0), |
| 19 | dim=0, |
| 20 | ) |
| 21 | |
| 22 | |
| 23 | # Adapted from https://github.com/deepseek-ai/FlashMLA/blob/main/tests/ref.py#L19 |
| 24 | def reference_mla_sparse_prefill( |
| 25 | q: torch.Tensor, |
| 26 | kv: torch.Tensor, |
| 27 | indices: torch.Tensor, |
| 28 | sm_scale: float, |
| 29 | d_v: int, |
| 30 | topk_length: torch.Tensor | None = None, |
| 31 | attn_sink: torch.Tensor | None = None, |
| 32 | ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: |
| 33 | """Returns: |
| 34 | - o: [s_q, h_q, dv] |
| 35 | - o_fp32: [s_q, h_q, dv] |
| 36 | - max_logits: [s_q, h_q] |
| 37 | - lse: [s_q, h_q] |
| 38 | |
| 39 | """ |
| 40 | s_q, h_q, d_qk = q.shape |
| 41 | s_kv, _, _ = kv.shape |
| 42 | _, _, topk = indices.shape |
| 43 | |
| 44 | indices = indices.clone().squeeze(1) |
| 45 | if topk_length is not None: |
| 46 | mask = torch.arange(topk, device=topk_length.device).unsqueeze(0).broadcast_to( |
| 47 | s_q, topk |
| 48 | ) >= topk_length.unsqueeze(1) # [s_q, topk] |
| 49 | indices[mask] = -1 |
| 50 | invalid_mask = (indices < 0) | (indices >= s_kv) # [s_q, topk] |
| 51 | indices[invalid_mask] = 0 |
| 52 | |
| 53 | q = q.float() |
| 54 | gathered_kv = ( |
| 55 | kv.index_select(dim=0, index=indices.flatten()).reshape(s_q, topk, d_qk).float() |
| 56 | ) # [s_q, topk, d_qk] |
| 57 | P = q @ gathered_kv.transpose(1, 2) # [s_q, h_q, topk] |
| 58 | P *= sm_scale |
| 59 | P[invalid_mask.unsqueeze(1).broadcast_to(P.shape)] = float("-inf") |
| 60 | |
| 61 | orig_lse = torch.logsumexp(P, dim=-1) # [s_q, h_q] |
| 62 | max_logits = P.max(dim=-1).values # [s_q, h_q] |
| 63 | |
| 64 | lse_for_o = _merge_two_lse(orig_lse, attn_sink, s_q, h_q) |
| 65 | if not torch.is_inference_mode_enabled(): |
| 66 | lse_for_o = lse_for_o.clone() |
| 67 | lse_for_o[lse_for_o == float("-inf")] = float( |
| 68 | "+inf" |
| 69 | ) # So that corresponding O will be 0 |
| 70 | s_for_o = torch.exp(P - lse_for_o.unsqueeze(-1)) |
| 71 | out = s_for_o @ gathered_kv[..., :d_v] # [s_q, h_q, dv] |
| 72 | |
| 73 | lonely_q_mask = orig_lse == float("-inf") # [s_q, h_q] |
| 74 | orig_lse[lonely_q_mask] = float("+inf") |
| 75 | return (out.to(kv.dtype), out, max_logits, orig_lse) |
| 76 | |
| 77 | |
| 78 | @pytest.mark.parametrize("device_str", ["xpu"]) |
| 79 | @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) |
| 80 | @pytest.mark.skipif( |
| 81 | not torch.xpu.is_available(), |
| 82 | reason="XPU is required", |
| 83 | ) |
| 84 | def test_bf16_triton_sparse_mla(device_str, dtype): |
| 85 | device = torch.device(device_str) |
| 86 | s_q = 1 |
| 87 | s_kv = 256 |
| 88 | h_q = 64 # kernel expects multiple of 64 |
| 89 | h_kv = 1 |
| 90 | d_qk = 576 |
| 91 | d_v = 512 |
| 92 | topk = 128 |
| 93 | |
| 94 | torch.random.manual_seed(1234) |
| 95 | |
| 96 | q = torch.randn((s_q, h_q, d_qk), dtype=dtype, device=device) |
| 97 | kv = torch.randn((s_kv, h_kv, d_qk), dtype=dtype, device=device) |
| 98 | indices = torch.full((s_q, h_kv, topk), -1, dtype=torch.int32, device=device) |
| 99 | for t in range(s_q): |
| 100 | for h in range(h_kv): |
| 101 | i_i = torch.randperm(max(1, t))[:topk] |
| 102 | indices[t, h, : len(i_i)] = i_i |
| 103 | |
| 104 | sm_scale = d_qk**-0.5 |
| 105 | |
| 106 | out, max_logits, lse = triton_bf16_mla_sparse_interface( |
| 107 | q, kv, indices, sm_scale, d_v |
| 108 | ) |
| 109 | assert out.shape == (s_q, h_q, d_v) |
| 110 | assert max_logits.shape == (s_q, h_q) |
| 111 | assert lse.shape == (s_q, h_q) |
| 112 | |
| 113 | ref_out, ref_out_fp32, ref_max_logits, ref_lse = reference_mla_sparse_prefill( |
| 114 | q, kv, indices, sm_scale, d_v |
| 115 | ) |
| 116 | assert torch.allclose(out, ref_out, atol=1e-2, rtol=1e-2) |
| 117 | assert torch.allclose(max_logits, ref_max_logits, atol=1e-3, rtol=1e-3) |
| 118 | assert torch.allclose(lse, ref_lse, atol=1e-3, rtol=1e-3) |
| 119 | |
| 120 | |
| 121 | @pytest.mark.parametrize("device_str", ["xpu"]) |
| 122 | @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) |
| 123 | @pytest.mark.skipif( |
| 124 | not torch.xpu.is_available(), |
| 125 | reason="XPU is required", |
| 126 | ) |
| 127 | def test_bf16_triton_sparse_mla_masked_chunks(device_str, dtype): |
| 128 | """Rows whose leading BLOCK_N index entries are all masked must not NaN. |
| 129 | |
| 130 | Regression test: with an -inf running max, a fully-masked leading chunk |
| 131 | produced re_scale = exp2(-inf - -inf) = NaN, permanently poisoning the |
| 132 | accumulator even though valid keys followed in later chunks. |
| 133 | """ |
| 134 | device = torch.device(device_str) |
| 135 | s_q = 3 |
| 136 | s_kv = 256 |
| 137 | h_q = 64 |
| 138 | h_kv = 1 |
| 139 | d_qk = 576 |
| 140 | d_v = 512 |
| 141 | topk = 128 # 8 chunks of BLOCK_N=16 |
| 142 | |
| 143 | torch.random.manual_seed(1234) |
| 144 | |
| 145 | q = torch.randn((s_q, h_q, d_qk), dtype=dtype, device=device) |
| 146 | kv = torch.randn((s_kv, h_kv, d_qk), dtype=dtype, device=device) |
| 147 | indices = torch.full((s_q, h_kv, topk), -1, dtype=torch.int32, device=device) |
| 148 | # row 0: valid keys only in chunks 1-2 -> leading AND trailing masked chunks |
| 149 | indices[0, 0, 16:48] = torch.arange(32, dtype=torch.int32, device=device) |
| 150 | # row 1: fully valid |
| 151 | indices[1, 0, :] = torch.arange(topk, dtype=torch.int32, device=device) |
| 152 | # row 2: no valid key at all |
| 153 | |
| 154 | sm_scale = d_qk**-0.5 |
| 155 | |
| 156 | out, max_logits, lse = triton_bf16_mla_sparse_interface( |
| 157 | q, kv, indices, sm_scale, d_v |
| 158 | ) |
| 159 | assert out.isfinite().all() |
| 160 | |
| 161 | ref_out, _, ref_max_logits, ref_lse = reference_mla_sparse_prefill( |
| 162 | q, kv, indices, sm_scale, d_v |
| 163 | ) |
| 164 | assert torch.allclose(out[:2], ref_out[:2], atol=1e-2, rtol=1e-2) |
| 165 | assert torch.allclose(max_logits[:2], ref_max_logits[:2], atol=1e-3, rtol=1e-3) |
| 166 | assert torch.allclose(lse[:2], ref_lse[:2], atol=1e-3, rtol=1e-3) |
| 167 | # A row with no valid key yields zeros (the reference's convention); its |
| 168 | # lse/max_logits are large-negative finite rather than the reference's |
| 169 | # +inf/-inf placeholders, so only the output is compared here. |
| 170 | assert torch.allclose(out[2], torch.zeros_like(out[2])) |