main
py 540 lines 19.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """GPU numerics for the AITER ASM cprr DCP-verify route.
4
5 Runs the REAL metadata builder (``AiterMLAMetadataBuilder.build``, so
6 ``get_mla_metadata_v1`` is exercised with the cprr kwargs and the persistent
7 schedule buffers are produced) and the REAL asm kernel, on ONE GPU with the DCP
8 ranks simulated one after another.
9
10 That is legitimate because the aiter MLA backend contains no collective on this
11 path: the query all-gather happens in the *layer*, strictly before
12 ``forward_mqa``, so the head fold, the round-robin kernel call and the un-fold
13 are pure per-rank local arithmetic in which ``cp_world_size``/``cp_rank`` are
14 just numbers feeding the causal-mask maths. Restricting a shard to a residue
15 class of global positions is exactly what DCP rank r sees.
16
17 Three properties are checked:
18 * each shard matches an exact torch reference over the positions it holds,
19 with causality applied on the GLOBAL position (the thing cprr reconstructs
20 in-kernel and the segmented route instead carries in per-row lengths);
21 * the LSE merge of all shards reproduces full-context attention;
22 * a shard told the wrong ``cp_rank`` fails both -- without this positive
23 control a passing run proves nothing, since a kernel that ignored global
24 positions still produces plausible-looking output.
25 """
26
27 from __future__ import annotations
28
29 from types import SimpleNamespace
30
31 import pytest
32 import torch
33
34 from vllm.platforms import current_platform
35
36 if not current_platform.is_rocm():
37 pytest.skip("ROCm AITER MLA tests", allow_module_level=True)
38
39 from vllm._aiter_ops import is_aiter_found # noqa: E402
40 from vllm.v1.attention.backends.mla import rocm_aiter_mla # noqa: E402
41
42 KV_LORA_RANK = 512
43 QK_ROPE_HEAD_DIM = 64
44 QK_NOPE_HEAD_DIM = 128
45 HEAD_SIZE = KV_LORA_RANK + QK_ROPE_HEAD_DIM
46 SCALE = (QK_NOPE_HEAD_DIM + QK_ROPE_HEAD_DIM) ** -0.5
47
48 DCP = 8
49 NUM_REQS = 8
50 CTX = 256
51 QLEN = 5 # 1 + num_speculative_tokens; must clear _MIN_CPRR_QLEN
52
53 # fp8-e4m3 keeps 3 mantissa bits, so the relative error floor is ~6.2e-2. Gates
54 # are relative for the same reason: an absolute bound would just be a bound on
55 # the output magnitude.
56 SHARD_RTOL = 1.0e-1
57 MERGE_RTOL = 5.0e-2
58
59 MODEL = "deepseek-ai/DeepSeek-R1"
60
61
62 def _gpu_available() -> bool:
63 return is_aiter_found() and torch.accelerator.is_available()
64
65
66 def _on_gfx950() -> bool:
67 if not is_aiter_found():
68 return False
69 from vllm.platforms.rocm import on_gfx950
70
71 return on_gfx950()
72
73
74 requires_cprr = pytest.mark.skipif(
75 not (_gpu_available() and _on_gfx950()),
76 reason="cprr DCP verify needs ROCm + AITER on gfx950",
77 )
78
79
80 @pytest.fixture(autouse=True)
81 def select_asm_route(monkeypatch):
82 monkeypatch.setenv("VLLM_ROCM_AITER_MLA_DCP_VERIFY", "asm")
83
84
85 # --------------------------------------------------------------------------
86 # reference maths
87 # --------------------------------------------------------------------------
88
89
90 def _torch_reference(q_f32, kv_f32, ctx, qlen, scale, positions):
91 """Exact absorbed-MLA decode over an explicit set of GLOBAL KV positions.
92
93 A shard sees only ``positions``, but causality still uses the global
94 position: query token i (global position ctx + i) may attend to any p in
95 positions with p <= ctx + i. Restricting ``positions`` to a round-robin
96 residue class is precisely DCP rank r's view.
97 """
98 nr, _, heads, _ = q_f32.shape
99 dev = q_f32.device
100 o = torch.zeros(nr, qlen, heads, KV_LORA_RANK, dtype=torch.float32, device=dev)
101 lse = torch.full((nr, qlen, heads), float("-inf"), dtype=torch.float32, device=dev)
102 for r in range(nr):
103 kv_sel = kv_f32[r].index_select(0, positions)
104 for i in range(qlen):
105 valid = positions <= (ctx + i)
106 if not bool(valid.any()):
107 continue
108 k = kv_sel[valid]
109 s = (q_f32[r, i] @ k.T) * scale
110 m = s.max(dim=-1, keepdim=True).values
111 p = torch.exp(s - m)
112 denom = p.sum(dim=-1, keepdim=True)
113 o[r, i] = (p / denom) @ k[:, :KV_LORA_RANK]
114 lse[r, i] = m.squeeze(-1) + torch.log(denom.squeeze(-1))
115 return o, lse
116
117
118 def _merge_partials(os_, lses):
119 """Cross-shard softmax merge (natural-log LSE)."""
120 stack_lse = torch.stack(lses)
121 m = stack_lse.max(dim=0).values
122 w = torch.exp(stack_lse - m)
123 num = (w.unsqueeze(-1) * torch.stack(os_)).sum(dim=0)
124 den = w.sum(dim=0).unsqueeze(-1)
125 return num / den, m + torch.log(w.sum(dim=0))
126
127
128 # --------------------------------------------------------------------------
129 # single-process rank simulation
130 # --------------------------------------------------------------------------
131
132
133 class _FakeGroup:
134 def __init__(self, world_size: int, rank: int):
135 self.world_size = world_size
136 self.rank_in_group = rank
137 self.rank = rank
138 self.local_rank = rank
139
140 def all_gather(self, tensor, dim=0):
141 raise RuntimeError(
142 "collective called in a single-process test -- the path under test "
143 "is supposed to be collective-free; this indicates a real change"
144 )
145
146
147 def _install_fake_groups(dcp_size: int, cp_rank: int, tp_size: int = 1) -> None:
148 """Point vLLM's parallel_state at rank-simulating stubs (no NCCL)."""
149 import vllm.distributed.parallel_state as ps
150 import vllm.model_executor.layers.attention.mla_attention as mla_attention
151 import vllm.v1.attention.backends.mla.rocm_aiter_mla as backend
152
153 dcp = _FakeGroup(dcp_size, cp_rank)
154 tp = _FakeGroup(tp_size, cp_rank)
155 ps.get_dcp_group = lambda: dcp
156 ps.get_tp_group = lambda: tp
157 # Both of these imported the symbol directly at module import time, so
158 # patching parallel_state alone leaves them resolving the real group (and
159 # the builder then reports dcp_world_size 1, silently skipping the route).
160 backend.get_dcp_group = lambda: dcp
161 mla_attention.get_dcp_group = lambda: dcp
162
163
164 @pytest.fixture(autouse=True)
165 def _restore_parallel_groups():
166 """Undo _install_fake_groups after every test.
167
168 It patches module globals in three modules, so without this the fake DCP
169 group leaks into the rest of the pytest session and silently breaks
170 unrelated tests that expect a real (or absent) group.
171 """
172 import vllm.distributed.parallel_state as ps
173 import vllm.model_executor.layers.attention.mla_attention as mla_attention
174 import vllm.v1.attention.backends.mla.rocm_aiter_mla as backend
175
176 saved = [
177 (mod, name, getattr(mod, name))
178 for mod, name in (
179 (ps, "get_dcp_group"),
180 (ps, "get_tp_group"),
181 (backend, "get_dcp_group"),
182 (mla_attention, "get_dcp_group"),
183 )
184 ]
185 yield
186 for mod, name, value in saved:
187 setattr(mod, name, value)
188
189
190 class _PrefillBackendStub:
191 """The base builder does ``attention_layer.prefill_backend.clone()``.
192
193 prefill_backend is consulted only from the chunked-context prefill path,
194 which a decode-only test never enters.
195 """
196
197 def clone(self):
198 return self
199
200
201 def _make_ctx_layer(vllm_config):
202 """Stand-in for the registered attention layer in static_forward_context.
203
204 ``MLACommonMetadataBuilder.__init__`` reads the MLA dims, ``dcp_manager``
205 and ``prefill_backend`` off this object. ``dcp_manager`` is the Direct-DCP
206 symmetric-memory workspace, used ONLY by the chunked-prefill KV all-gather;
207 it is constructed without running __init__ so no peer pointers are exchanged
208 and no collective is issued. That is what keeps this test single-process.
209 """
210 from vllm.v1.attention.ops.dcp import MLADCPManager
211
212 mgr = object.__new__(MLADCPManager)
213 mgr.init_kv_gather = lambda *a, **k: None
214
215 return SimpleNamespace(
216 non_causal_multi_token_decode=False,
217 q_lora_rank=None,
218 kv_lora_rank=KV_LORA_RANK,
219 qk_nope_head_dim=QK_NOPE_HEAD_DIM,
220 qk_rope_head_dim=QK_ROPE_HEAD_DIM,
221 v_head_dim=QK_NOPE_HEAD_DIM,
222 dcp_manager=mgr,
223 prefill_backend=_PrefillBackendStub(),
224 )
225
226
227 def _ensure_workspace_manager(device: torch.device) -> None:
228 """Init the global workspace manager, which GPUModelRunner does in production.
229
230 ``AiterMLAMetadataBuilder.__init__`` reserves the fp8 prefill
231 persistent-scheduling scratch through ``current_workspace_manager()``, so
232 merely constructing a builder outside a serve trips its assert. Idempotent:
233 one builder is constructed per DCP shard. Deliberately does NOT lock the
234 workspace -- production locks only after warmup, and this keeps allocating
235 across shards.
236 """
237 from vllm.v1.worker.workspace import (
238 init_workspace_manager,
239 is_workspace_manager_initialized,
240 )
241
242 if not is_workspace_manager_initialized():
243 init_workspace_manager(device)
244
245
246 class _Layer:
247 def __init__(self, device):
248 self._q_scale = torch.tensor(1.0, device=device)
249 self._k_scale = torch.tensor(1.0, device=device)
250 self._v_scale = torch.tensor(1.0, device=device)
251
252
253 def _build_impl(num_heads: int, dcp: int, cp_rank: int):
254 """Real AiterMLAImpl.forward_mqa bound to a minimally-populated instance.
255
256 Constructing the full impl would drag in kv_b_proj and the rest of the
257 weight-dependent MLA setup -- i.e. model loading, which this test exists to
258 avoid. forward_mqa's fold path reads only the attributes set below.
259 """
260 from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAImpl
261
262 impl = object.__new__(AiterMLAImpl)
263 impl.kv_lora_rank = KV_LORA_RANK
264 impl.qk_rope_head_dim = QK_ROPE_HEAD_DIM
265 impl.num_heads = num_heads
266 impl.dcp_world_size = dcp
267 impl.dcp_rank = cp_rank
268 impl.pcp_world_size = 1
269 impl.kv_cache_dtype = "fp8"
270 impl.scale = SCALE
271 # `_decode_num_heads` is a read-only property deriving num_heads *
272 # dcp_world_size, both set above; assert rather than assign.
273 assert impl._decode_num_heads == num_heads * dcp
274 return impl
275
276
277 def _make_vllm_config(heads_per_rank: int):
278 """A DCP8 MLA config with no weights and no distributed init.
279
280 DeepSeek-R1's config supplies the MLA dims (kv_lora_rank 512, rope 64,
281 nope 128) that the aiter asm kernel is built for; only the head count is
282 overridden, to place the GATHERED count (heads_per_rank * DCP) on the
283 kernel variant under test. ``get_num_attention_heads`` reads
284 model_arch_config, a snapshot taken at config-creation time, so patching
285 hf_config alone would be inert.
286 """
287 from tests.v1.attention.utils import create_vllm_config
288
289 vllm_config = create_vllm_config(
290 model_name=MODEL,
291 tensor_parallel_size=1,
292 max_model_len=2048,
293 max_num_seqs=NUM_REQS * 2,
294 max_num_batched_tokens=4096,
295 )
296 vllm_config.model_config.model_arch_config.total_num_attention_heads = (
297 heads_per_rank
298 )
299 # Without a speculative config the batch reorder threshold stays at 1 and a
300 # qlen-QLEN row is classified as a PREFILL, so the decode path under test is
301 # never reached. _init_reorder_batch_threshold reads exactly these two
302 # fields, so a stand-in is enough and avoids pulling in a draft model.
303 vllm_config.speculative_config = SimpleNamespace(
304 num_speculative_tokens=QLEN - 1, parallel_drafting=False
305 )
306 vllm_config.cache_config.cache_dtype = "fp8"
307 vllm_config.parallel_config.decode_context_parallel_size = DCP
308 vllm_config.parallel_config.cp_kv_cache_interleave_size = 1
309 return vllm_config
310
311
312 def _run_shard(
313 vllm_config,
314 spec,
315 layer_name,
316 device,
317 cp_rank,
318 kern_rank,
319 q_flat,
320 kv_flat,
321 nr,
322 qlen,
323 ctx,
324 dcp,
325 pos,
326 ):
327 """Build real metadata for one DCP rank and run the real asm kernel.
328
329 ``kern_rank`` is separate from ``cp_rank`` so the positive control can hand
330 rank r's pages to the kernel while telling it that it is rank r+1.
331 """
332 from vllm.v1.attention.backend import CommonAttentionMetadata
333 from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAMetadataBuilder
334
335 L = ctx + qlen
336 local_len = int(pos.numel())
337
338 # One GLOBAL kv cache: page (req*L + p) holds request req position p. Shards
339 # select pages by residue class, so every shard reads the same underlying
340 # bytes and no re-quantisation noise is introduced between arms.
341 block_table = (
342 torch.arange(nr, device=device).view(nr, 1) * L + pos.view(1, -1)
343 ).to(torch.int32)
344
345 # seq_lens is the GLOBAL context; dcp_local_seq_lens is what this rank
346 # actually holds. Passing the global length as the local one walks the
347 # kernel off the end of the page table.
348 seq_lens_cpu = torch.full((nr,), L, dtype=torch.int32)
349 local_cpu = torch.full((nr,), local_len, dtype=torch.int32)
350
351 common = CommonAttentionMetadata(
352 query_start_loc=torch.arange(
353 0, nr * qlen + 1, qlen, dtype=torch.int32, device=device
354 ),
355 query_start_loc_cpu=torch.arange(0, nr * qlen + 1, qlen, dtype=torch.int32),
356 seq_lens=seq_lens_cpu.to(device),
357 num_reqs=nr,
358 num_actual_tokens=nr * qlen,
359 max_query_len=qlen,
360 max_seq_len=L,
361 block_table_tensor=block_table,
362 slot_mapping=torch.arange(nr * qlen, dtype=torch.int64, device=device),
363 causal=True,
364 dcp_local_seq_lens=local_cpu.to(device),
365 dcp_local_seq_lens_cpu_upper_bound=local_cpu,
366 )
367
368 _install_fake_groups(dcp, kern_rank)
369 builder = AiterMLAMetadataBuilder(spec, [layer_name], vllm_config, device)
370 md = builder.build(0, common)
371
372 impl = _build_impl(builder.num_heads, dcp, kern_rank)
373 o, lse = impl.forward_mqa(q_flat, kv_flat, md, _Layer(device))
374 torch.accelerator.synchronize()
375 return md, o, lse, local_len
376
377
378 def _numerics(heads_per_rank: int, sabotage: bool = False):
379 """Run every DCP shard and return (per-shard max rel err, merged rel err)."""
380 import aiter
381
382 from vllm.config import set_current_vllm_config
383
384 device = torch.device("cuda", 0)
385 torch.accelerator.set_device_index(device.index)
386 torch.manual_seed(0)
387 _ensure_workspace_manager(device)
388
389 _install_fake_groups(DCP, 0)
390 vllm_config = _make_vllm_config(heads_per_rank)
391
392 with set_current_vllm_config(vllm_config):
393 from vllm.v1.kv_cache_interface import MLAAttentionSpec
394
395 layer_name = "model.layers.0.self_attn.attn"
396 vllm_config.compilation_config.static_forward_context[layer_name] = (
397 _make_ctx_layer(vllm_config)
398 )
399 # block_size 1: one page per token, so page ids are positions and the
400 # residue-class page table is expressible directly.
401 spec = MLAAttentionSpec(
402 block_size=1,
403 num_kv_heads=1,
404 head_size=HEAD_SIZE,
405 dtype=vllm_config.model_config.dtype,
406 non_causal_multi_token_decode=False,
407 )
408
409 nr, ctx, qlen = NUM_REQS, CTX, QLEN
410 L = ctx + qlen
411 gathered_heads = heads_per_rank * DCP
412 kv_dtype = aiter.dtypes.fp8
413
414 kv_flat = torch.randn(
415 nr * L, 1, HEAD_SIZE, dtype=torch.float32, device=device
416 ).to(kv_dtype)
417 q_flat = (
418 torch.randn(
419 nr * qlen,
420 gathered_heads,
421 HEAD_SIZE,
422 dtype=torch.float32,
423 device=device,
424 )
425 .mul_(0.5)
426 .to(kv_dtype)
427 )
428 # Dequantise for the reference so the comparison measures the kernel,
429 # not the fp8 cast that both sides share.
430 kv_f32 = kv_flat.float().view(nr, L, HEAD_SIZE)
431 q_f32 = q_flat.float().view(nr, qlen, gathered_heads, HEAD_SIZE)
432
433 ref_o, _ = _torch_reference(
434 q_f32, kv_f32, ctx, qlen, SCALE, torch.arange(L, device=device)
435 )
436
437 shard_o, shard_lse = [], []
438 max_shard_rel = 0.0
439 for r in range(DCP):
440 pos = torch.arange(r, L, DCP, device=device) # residue class r
441 kern_rank = (r + 1) % DCP if sabotage else r
442 md, o, lse, _ = _run_shard(
443 vllm_config,
444 spec,
445 layer_name,
446 device,
447 r,
448 kern_rank,
449 q_flat,
450 kv_flat,
451 nr,
452 qlen,
453 ctx,
454 DCP,
455 pos,
456 )
457 # The route actually under test must have been taken; a silent
458 # fall-out to Triton/segmented would otherwise pass quietly.
459 assert md.decode.dcp_route is rocm_aiter_mla._DCPDecodeRoute.CPRR
460 assert md.decode.asm_decode_num_heads, "cprr asm route was not taken"
461
462 o = o.float().view(nr, qlen, gathered_heads, KV_LORA_RANK)
463 shard_lse.append(lse.float().view(nr, qlen, gathered_heads))
464 shard_o.append(o)
465
466 po, _ = _torch_reference(q_f32, kv_f32, ctx, qlen, SCALE, pos)
467 rel = (o - po).abs().max().item() / max(po.abs().max().item(), 1e-6)
468 max_shard_rel = max(max_shard_rel, rel)
469
470 merged, _ = _merge_partials(shard_o, shard_lse)
471 merge_rel = (merged - ref_o).abs().max().item() / max(
472 ref_o.abs().max().item(), 1e-6
473 )
474 return max_shard_rel, merge_rel
475
476
477 # --------------------------------------------------------------------------
478 # tests
479 # --------------------------------------------------------------------------
480
481
482 @requires_cprr
483 @pytest.mark.parametrize(
484 "heads_per_rank",
485 [
486 16, # 128 gathered heads -- a NATIVE cprr kernel, no padding
487 12, # 96 gathered heads -- K3's DSpark target, must pad up to 128
488 ],
489 ids=["native-128", "padded-96-to-128"],
490 )
491 @torch.inference_mode()
492 def test_cprr_shards_match_reference_and_merge_to_full_context(heads_per_rank):
493 """Every DCP shard must be right, and their LSE merge must reproduce
494 full-context attention.
495
496 A shard that ignored global positions (applying a local causal tail
497 instead) still produces plausible-looking output; it only diverges from the
498 reference on the tokens whose causal window is truncated. Checking the merge
499 alone would hide that, so both are asserted.
500 """
501 shard_rel, merge_rel = _numerics(heads_per_rank)
502 assert shard_rel < SHARD_RTOL, f"per-shard rel err {shard_rel:.3e}"
503 assert merge_rel < MERGE_RTOL, f"merged rel err {merge_rel:.3e}"
504
505
506 @requires_cprr
507 @torch.inference_mode()
508 def test_wrong_cp_rank_breaks_the_result():
509 """Positive control: without it, a passing run above proves nothing.
510
511 Feeding shard r's pages to the kernel while telling it that it is rank r+1
512 shifts every reconstructed global position by one, so both the causal mask
513 and the round-robin reconstruction are wrong. If the error does NOT blow up,
514 the kernel is not using cp_rank and the test above has no power.
515 """
516 shard_rel, merge_rel = _numerics(16, sabotage=True)
517 assert shard_rel > SHARD_RTOL or merge_rel > MERGE_RTOL, (
518 f"sabotaged run still passed (shard {shard_rel:.3e}, merge "
519 f"{merge_rel:.3e}) -- cp_rank is not reaching the kernel"
520 )
521
522
523 @requires_cprr
524 def test_cprr_kernel_is_selected_for_the_k3_shape():
525 """The K3 DSpark target shape must actually reach the cprr route.
526
527 Guards against a silent fall-out to segmented/Triton, which is what the
528 stock configuration does and what this route exists to avoid.
529 """
530 from vllm.v1.attention.backends.mla import rocm_aiter_mla as m
531
532 assert (
533 m._asm_dcp_verify_configured(
534 dcp_world_size=DCP, cp_interleave=1, multi_token_decode=True
535 )
536 is True
537 )
538 assert QLEN >= m._MIN_CPRR_QLEN
539 assert m._select_dcp_decode_route(True, True, QLEN, True) is m._DCPDecodeRoute.CPRR
540 assert m._asm_dcp_verify_heads(96) in m._NATIVE_CPRR_HEADS