| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Smoke tests for the CPU MLA backend. |
| 4 | |
| 5 | Two levels of coverage are provided: |
| 6 | |
| 7 | * :func:`test_kv_cache_cpu_write` checks that the kv-cache write logic |
| 8 | inlined in the CPU MLA backend writes the latent KV cache in the same |
| 9 | layout the CPU decode kernel expects. This runs without downloading |
| 10 | any model weights and is safe for CI. |
| 11 | * :func:`test_cpu_mla_backend_smoke` exercises the full end-to-end path |
| 12 | (LLM engine, model construction, prefill + decode) against a shrunk |
| 13 | DeepSeek-V2-Lite with `hf_overrides`. It uses `dummy` weights so the |
| 14 | outputs are not meaningful, only shape / plumbing correctness is |
| 15 | asserted. Marked `cpu_model` so it is skipped on non-CPU CI. |
| 16 | """ |
| 17 | |
| 18 | import pytest |
| 19 | import torch |
| 20 | |
| 21 | from vllm.platforms import current_platform |
| 22 | from vllm.v1.attention.backends.mla.prefill.cpu_sdpa import ( |
| 23 | CPUSDPAMLAPrefillBackend, |
| 24 | ) |
| 25 | from vllm.v1.attention.backends.mla.prefill.selector import ( |
| 26 | get_mla_prefill_backend, |
| 27 | ) |
| 28 | from vllm.v1.attention.backends.mla.prefill.zen_cpu_sdpa import ( |
| 29 | ZenCPUSDPAMLAPrefillBackend, |
| 30 | ) |
| 31 | |
| 32 | |
| 33 | def _reference_ragged_attention( |
| 34 | q: torch.Tensor, |
| 35 | k: torch.Tensor, |
| 36 | v: torch.Tensor, |
| 37 | q_cu_seq_lens: torch.Tensor, |
| 38 | kv_cu_seq_lens: torch.Tensor, |
| 39 | *, |
| 40 | scale: float, |
| 41 | causal: bool, |
| 42 | ) -> tuple[torch.Tensor, torch.Tensor]: |
| 43 | outputs: list[torch.Tensor] = [] |
| 44 | lses: list[torch.Tensor] = [] |
| 45 | num_reqs = q_cu_seq_lens.numel() - 1 |
| 46 | for req_idx in range(num_reqs): |
| 47 | q_start = int(q_cu_seq_lens[req_idx]) |
| 48 | q_end = int(q_cu_seq_lens[req_idx + 1]) |
| 49 | kv_start = int(kv_cu_seq_lens[req_idx]) |
| 50 | kv_end = int(kv_cu_seq_lens[req_idx + 1]) |
| 51 | |
| 52 | q_r = q[q_start:q_end].transpose(0, 1).float() |
| 53 | k_r = k[kv_start:kv_end].transpose(0, 1).float() |
| 54 | v_r = v[kv_start:kv_end].transpose(0, 1).float() |
| 55 | |
| 56 | scores = torch.matmul(q_r, k_r.transpose(-2, -1)) * scale |
| 57 | if causal: |
| 58 | causal_mask = torch.ones( |
| 59 | (q_end - q_start, kv_end - kv_start), |
| 60 | dtype=torch.bool, |
| 61 | device=scores.device, |
| 62 | ).triu(1) |
| 63 | scores.masked_fill_(causal_mask.unsqueeze(0), float("-inf")) |
| 64 | |
| 65 | probs = torch.softmax(scores, dim=-1) |
| 66 | outputs.append(torch.matmul(probs, v_r).transpose(0, 1).to(v.dtype)) |
| 67 | lses.append(torch.logsumexp(scores, dim=-1)) |
| 68 | |
| 69 | return torch.cat(outputs, dim=0), torch.cat(lses, dim=1) |
| 70 | |
| 71 | |
| 72 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 73 | def test_kv_cache_cpu_write() -> None: |
| 74 | kv_lora_rank = 512 |
| 75 | pe_dim = 64 |
| 76 | block_size = 16 |
| 77 | num_blocks = 4 |
| 78 | num_tokens = 8 |
| 79 | dtype = torch.float32 |
| 80 | |
| 81 | kv_c = torch.randn(num_tokens, kv_lora_rank, dtype=dtype) |
| 82 | k_pe = torch.randn(num_tokens, pe_dim, dtype=dtype) |
| 83 | kv_cache = torch.zeros(num_blocks, block_size, kv_lora_rank + pe_dim, dtype=dtype) |
| 84 | slot_mapping = torch.arange(num_tokens, dtype=torch.long) |
| 85 | # Mark the last slot as padding to make sure we honour negative slots. |
| 86 | slot_mapping[-1] = -1 |
| 87 | |
| 88 | flat = kv_cache.view(-1, kv_lora_rank + pe_dim) |
| 89 | slots = slot_mapping.to(torch.long) |
| 90 | valid_mask = slots >= 0 |
| 91 | if not valid_mask.all().item(): |
| 92 | slots = slots[valid_mask] |
| 93 | kv_c = kv_c[valid_mask] |
| 94 | k_pe = k_pe[valid_mask] |
| 95 | target_dtype = flat.dtype |
| 96 | flat[slots, :kv_lora_rank] = kv_c.to(target_dtype) |
| 97 | flat[slots, kv_lora_rank:] = k_pe.to(target_dtype) |
| 98 | |
| 99 | flat = kv_cache.view(-1, kv_lora_rank + pe_dim) |
| 100 | for i in range(num_tokens - 1): |
| 101 | assert torch.equal(flat[i, :kv_lora_rank], kv_c[i]) |
| 102 | assert torch.equal(flat[i, kv_lora_rank:], k_pe[i]) |
| 103 | # The padding slot must remain untouched. |
| 104 | assert torch.all(flat[num_tokens - 1] == 0) |
| 105 | |
| 106 | |
| 107 | @pytest.mark.cpu_model |
| 108 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 109 | def test_cpu_mla_backend_smoke(tmp_path) -> None: |
| 110 | # Shrink the architecture aggressively so the test is CI-friendly. |
| 111 | from vllm import LLM, SamplingParams |
| 112 | |
| 113 | hf_overrides = { |
| 114 | "num_hidden_layers": 2, |
| 115 | "n_routed_experts": 4, |
| 116 | "first_k_dense_replace": 0, |
| 117 | "num_experts_per_tok": 2, |
| 118 | } |
| 119 | llm = LLM( |
| 120 | model="deepseek-ai/DeepSeek-V2-Lite", |
| 121 | skip_tokenizer_init=True, |
| 122 | trust_remote_code=True, |
| 123 | load_format="dummy", |
| 124 | enforce_eager=True, |
| 125 | max_model_len=128, |
| 126 | max_num_seqs=2, |
| 127 | block_size=16, |
| 128 | gpu_memory_utilization=0.25, |
| 129 | hf_overrides=hf_overrides, |
| 130 | kv_transfer_config={ |
| 131 | "kv_connector": "ExampleConnector", |
| 132 | "kv_role": "kv_both", |
| 133 | "kv_connector_extra_config": { |
| 134 | "shared_storage_path": str(tmp_path), |
| 135 | }, |
| 136 | }, |
| 137 | ) |
| 138 | |
| 139 | sampling_params = SamplingParams(max_tokens=4, temperature=0.0) |
| 140 | outputs = llm.generate([[1, 2, 3, 4], [5, 6, 7]], sampling_params, use_tqdm=False) |
| 141 | assert len(outputs) == 2 |
| 142 | for output in outputs: |
| 143 | # `dummy` weights do not produce meaningful text, but the number |
| 144 | # of generated tokens must match what we asked for. |
| 145 | assert len(output.outputs[0].token_ids) == 4 |
| 146 | |
| 147 | prefix_hit_prompt = [1] * 20 |
| 148 | outputs = llm.generate([prefix_hit_prompt], sampling_params, use_tqdm=False) |
| 149 | assert len(outputs[0].outputs[0].token_ids) == 4 |
| 150 | assert llm.reset_prefix_cache(reset_connector=False) |
| 151 | |
| 152 | # ExampleConnector persists KV externally at block granularity. With a |
| 153 | # 20-token prompt and block_size=16, the second run reloads 16 cached |
| 154 | # tokens and forces the 4-token suffix through MLA prefill with context. |
| 155 | outputs = llm.generate([prefix_hit_prompt], sampling_params, use_tqdm=False) |
| 156 | assert len(outputs[0].outputs[0].token_ids) == 4 |
| 157 | |
| 158 | |
| 159 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 160 | def test_cpu_mla_prefill_backend_selected() -> None: |
| 161 | # On CPU (no device capability) the MLA prefill backend must be the |
| 162 | # SDPA-based CPU backend, not flash-attn which is unavailable on CPU. |
| 163 | # Accelerator-specific subclasses (e.g. the zentorch-backed Zen CPU |
| 164 | # backend) are also valid selections. |
| 165 | backend_cls = get_mla_prefill_backend(None) |
| 166 | assert issubclass(backend_cls, CPUSDPAMLAPrefillBackend) |
| 167 | |
| 168 | |
| 169 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 170 | def test_cpu_mla_prefill_new_tokens() -> None: |
| 171 | # The SDPA prefill must exactly match a reference causal attention over |
| 172 | # the ragged (varlen) q/k/v layout, with per-request padding handled. |
| 173 | backend = CPUSDPAMLAPrefillBackend( |
| 174 | num_heads=4, |
| 175 | scale=0.25, |
| 176 | kv_lora_rank=16, |
| 177 | qk_nope_head_dim=16, |
| 178 | qk_rope_head_dim=0, |
| 179 | v_head_dim=16, |
| 180 | vllm_config=None, |
| 181 | ) |
| 182 | |
| 183 | class _PrefillMeta: |
| 184 | query_start_loc = torch.tensor([0, 3, 6], dtype=torch.int32) |
| 185 | |
| 186 | backend.prepare_metadata(_PrefillMeta()) |
| 187 | q = torch.randn(6, 4, 16, dtype=torch.bfloat16) |
| 188 | k = torch.randn(6, 4, 16, dtype=torch.bfloat16) |
| 189 | v = torch.randn(6, 4, 16, dtype=torch.bfloat16) |
| 190 | out = backend.run_prefill_new_tokens(q, k, v, return_softmax_lse=False) |
| 191 | ref, _ = _reference_ragged_attention( |
| 192 | q, |
| 193 | k, |
| 194 | v, |
| 195 | _PrefillMeta.query_start_loc, |
| 196 | _PrefillMeta.query_start_loc, |
| 197 | scale=0.25, |
| 198 | causal=True, |
| 199 | ) |
| 200 | assert out.float().allclose(ref.float(), atol=2e-2) |
| 201 | assert out.shape == (6, 4, 16) |
| 202 | |
| 203 | out_lse, lse = backend.run_prefill_new_tokens(q, k, v, return_softmax_lse=True) |
| 204 | ref_out, ref_lse = _reference_ragged_attention( |
| 205 | q, |
| 206 | k, |
| 207 | v, |
| 208 | _PrefillMeta.query_start_loc, |
| 209 | _PrefillMeta.query_start_loc, |
| 210 | scale=0.25, |
| 211 | causal=True, |
| 212 | ) |
| 213 | assert out_lse.float().allclose(ref_out.float(), atol=2e-2) |
| 214 | assert lse.float().allclose(ref_lse.float(), atol=2e-2) |
| 215 | assert lse.shape == (4, 6) |
| 216 | |
| 217 | |
| 218 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 219 | def test_cpu_mla_prefill_context_chunk() -> None: |
| 220 | backend = CPUSDPAMLAPrefillBackend( |
| 221 | num_heads=4, |
| 222 | scale=0.25, |
| 223 | kv_lora_rank=16, |
| 224 | qk_nope_head_dim=16, |
| 225 | qk_rope_head_dim=0, |
| 226 | v_head_dim=16, |
| 227 | vllm_config=None, |
| 228 | ) |
| 229 | |
| 230 | class _PrefillMeta: |
| 231 | query_start_loc = torch.tensor([0, 5], dtype=torch.int32) |
| 232 | |
| 233 | class _Chunk: |
| 234 | query_start_loc = torch.tensor([0, 2, 5], dtype=torch.int32) |
| 235 | cu_seq_lens = torch.tensor([0, 3, 7], dtype=torch.int32) |
| 236 | |
| 237 | backend.prepare_metadata(_PrefillMeta()) |
| 238 | q = torch.randn(5, 4, 16, dtype=torch.bfloat16) |
| 239 | k = torch.randn(7, 4, 16, dtype=torch.bfloat16) |
| 240 | v = torch.randn(7, 4, 16, dtype=torch.bfloat16) |
| 241 | |
| 242 | out, lse = backend.run_prefill_context_chunk(_Chunk(), q, k, v) |
| 243 | ref_out, ref_lse = _reference_ragged_attention( |
| 244 | q, |
| 245 | k, |
| 246 | v, |
| 247 | _Chunk.query_start_loc, |
| 248 | _Chunk.cu_seq_lens, |
| 249 | scale=0.25, |
| 250 | causal=False, |
| 251 | ) |
| 252 | assert out.float().allclose(ref_out.float(), atol=2e-2) |
| 253 | assert lse.float().allclose(ref_lse.float(), atol=2e-2) |
| 254 | assert out.shape == (5, 4, 16) |
| 255 | assert lse.shape == (4, 5) |
| 256 | |
| 257 | |
| 258 | @pytest.mark.skipif(not current_platform.is_cpu(), reason="CPU only") |
| 259 | @pytest.mark.skipif( |
| 260 | not ZenCPUSDPAMLAPrefillBackend.is_available(), |
| 261 | reason="requires a Zen CPU with the zentorch_sdpa op registered", |
| 262 | ) |
| 263 | def test_zen_cpu_mla_prefill_new_tokens() -> None: |
| 264 | # Runs the zentorch kernel itself. The head dims are deliberately |
| 265 | # asymmetric (v_head_dim < qk_head_dim, as in real MLA) so that the V |
| 266 | # padding and output slicing are exercised; the generic-CPU cases above |
| 267 | # use equal dims and never reach that code. |
| 268 | qk_head_dim = 24 |
| 269 | v_head_dim = 16 |
| 270 | backend = ZenCPUSDPAMLAPrefillBackend( |
| 271 | num_heads=4, |
| 272 | scale=0.25, |
| 273 | kv_lora_rank=16, |
| 274 | qk_nope_head_dim=16, |
| 275 | qk_rope_head_dim=8, |
| 276 | v_head_dim=v_head_dim, |
| 277 | vllm_config=None, |
| 278 | ) |
| 279 | |
| 280 | class _PrefillMeta: |
| 281 | query_start_loc = torch.tensor([0, 3, 6], dtype=torch.int32) |
| 282 | |
| 283 | backend.prepare_metadata(_PrefillMeta()) |
| 284 | q = torch.randn(6, 4, qk_head_dim, dtype=torch.bfloat16) |
| 285 | k = torch.randn(6, 4, qk_head_dim, dtype=torch.bfloat16) |
| 286 | v = torch.randn(6, 4, v_head_dim, dtype=torch.bfloat16) |
| 287 | |
| 288 | out = backend.run_prefill_new_tokens(q, k, v, return_softmax_lse=False) |
| 289 | ref, _ = _reference_ragged_attention( |
| 290 | q, |
| 291 | k, |
| 292 | v, |
| 293 | _PrefillMeta.query_start_loc, |
| 294 | _PrefillMeta.query_start_loc, |
| 295 | scale=0.25, |
| 296 | causal=True, |
| 297 | ) |
| 298 | assert out.shape == (6, 4, v_head_dim) |
| 299 | assert out.float().allclose(ref.float(), atol=2e-2) |
| 300 | |
| 301 | # zentorch_sdpa does not return a usable LSE, so that path must fall back |
| 302 | # to the fp32 reference and match the generic backend exactly. |
| 303 | generic = CPUSDPAMLAPrefillBackend( |
| 304 | num_heads=4, |
| 305 | scale=0.25, |
| 306 | kv_lora_rank=16, |
| 307 | qk_nope_head_dim=16, |
| 308 | qk_rope_head_dim=8, |
| 309 | v_head_dim=v_head_dim, |
| 310 | vllm_config=None, |
| 311 | ) |
| 312 | generic.prepare_metadata(_PrefillMeta()) |
| 313 | zen_out, zen_lse = backend.run_prefill_new_tokens(q, k, v, return_softmax_lse=True) |
| 314 | gen_out, gen_lse = generic.run_prefill_new_tokens(q, k, v, return_softmax_lse=True) |
| 315 | assert torch.equal(zen_out, gen_out) |
| 316 | assert torch.equal(zen_lse, gen_lse) |