main
py 1,081 lines 33.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Correctness tests for MiniMax M3 MSA sparse attention (CUTLASS decode and
4 NVFP4 KV cache)."""
5
6 import math
7 from types import SimpleNamespace
8
9 import pytest
10 import torch
11
12 from tests.kernels.quantization.nvfp4_utils import dequant_nvfp4_kv_cache
13 from vllm import _custom_ops as ops
14 from vllm.config import AttentionConfig, CUDAGraphMode
15 from vllm.forward_context import ForwardContext, override_forward_context
16 from vllm.models.minimax_m3.common.ops.sparse_attn import (
17 minimax_m3_sparse_attn_decode,
18 )
19 from vllm.models.minimax_m3.common.sparse_attention import (
20 MiniMaxM3SparseBackend,
21 MiniMaxM3SparseMetadata,
22 MiniMaxM3SparseMetadataBuilder,
23 MiniMaxM3SparsePrefillMetadata,
24 MiniMaxM3SparseTritonImpl,
25 select_main_backend_and_impl_cls,
26 )
27 from vllm.models.minimax_m3.nvidia import (
28 sparse_attention_msa as sparse_attention_msa_module,
29 )
30 from vllm.models.minimax_m3.nvidia.model import MiniMaxM3SparseAttention
31 from vllm.models.minimax_m3.nvidia.msa_cutlass_sparse_decode import (
32 MSACutlassDecodePlanCache,
33 msa_cutlass_sparse_decode,
34 nvfp4_kv_cache_views,
35 prepare_decode_metadata,
36 should_prepare_decode_metadata,
37 )
38 from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
39 MiniMaxM3SparseMSABackend,
40 MiniMaxM3SparseMSADecodeMetadata,
41 MiniMaxM3SparseMSAImpl,
42 MiniMaxM3SparseMSAMetadataBuilder,
43 MiniMaxM3SparseMSANvfp4Backend,
44 )
45 from vllm.platforms import current_platform
46 from vllm.v1.attention.backends.registry import AttentionBackendEnum
47
48 if not current_platform.is_device_capability_family(100):
49 pytest.skip(
50 "fmha_sm100 sparse decode requires SM100 (Blackwell).",
51 allow_module_level=True,
52 )
53
54
55 HEAD_DIM = 128
56 BLOCK_SIZE = 128
57 TOPK = 16
58 DEFAULT_QUERY_LEN = 4
59 SM_SCALE = HEAD_DIM**-0.5
60
61
62 @pytest.mark.parametrize(
63 (
64 "batch_size",
65 "decode_query_len",
66 "num_q_heads",
67 "num_kv_heads",
68 "expected",
69 ),
70 [
71 pytest.param(8, 4, 64, 4, False, id="tp1-below-min-batch"),
72 pytest.param(16, 4, 64, 4, True, id="tp1-supported"),
73 pytest.param(16, 4, 16, 1, True, id="tp4-min-batch"),
74 pytest.param(24, 4, 16, 1, True, id="tp4-intermediate-batch"),
75 pytest.param(32, 4, 16, 1, True, id="tp4-supported"),
76 pytest.param(16, 1, 64, 4, True, id="tp1-query-len-1"),
77 pytest.param(16, 1, 16, 1, True, id="tp4-query-len-1"),
78 pytest.param(16, 2, 64, 4, True, id="tp1-query-len-2"),
79 pytest.param(16, 2, 16, 1, True, id="tp4-query-len-2"),
80 pytest.param(16, 32, 64, 4, True, id="query-len-upper-bound"),
81 pytest.param(16, 0, 64, 4, False, id="query-len-zero"),
82 pytest.param(16, 33, 64, 4, False, id="query-len-above-bound"),
83 ],
84 )
85 def test_msa_cutlass_decode_static_dispatch(
86 batch_size: int,
87 decode_query_len: int,
88 expected: bool,
89 num_q_heads: int,
90 num_kv_heads: int,
91 ) -> None:
92 assert (
93 should_prepare_decode_metadata(
94 batch_size,
95 decode_query_len,
96 decode_backend="cutlass",
97 num_q_heads=num_q_heads,
98 num_kv_heads=num_kv_heads,
99 kv_cache_dtype="fp8_e4m3",
100 page_size=BLOCK_SIZE,
101 topk_blocks=TOPK,
102 )
103 is expected
104 )
105
106
107 def test_msa_cutlass_decode_static_dispatch_requires_opt_in() -> None:
108 assert not should_prepare_decode_metadata(
109 32,
110 DEFAULT_QUERY_LEN,
111 decode_backend="triton",
112 num_q_heads=16,
113 num_kv_heads=1,
114 kv_cache_dtype="fp8_e4m3",
115 page_size=BLOCK_SIZE,
116 topk_blocks=TOPK,
117 )
118
119
120 def test_msa_cutlass_decode_static_dispatch_accepts_fp8_alias() -> None:
121 assert should_prepare_decode_metadata(
122 32,
123 DEFAULT_QUERY_LEN,
124 decode_backend="cutlass",
125 num_q_heads=16,
126 num_kv_heads=1,
127 kv_cache_dtype="fp8",
128 page_size=BLOCK_SIZE,
129 topk_blocks=TOPK,
130 )
131
132
133 @pytest.mark.parametrize(
134 "kv_cache_dtype",
135 ["auto", "bfloat16", "float16", "fp8_e5m2"],
136 )
137 def test_msa_cutlass_decode_static_dispatch_requires_fp8_e4m3(
138 kv_cache_dtype: str,
139 ) -> None:
140 assert not should_prepare_decode_metadata(
141 32,
142 DEFAULT_QUERY_LEN,
143 decode_backend="cutlass",
144 num_q_heads=16,
145 num_kv_heads=1,
146 kv_cache_dtype=kv_cache_dtype,
147 page_size=BLOCK_SIZE,
148 topk_blocks=TOPK,
149 )
150
151
152 def test_msa_cutlass_decode_static_dispatch_requires_sm100(
153 monkeypatch: pytest.MonkeyPatch,
154 ) -> None:
155 monkeypatch.setattr(
156 current_platform,
157 "is_device_capability_family",
158 lambda _: False,
159 )
160 assert not should_prepare_decode_metadata(
161 32,
162 DEFAULT_QUERY_LEN,
163 decode_backend="cutlass",
164 num_q_heads=16,
165 num_kv_heads=1,
166 kv_cache_dtype="fp8",
167 page_size=BLOCK_SIZE,
168 topk_blocks=TOPK,
169 )
170
171
172 @pytest.mark.parametrize("decode_backend", ["cutlass", "triton"])
173 def test_msa_cutlass_decode_static_dispatch_nvfp4_has_no_fallback(
174 decode_backend: str,
175 ) -> None:
176 """NVFP4 has no Triton decode, so it plans CUTLASS for any batch size."""
177 assert should_prepare_decode_metadata(
178 1,
179 DEFAULT_QUERY_LEN,
180 decode_backend=decode_backend, # type: ignore[arg-type]
181 num_q_heads=16,
182 num_kv_heads=1,
183 kv_cache_dtype="nvfp4",
184 page_size=BLOCK_SIZE,
185 topk_blocks=TOPK,
186 )
187
188
189 def test_nvfp4_selects_msa_nvfp4_backend_and_requires_sm100(
190 monkeypatch: pytest.MonkeyPatch,
191 ) -> None:
192 backend_cls, impl_cls = select_main_backend_and_impl_cls(
193 topk_blocks=TOPK,
194 kv_cache_dtype="nvfp4",
195 num_kv_heads=1,
196 )
197 assert backend_cls is MiniMaxM3SparseMSANvfp4Backend
198 assert impl_cls is MiniMaxM3SparseMSAImpl
199 with pytest.raises(ValueError, match="nvfp4_4over6"):
200 select_main_backend_and_impl_cls(
201 topk_blocks=TOPK,
202 kv_cache_dtype="nvfp4_4over6",
203 num_kv_heads=1,
204 )
205
206 monkeypatch.setattr(
207 current_platform,
208 "is_device_capability_family",
209 lambda _: False,
210 )
211 with pytest.raises(ValueError, match="NVFP4"):
212 select_main_backend_and_impl_cls(
213 topk_blocks=TOPK,
214 kv_cache_dtype="nvfp4",
215 num_kv_heads=1,
216 )
217
218
219 @pytest.mark.parametrize(
220 ("backend", "expected"),
221 [
222 (AttentionBackendEnum.CUTLASS_MSA, "cutlass"),
223 (AttentionBackendEnum.TRITON_MSA, "triton"),
224 ],
225 )
226 def test_msa_attention_backend_alias(
227 backend: AttentionBackendEnum,
228 expected: str,
229 ) -> None:
230 config = AttentionConfig(backend=backend)
231 assert config.backend is None
232 assert config.minimax_m3_msa_decode_backend == expected
233
234
235 def test_msa_backend_owns_msa_metadata_builder(
236 monkeypatch: pytest.MonkeyPatch,
237 ) -> None:
238 backend_cls, impl_cls = select_main_backend_and_impl_cls(
239 topk_blocks=TOPK,
240 kv_cache_dtype="fp8_e4m3",
241 num_kv_heads=1,
242 )
243 assert backend_cls is MiniMaxM3SparseMSABackend
244 assert impl_cls is MiniMaxM3SparseMSAImpl
245
246 monkeypatch.setattr(
247 current_platform,
248 "is_device_capability_family",
249 lambda _: False,
250 )
251 backend_cls, impl_cls = select_main_backend_and_impl_cls(
252 topk_blocks=TOPK,
253 kv_cache_dtype="fp8_e4m3",
254 num_kv_heads=1,
255 )
256 assert backend_cls is MiniMaxM3SparseBackend
257 assert impl_cls is MiniMaxM3SparseTritonImpl
258
259
260 def test_msa_metadata_builder_prepares_cutlass_for_regular_decode(
261 monkeypatch: pytest.MonkeyPatch,
262 ) -> None:
263 batch = 16
264 block_table = torch.zeros(batch, 3, dtype=torch.int32, device="cuda")
265 seq_lens = torch.full((batch,), 257, dtype=torch.int32, device="cuda")
266 base_metadata = SimpleNamespace(
267 num_decodes=batch,
268 decode=SimpleNamespace(
269 block_table=block_table,
270 seq_lens=seq_lens,
271 decode_query_len=1,
272 ),
273 )
274 monkeypatch.setattr(
275 MiniMaxM3SparseMetadataBuilder,
276 "build",
277 lambda *args, **kwargs: base_metadata,
278 )
279 expected_metadata = object()
280 monkeypatch.setattr(
281 sparse_attention_msa_module,
282 "prepare_decode_metadata",
283 lambda *args, **kwargs: expected_metadata,
284 )
285
286 builder = object.__new__(MiniMaxM3SparseMSAMetadataBuilder)
287 builder.num_q_heads = 64
288 builder.num_kv_heads = 4
289 builder.topk_blocks = TOPK
290 builder.kv_cache_spec = SimpleNamespace(num_kv_heads=4)
291 builder.kv_cache_dtype = "fp8_e4m3"
292 builder.decode_backend = "cutlass"
293 builder.msa_cutlass_plan_cache = object()
294
295 metadata = builder.build(
296 0,
297 SimpleNamespace(
298 seq_lens_cpu_upper_bound=torch.full((batch,), 257, dtype=torch.int32)
299 ),
300 )
301
302 assert isinstance(metadata.decode, MiniMaxM3SparseMSADecodeMetadata)
303 assert metadata.decode.decode_query_len == 1
304 assert metadata.decode.msa_cutlass is expected_metadata
305
306
307 def test_msa_cutlass_plan_cache_keys_query_len(
308 monkeypatch: pytest.MonkeyPatch,
309 ) -> None:
310 batch = 16
311 block_table = torch.zeros(batch, 3, dtype=torch.int32, device="cuda")
312 seq_lens = torch.full((batch,), 257, dtype=torch.int32, device="cuda")
313 seq_lens_cpu = torch.full((batch,), 257, dtype=torch.int32)
314 plan_cache = MSACutlassDecodePlanCache()
315 built_query_lens: list[int] = []
316
317 def fake_build_plan(**kwargs):
318 query_len = kwargs["decode_query_len"]
319 built_query_lens.append(query_len)
320 num_rows = batch * query_len
321 return (
322 None,
323 None,
324 None,
325 {
326 "kv_segment_lens": torch.empty(
327 num_rows, dtype=torch.int32, device="cuda"
328 ),
329 "qo_offset": torch.empty(num_rows, dtype=torch.int32, device="cuda"),
330 },
331 )
332
333 monkeypatch.setattr(plan_cache, "_build_plan", fake_build_plan)
334 first = prepare_decode_metadata(
335 block_table,
336 seq_lens,
337 seq_lens_cpu,
338 1,
339 num_q_heads=64,
340 num_kv_heads=4,
341 page_size=BLOCK_SIZE,
342 topk_blocks=TOPK,
343 plan_cache=plan_cache,
344 )
345 repeated = prepare_decode_metadata(
346 block_table,
347 seq_lens,
348 seq_lens_cpu,
349 1,
350 num_q_heads=64,
351 num_kv_heads=4,
352 page_size=BLOCK_SIZE,
353 topk_blocks=TOPK,
354 plan_cache=plan_cache,
355 )
356 different = prepare_decode_metadata(
357 block_table,
358 seq_lens,
359 seq_lens_cpu,
360 2,
361 num_q_heads=64,
362 num_kv_heads=4,
363 page_size=BLOCK_SIZE,
364 topk_blocks=TOPK,
365 plan_cache=plan_cache,
366 )
367 current_platform.synchronize()
368
369 assert first.plan is repeated.plan
370 assert different.plan is not first.plan
371 assert built_query_lens == [1, 2]
372
373
374 def test_query_fp8_stays_valid_when_cutlass_plan_appears_on_replay(
375 monkeypatch: pytest.MonkeyPatch,
376 ) -> None:
377 import vllm.envs as envs
378 from vllm.compilation.breakable_cudagraph import (
379 BreakableCUDAGraphCapture,
380 eager_break_during_capture,
381 )
382
383 monkeypatch.setenv("VLLM_USE_BREAKABLE_CUDAGRAPH", "1")
384 envs.disable_envs_cache()
385
386 num_tokens = 16
387 layer_name = "model.layers.0.self_attn.attn"
388 seq_lens = torch.full((num_tokens,), 257, dtype=torch.int32, device="cuda")
389 block_table = torch.zeros(num_tokens, 3, dtype=torch.int32, device="cuda")
390 slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device="cuda")
391 decode = MiniMaxM3SparseMSADecodeMetadata(
392 seq_lens=seq_lens,
393 block_table=block_table,
394 decode_query_len=1,
395 msa_cutlass=None,
396 )
397 metadata = MiniMaxM3SparseMetadata(
398 seq_lens=seq_lens,
399 max_seq_len=257,
400 slot_mapping=slot_mapping,
401 num_actual_tokens=num_tokens,
402 num_decodes=num_tokens,
403 num_decode_tokens=num_tokens,
404 num_prefills=0,
405 num_prefill_tokens=0,
406 decode=decode,
407 )
408 forward_context = ForwardContext(
409 no_compile_layers={},
410 attn_metadata={layer_name: metadata},
411 slot_mapping={},
412 cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE,
413 )
414
415 impl = object.__new__(MiniMaxM3SparseMSAImpl)
416 impl.use_cutlass_decode = True
417 attention = SimpleNamespace(impl=impl, q_size=HEAD_DIM)
418 qkv = torch.empty(num_tokens, HEAD_DIM, device="cuda")
419 observed_ptrs: list[int] = []
420
421 @eager_break_during_capture
422 def run_attention(query_fp8: torch.Tensor | None) -> None:
423 if impl.should_use_msa_decode(layer_name):
424 assert query_fp8 is not None
425 observed_ptrs.append(query_fp8.data_ptr())
426
427 stream = torch.cuda.Stream()
428
429 with torch.cuda.stream(stream), override_forward_context(forward_context):
430 capture = BreakableCUDAGraphCapture()
431 with capture:
432 query_fp8 = MiniMaxM3SparseAttention._allocate_query_fp8(attention, qkv)
433 if query_fp8 is not None:
434 query_fp8.zero_()
435 run_attention(query_fp8)
436 qkv.zero_()
437
438 assert capture.num_graphs == 2
439 assert capture.num_eager_breaks == 1
440 assert observed_ptrs == []
441
442 decode.msa_cutlass = object()
443 for _ in range(3):
444 capture.replay()
445 stream.synchronize()
446
447 assert len(observed_ptrs) == 3
448 assert len(set(observed_ptrs)) == 1
449
450
451 def _make_topk(
452 seq_lens: list[int],
453 num_kv_heads: int,
454 query_len: int,
455 ) -> torch.Tensor:
456 topk = torch.full(
457 (len(seq_lens) * query_len, num_kv_heads, TOPK),
458 -1,
459 dtype=torch.int32,
460 device="cuda",
461 )
462 for request, seq_len in enumerate(seq_lens):
463 for local_query in range(query_len):
464 token = request * query_len + local_query
465 visible_tokens = seq_len - query_len + local_query + 1
466 visible_pages = math.ceil(visible_tokens / BLOCK_SIZE)
467 topk[token, :, :visible_pages] = torch.arange(
468 visible_pages, dtype=torch.int32, device="cuda"
469 )
470 return topk
471
472
473 @pytest.mark.parametrize(
474 (
475 "num_q_heads",
476 "num_kv_heads",
477 "num_request_pairs",
478 "query_len",
479 "capture_graph",
480 ),
481 [
482 pytest.param(64, 4, 8, 1, True, id="tp1-query-len-1"),
483 pytest.param(64, 4, 8, 2, False, id="tp1-query-len-2"),
484 pytest.param(64, 4, 8, 3, False, id="tp1-query-len-3"),
485 pytest.param(64, 4, 8, 4, True, id="tp1-query-len-4"),
486 pytest.param(64, 4, 8, 8, False, id="tp1-query-len-8"),
487 pytest.param(16, 1, 8, 1, True, id="tp4-query-len-1"),
488 pytest.param(16, 1, 16, 4, True, id="tp4-query-len-4"),
489 ],
490 )
491 def test_msa_cutlass_decode_matches_triton_with_interleaved_cache(
492 num_q_heads: int,
493 num_kv_heads: int,
494 num_request_pairs: int,
495 query_len: int,
496 capture_graph: bool,
497 ) -> None:
498 torch.manual_seed(0)
499 seq_lens_list = [257, 513] * num_request_pairs
500 seq_lens_cpu = torch.tensor(seq_lens_list, dtype=torch.int32)
501 seq_lens = seq_lens_cpu.cuda()
502 pages_per_request = [math.ceil(seq_len / BLOCK_SIZE) for seq_len in seq_lens_list]
503 num_pages = sum(pages_per_request)
504 max_pages = max(pages_per_request)
505
506 block_table = torch.zeros(
507 len(seq_lens_list), max_pages, dtype=torch.int32, device="cuda"
508 )
509 physical_pages = torch.randperm(num_pages, dtype=torch.int32, device="cuda")
510 offset = 0
511 for request, request_pages in enumerate(pages_per_request):
512 block_table[request, :request_pages] = physical_pages[
513 offset : offset + request_pages
514 ]
515 offset += request_pages
516
517 key = (
518 torch.randn(
519 num_pages,
520 num_kv_heads,
521 BLOCK_SIZE,
522 HEAD_DIM,
523 dtype=torch.bfloat16,
524 device="cuda",
525 )
526 * 0.25
527 ).to(torch.float8_e4m3fn)
528 value = (torch.randn_like(key, dtype=torch.bfloat16) * 0.25).to(torch.float8_e4m3fn)
529 kv_cache = torch.cat((key, value), dim=-1)
530 assert kv_cache.stride() == (
531 num_kv_heads * BLOCK_SIZE * 2 * HEAD_DIM,
532 BLOCK_SIZE * 2 * HEAD_DIM,
533 2 * HEAD_DIM,
534 1,
535 )
536
537 num_query_tokens = len(seq_lens_list) * query_len
538 query = torch.randn(
539 num_query_tokens,
540 num_q_heads,
541 HEAD_DIM,
542 dtype=torch.bfloat16,
543 device="cuda",
544 )
545 q_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
546 query_fp8 = torch.empty_like(query, dtype=torch.float8_e4m3fn)
547 ops.scaled_fp8_quant(
548 query.view(num_query_tokens, -1),
549 scale=q_scale,
550 output=query_fp8.view(num_query_tokens, -1),
551 )
552 query_dequantized = query_fp8.to(torch.bfloat16) * q_scale
553
554 topk_token_major = _make_topk(seq_lens_list, num_kv_heads, query_len)
555 expected = torch.empty_like(query)
556 minimax_m3_sparse_attn_decode(
557 query_dequantized,
558 kv_cache,
559 topk_token_major.transpose(0, 1),
560 block_table,
561 seq_lens,
562 num_kv_heads,
563 SM_SCALE,
564 expected,
565 query_len,
566 k_scale=None,
567 v_scale=None,
568 )
569
570 plan_cache = MSACutlassDecodePlanCache()
571 metadata = prepare_decode_metadata(
572 block_table,
573 seq_lens,
574 seq_lens_cpu,
575 query_len,
576 num_q_heads=num_q_heads,
577 num_kv_heads=num_kv_heads,
578 page_size=BLOCK_SIZE,
579 topk_blocks=TOPK,
580 plan_cache=plan_cache,
581 )
582 assert metadata.page_table.data_ptr() == block_table.data_ptr()
583 actual = torch.empty_like(query)
584 msa_cutlass_sparse_decode(
585 query_fp8,
586 kv_cache,
587 topk_token_major,
588 actual,
589 metadata,
590 scale=SM_SCALE,
591 q_scale_float=1.0,
592 k_scale_float=1.0,
593 v_scale_float=1.0,
594 )
595
596 torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
597
598 # The same captured plan must remain correct as ragged lengths change.
599 updated_seq_lens_list = [129, 385] * num_request_pairs
600 seq_lens.copy_(
601 torch.tensor(updated_seq_lens_list, dtype=torch.int32, device="cuda")
602 )
603 updated_seq_lens_cpu = torch.tensor(updated_seq_lens_list, dtype=torch.int32)
604 topk_token_major.copy_(_make_topk(updated_seq_lens_list, num_kv_heads, query_len))
605 updated_metadata = prepare_decode_metadata(
606 block_table,
607 seq_lens,
608 updated_seq_lens_cpu,
609 query_len,
610 num_q_heads=num_q_heads,
611 num_kv_heads=num_kv_heads,
612 page_size=BLOCK_SIZE,
613 topk_blocks=TOPK,
614 plan_cache=plan_cache,
615 )
616 assert updated_metadata.plan is metadata.plan
617 assert updated_metadata.page_table.data_ptr() == metadata.page_table.data_ptr()
618
619 minimax_m3_sparse_attn_decode(
620 query_dequantized,
621 kv_cache,
622 topk_token_major.transpose(0, 1),
623 block_table,
624 seq_lens,
625 num_kv_heads,
626 SM_SCALE,
627 expected,
628 query_len,
629 k_scale=None,
630 v_scale=None,
631 )
632 msa_cutlass_sparse_decode(
633 query_fp8,
634 kv_cache,
635 topk_token_major,
636 actual,
637 updated_metadata,
638 scale=SM_SCALE,
639 q_scale_float=1.0,
640 k_scale_float=1.0,
641 v_scale_float=1.0,
642 )
643 torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
644
645 if capture_graph:
646 graph = torch.cuda.CUDAGraph()
647 with torch.cuda.graph(graph):
648 msa_cutlass_sparse_decode(
649 query_fp8,
650 kv_cache,
651 topk_token_major,
652 actual,
653 updated_metadata,
654 scale=SM_SCALE,
655 q_scale_float=1.0,
656 k_scale_float=1.0,
657 v_scale_float=1.0,
658 )
659
660 replay_seq_lens_list = [257, 513] * num_request_pairs
661 seq_lens.copy_(
662 torch.tensor(replay_seq_lens_list, dtype=torch.int32, device="cuda")
663 )
664 topk_token_major.copy_(
665 _make_topk(replay_seq_lens_list, num_kv_heads, query_len)
666 )
667 prepare_decode_metadata(
668 block_table,
669 seq_lens,
670 torch.tensor(replay_seq_lens_list, dtype=torch.int32),
671 query_len,
672 num_q_heads=num_q_heads,
673 num_kv_heads=num_kv_heads,
674 page_size=BLOCK_SIZE,
675 topk_blocks=TOPK,
676 plan_cache=plan_cache,
677 )
678 minimax_m3_sparse_attn_decode(
679 query_dequantized,
680 kv_cache,
681 topk_token_major.transpose(0, 1),
682 block_table,
683 seq_lens,
684 num_kv_heads,
685 SM_SCALE,
686 expected,
687 query_len,
688 k_scale=None,
689 v_scale=None,
690 )
691 graph.replay()
692 current_platform.synchronize()
693 torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
694
695
696 def _make_paged_layout(
697 seq_lens_list: list[int],
698 ) -> tuple[torch.Tensor, int]:
699 """Block table over a shuffled physical page pool."""
700 pages_per_request = [math.ceil(seq_len / BLOCK_SIZE) for seq_len in seq_lens_list]
701 num_pages = sum(pages_per_request)
702 block_table = torch.zeros(
703 len(seq_lens_list), max(pages_per_request), dtype=torch.int32, device="cuda"
704 )
705 physical_pages = torch.randperm(num_pages, dtype=torch.int32, device="cuda")
706 offset = 0
707 for request, request_pages in enumerate(pages_per_request):
708 block_table[request, :request_pages] = physical_pages[
709 offset : offset + request_pages
710 ]
711 offset += request_pages
712 return block_table, num_pages
713
714
715 def _make_nvfp4_kv_cache(
716 num_pages: int,
717 num_kv_heads: int,
718 kv_cache_dtype: str,
719 k_scale: torch.Tensor,
720 v_scale: torch.Tensor,
721 ) -> tuple[torch.Tensor, torch.Tensor]:
722 """Write random K/V through the NVFP4 ``reshape_and_cache_flash`` writer,
723 whose pages hold every head's data then every head's scales per side, and
724 repack them into head slots.
725
726 Returns the packed HND cache ``[pages, 2 * Hkv, 128, 72]`` and its BF16
727 dequantization ``[pages, Hkv, 128, 2 * 128]`` for the Triton reference.
728 """
729 num_slots = num_pages * BLOCK_SIZE
730 key = torch.randn(
731 num_slots, num_kv_heads, HEAD_DIM, dtype=torch.bfloat16, device="cuda"
732 )
733 value = torch.randn_like(key)
734 written = torch.zeros(
735 num_pages,
736 2 * num_kv_heads,
737 BLOCK_SIZE,
738 HEAD_DIM // 2 + HEAD_DIM // 16,
739 dtype=torch.uint8,
740 device="cuda",
741 )
742 k_cache, v_cache = written.transpose(1, 2).split(num_kv_heads, dim=-2)
743 ops.reshape_and_cache_flash(
744 key,
745 value,
746 k_cache,
747 v_cache,
748 torch.arange(num_slots, dtype=torch.int64, device="cuda"),
749 kv_cache_dtype,
750 k_scale,
751 v_scale,
752 )
753
754 kv_cache = torch.empty_like(written)
755 head_slot_views = iter(nvfp4_kv_cache_views(kv_cache))
756 dequantized = []
757 for side, global_scale, swizzled in (
758 (written[:, :num_kv_heads], k_scale, False),
759 (written[:, num_kv_heads:], v_scale, True),
760 ):
761 data = side.as_strided(
762 (num_pages, num_kv_heads, BLOCK_SIZE, HEAD_DIM // 2),
763 (side.stride(0), BLOCK_SIZE * HEAD_DIM // 2, HEAD_DIM // 2, 1),
764 )
765 scale = side.as_strided(
766 (num_pages, num_kv_heads, BLOCK_SIZE, HEAD_DIM // 16),
767 (side.stride(0), BLOCK_SIZE * HEAD_DIM // 16, HEAD_DIM // 16, 1),
768 side.storage_offset() + num_kv_heads * BLOCK_SIZE * HEAD_DIM // 2,
769 )
770 next(head_slot_views).copy_(data)
771 next(head_slot_views).copy_(scale)
772 dequantized.append(
773 dequant_nvfp4_kv_cache(
774 data,
775 scale,
776 global_scale.item(),
777 HEAD_DIM,
778 BLOCK_SIZE,
779 swizzled_scales=swizzled,
780 )
781 )
782 return kv_cache, torch.cat(dequantized, dim=-1).to(torch.bfloat16)
783
784
785 def test_nvfp4_kv_cache_views_address_head_slots() -> None:
786 """Slot 2h (K) and 2h + 1 (V) hold head h's data then scales, so each head
787 is one contiguous run of the page: a TP rank's page is a byte slice."""
788 num_kv_heads, slot_bytes = 4, BLOCK_SIZE * (HEAD_DIM // 2 + HEAD_DIM // 16)
789 kv_cache = torch.randint(
790 0,
791 256,
792 (3, 2 * num_kv_heads, BLOCK_SIZE, HEAD_DIM // 2 + HEAD_DIM // 16),
793 dtype=torch.uint8,
794 )
795 k_data, k_sf, v_data, v_sf = nvfp4_kv_cache_views(kv_cache)
796 runs = kv_cache.view(3, num_kv_heads, 2 * slot_bytes)
797 for head in range(num_kv_heads):
798 expected = torch.cat(
799 [v[:, head].flatten(1) for v in (k_data, k_sf, v_data, v_sf)], dim=1
800 )
801 assert torch.equal(runs[:, head], expected)
802
803
804 @pytest.mark.parametrize(
805 ("num_q_heads", "num_kv_heads", "num_request_pairs", "query_len", "capture"),
806 [
807 pytest.param(64, 4, 1, 1, True, id="tp1-small-batch-query-len-1"),
808 pytest.param(64, 4, 8, 8, False, id="tp1-query-len-8"),
809 pytest.param(16, 1, 16, 4, True, id="tp4-query-len-4"),
810 ],
811 )
812 def test_msa_cutlass_decode_nvfp4_matches_triton_on_dequantized_cache(
813 num_q_heads: int,
814 num_kv_heads: int,
815 num_request_pairs: int,
816 query_len: int,
817 capture: bool,
818 ) -> None:
819 """CUTLASS decode reads vLLM's packed NVFP4 pages, including swizzled V
820 scales and device global scales, as their exact dequantized values."""
821 torch.manual_seed(0)
822 seq_lens_list = [257, 513] * num_request_pairs
823 seq_lens_cpu = torch.tensor(seq_lens_list, dtype=torch.int32)
824 seq_lens = seq_lens_cpu.cuda()
825 block_table, num_pages = _make_paged_layout(seq_lens_list)
826 k_scale = torch.tensor(0.5, dtype=torch.float32, device="cuda")
827 v_scale = torch.tensor(2.0, dtype=torch.float32, device="cuda")
828 kv_cache, kv_reference = _make_nvfp4_kv_cache(
829 num_pages, num_kv_heads, "nvfp4", k_scale, v_scale
830 )
831
832 num_query_tokens = len(seq_lens_list) * query_len
833 query = torch.randn(
834 num_query_tokens, num_q_heads, HEAD_DIM, dtype=torch.bfloat16, device="cuda"
835 )
836 query_fp8 = query.to(torch.float8_e4m3fn)
837 topk = _make_topk(seq_lens_list, num_kv_heads, query_len)
838 expected = torch.empty_like(query)
839 minimax_m3_sparse_attn_decode(
840 query_fp8.to(torch.bfloat16),
841 kv_reference,
842 topk.transpose(0, 1),
843 block_table,
844 seq_lens,
845 num_kv_heads,
846 SM_SCALE,
847 expected,
848 query_len,
849 )
850
851 assert should_prepare_decode_metadata(
852 len(seq_lens_list),
853 query_len,
854 decode_backend="triton",
855 num_q_heads=num_q_heads,
856 num_kv_heads=num_kv_heads,
857 kv_cache_dtype="nvfp4",
858 page_size=BLOCK_SIZE,
859 topk_blocks=TOPK,
860 )
861 metadata = prepare_decode_metadata(
862 block_table,
863 seq_lens,
864 seq_lens_cpu,
865 query_len,
866 num_q_heads=num_q_heads,
867 num_kv_heads=num_kv_heads,
868 page_size=BLOCK_SIZE,
869 topk_blocks=TOPK,
870 )
871 actual = torch.zeros_like(query)
872
873 def run() -> None:
874 msa_cutlass_sparse_decode(
875 query_fp8,
876 kv_cache,
877 topk,
878 actual,
879 metadata,
880 scale=SM_SCALE,
881 q_scale_float=1.0,
882 k_scale_float=1.0,
883 v_scale_float=1.0,
884 k_scale=k_scale,
885 v_scale=v_scale,
886 )
887
888 if capture:
889 run()
890 graph = torch.cuda.CUDAGraph()
891 with torch.cuda.graph(graph):
892 run()
893 actual.zero_()
894 graph.replay()
895 else:
896 run()
897 current_platform.synchronize()
898 torch.testing.assert_close(actual, expected, atol=0.05, rtol=0.05)
899
900
901 @pytest.mark.parametrize(
902 ("num_q_heads", "num_kv_heads"),
903 [pytest.param(64, 4, id="tp1"), pytest.param(16, 1, id="tp4")],
904 )
905 def test_msa_prefill_nvfp4_matches_triton_on_dequantized_cache(
906 num_q_heads: int,
907 num_kv_heads: int,
908 ) -> None:
909 """MSA prefill reads NVFP4 pages as their exact dequantized values,
910 including chunked prefill over cached context, and takes q from its fp8
911 copy (the only one the fused insert writes on this path)."""
912 torch.manual_seed(0)
913 q_lens = [300, 128, 77]
914 seq_lens_list = [300, 640, 1100]
915 block_table, num_pages = _make_paged_layout(seq_lens_list)
916 k_scale = torch.tensor(0.5, dtype=torch.float32, device="cuda")
917 v_scale = torch.tensor(2.0, dtype=torch.float32, device="cuda")
918 kv_cache, kv_reference = _make_nvfp4_kv_cache(
919 num_pages, num_kv_heads, "nvfp4", k_scale, v_scale
920 )
921
922 total_q = sum(q_lens)
923 seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device="cuda")
924 context_lens = seq_lens - torch.tensor(q_lens, dtype=torch.int32, device="cuda")
925 cu_seqlens_q = torch.zeros(len(q_lens) + 1, dtype=torch.int32, device="cuda")
926 cu_seqlens_q[1:] = torch.tensor(q_lens, device="cuda").cumsum(0)
927 cu_seqlens_k = torch.zeros_like(cu_seqlens_q)
928 cu_seqlens_k[1:] = seq_lens.cumsum(0)
929 prefill = MiniMaxM3SparsePrefillMetadata(
930 cu_seqlens_q=cu_seqlens_q,
931 cu_seqlens_k=cu_seqlens_k,
932 seq_lens=seq_lens,
933 context_lens=context_lens,
934 block_table=block_table,
935 max_query_len=max(q_lens),
936 max_seq_len=max(seq_lens_list),
937 total_kv_blocks=sum(math.ceil(n / BLOCK_SIZE) for n in seq_lens_list),
938 )
939 metadata = MiniMaxM3SparseMetadata(
940 seq_lens=seq_lens,
941 max_seq_len=max(seq_lens_list),
942 slot_mapping=torch.empty(total_q, dtype=torch.int64, device="cuda"),
943 num_actual_tokens=total_q,
944 num_decodes=0,
945 num_decode_tokens=0,
946 num_prefills=len(q_lens),
947 num_prefill_tokens=total_q,
948 prefill=prefill,
949 )
950
951 # Every causally visible page (<= TOPK per query), ending with the local one.
952 topk = torch.full(
953 (total_q, num_kv_heads, TOPK), -1, dtype=torch.int32, device="cuda"
954 )
955 token = 0
956 for q_len, seq_len in zip(q_lens, seq_lens_list):
957 for local_q in range(q_len):
958 visible = (seq_len - q_len + local_q) // BLOCK_SIZE + 1
959 assert visible <= TOPK
960 topk[token, :, :visible] = torch.arange(visible, device="cuda")
961 token += 1
962
963 layer_name = "model.layers.0.self_attn.attn"
964 q_scale = 0.5
965 layer = SimpleNamespace(
966 layer_name=layer_name,
967 topk_indices_buffer=topk,
968 _q_scale_float=q_scale,
969 _k_scale=k_scale,
970 _v_scale=v_scale,
971 )
972 query_fp8 = (
973 torch.randn(
974 total_q, num_q_heads * HEAD_DIM, dtype=torch.bfloat16, device="cuda"
975 )
976 / q_scale
977 ).to(torch.float8_e4m3fn)
978 query = query_fp8.to(torch.bfloat16) * q_scale
979 common = dict(
980 num_heads=num_q_heads,
981 head_size=HEAD_DIM,
982 scale=SM_SCALE,
983 num_kv_heads=num_kv_heads,
984 topk_blocks=TOPK,
985 sparse_block_size=BLOCK_SIZE,
986 )
987 forward_context = ForwardContext(
988 no_compile_layers={},
989 attn_metadata={layer_name: metadata},
990 slot_mapping={},
991 )
992 with override_forward_context(forward_context):
993 expected = MiniMaxM3SparseTritonImpl(**common).forward(
994 layer, query, kv_reference, torch.empty_like(query)
995 )
996 actual = MiniMaxM3SparseMSAImpl(**common, kv_cache_dtype="nvfp4").forward(
997 layer,
998 torch.full_like(query, float("nan")),
999 kv_cache,
1000 torch.zeros_like(query),
1001 query_fp8=query_fp8,
1002 )
1003 current_platform.synchronize()
1004 torch.testing.assert_close(actual, expected, atol=0.05, rtol=0.05)
1005
1006
1007 def test_msa_triton_decode_fallback_reads_dequantized_query_fp8() -> None:
1008 """With CUTLASS decode on but not planned for the batch, the Triton decode
1009 fallback attends with the dequantized fp8 q, the only q written."""
1010 torch.manual_seed(0)
1011 num_q_heads, num_kv_heads, q_scale = 16, 1, 0.5
1012 seq_lens_list = [257, 513, 129]
1013 seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device="cuda")
1014 block_table, num_pages = _make_paged_layout(seq_lens_list)
1015 kv_cache = (
1016 torch.randn(
1017 num_pages,
1018 num_kv_heads,
1019 BLOCK_SIZE,
1020 2 * HEAD_DIM,
1021 dtype=torch.bfloat16,
1022 device="cuda",
1023 )
1024 * 0.25
1025 ).to(torch.float8_e4m3fn)
1026 num_tokens = len(seq_lens_list)
1027 metadata = MiniMaxM3SparseMetadata(
1028 seq_lens=seq_lens,
1029 max_seq_len=max(seq_lens_list),
1030 slot_mapping=torch.empty(num_tokens, dtype=torch.int64, device="cuda"),
1031 num_actual_tokens=num_tokens,
1032 num_decodes=num_tokens,
1033 num_decode_tokens=num_tokens,
1034 num_prefills=0,
1035 num_prefill_tokens=0,
1036 decode=MiniMaxM3SparseMSADecodeMetadata(
1037 seq_lens=seq_lens,
1038 block_table=block_table,
1039 decode_query_len=1,
1040 msa_cutlass=None,
1041 ),
1042 )
1043 layer_name = "model.layers.0.self_attn.attn"
1044 layer = SimpleNamespace(
1045 layer_name=layer_name,
1046 topk_indices_buffer=_make_topk(seq_lens_list, num_kv_heads, 1),
1047 _q_scale_float=q_scale,
1048 )
1049 query_fp8 = torch.randn(
1050 num_tokens, num_q_heads * HEAD_DIM, dtype=torch.bfloat16, device="cuda"
1051 ).to(torch.float8_e4m3fn)
1052 query = query_fp8.to(torch.bfloat16) * q_scale
1053 common = dict(
1054 num_heads=num_q_heads,
1055 head_size=HEAD_DIM,
1056 scale=SM_SCALE,
1057 num_kv_heads=num_kv_heads,
1058 kv_cache_dtype="fp8",
1059 topk_blocks=TOPK,
1060 sparse_block_size=BLOCK_SIZE,
1061 )
1062 impl = MiniMaxM3SparseMSAImpl(**common, msa_decode_backend="cutlass")
1063 assert impl.use_cutlass_decode
1064 forward_context = ForwardContext(
1065 no_compile_layers={},
1066 attn_metadata={layer_name: metadata},
1067 slot_mapping={},
1068 )
1069 with override_forward_context(forward_context):
1070 expected = MiniMaxM3SparseTritonImpl(**common).forward(
1071 layer, query, kv_cache, torch.empty_like(query)
1072 )
1073 actual = impl.forward(
1074 layer,
1075 torch.full_like(query, float("nan")),
1076 kv_cache,
1077 torch.zeros_like(query),
1078 query_fp8=query_fp8,
1079 )
1080 current_platform.synchronize()
1081 torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)