[Bugfix] Sparse MLA: enable fp8_ds_mla dense prefill (#48642)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Matthew Bonanni committed
Jul 16, 2026 at 18:44 UTC
67f9046e4a12d939ffad0abc917585a00ef5b42d
13 files changed
+516
-85
benchmarks/kernels/bench_cp_gather_fp8.py
+3
-4
@@ -69,12 +69,11 @@ def make_inputs(total_tokens, num_reqs, block_size):
69
# Output workspace
70
dst = torch.zeros(total_tokens, HEAD_DIM, dtype=torch.bfloat16, device="cuda")
71
72
- seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device="cuda")
72
workspace_starts_t = torch.tensor(
73
workspace_starts, dtype=torch.int32, device="cuda"
74
)
75
77
- return cache, dst, block_table, seq_lens_t, workspace_starts_t
76
+ return cache, dst, block_table, workspace_starts_t
77
78
79
def bench_scenario(label, num_reqs, total_tokens_list, save_path):
@@ -94,7 +93,7 @@ def bench_scenario(label, num_reqs, total_tokens_list, save_path):
93
)
94
)
95
def bench_fn(total_tokens, provider, num_reqs):
97
- cache, dst, block_table, seq_lens_t, ws_starts = make_inputs(
96
+ cache, dst, block_table, ws_starts = make_inputs(
97
total_tokens, num_reqs, BLOCK_SIZE
98
)
99
@@ -102,7 +101,7 @@ def bench_scenario(label, num_reqs, total_tokens_list, save_path):
101
102
ms, min_ms, max_ms = triton.testing.do_bench_cudagraph(
103
lambda: ops.cp_gather_and_upconvert_fp8_kv_cache(
105
- cache, dst, block_table, seq_lens_t, ws_starts, num_reqs
104
+ cache, dst, block_table, ws_starts, num_reqs
105
),
106
quantiles=quantiles,
107
rep=500,
csrc/cache.h
+1
-2
@@ -67,9 +67,8 @@ void cp_gather_and_upconvert_fp8_kv_cache(
67
torch::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 656]
68
torch::Tensor const& dst, // [TOT_TOKENS, 576]
69
torch::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
70
- torch::Tensor const& seq_lens, // [BATCH]
70
torch::Tensor const& workspace_starts, // [BATCH]
72
- int64_t batch_size);
71
+ int64_t batch_size, std::optional<torch::Tensor> seq_starts = std::nullopt);
72
73
// Indexer K quantization and cache function
74
void indexer_k_quant_and_cache(
csrc/libtorch_stable/cache_kernels.cu
+19
-9
@@ -1174,7 +1174,8 @@ __global__ void cp_gather_and_upconvert_fp8_kv_cache(
1174
const int32_t num_reqs, const int32_t block_size,
1175
const int32_t total_tokens, const int64_t block_table_stride,
1176
const int64_t cache_block_stride, const int64_t cache_entry_stride,
1177
- const int64_t dst_entry_stride) {
1177
+ const int64_t dst_entry_stride,
1178
+ const int32_t* __restrict__ seq_starts) { // Optional source offsets
1179
const int flat_warp_id = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
1180
if (flat_warp_id >= total_tokens) return;
1181
const int lane_id = threadIdx.x & 31;
@@ -1192,7 +1193,8 @@ __global__ void cp_gather_and_upconvert_fp8_kv_cache(
1193
1194
// Compute physical token address via block table
1195
const int out_token_id = flat_warp_id;
1195
- const int token_offset = out_token_id - workspace_starts[req_id];
1196
+ int token_offset = out_token_id - workspace_starts[req_id];
1197
+ if (seq_starts != nullptr) token_offset += seq_starts[req_id];
1198
const int cache_block_idx = token_offset / block_size;
1199
const int offset_in_block = token_offset % block_size;
1200
const int physical_block =
@@ -1383,9 +1385,9 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1385
torch::stable::Tensor const& src_cache, // [NUM_BLOCKS, BLOCK_SIZE, 656]
1386
torch::stable::Tensor const& dst, // [TOT_TOKENS, 576]
1387
torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
1386
- torch::stable::Tensor const& seq_lens, // [BATCH]
1388
torch::stable::Tensor const& workspace_starts, // [BATCH]
1388
- int64_t batch_size) {
1389
+ int64_t batch_size,
1390
+ std::optional<torch::stable::Tensor> seq_starts = std::nullopt) {
1391
torch::stable::accelerator::DeviceGuard device_guard(
1392
src_cache.get_device_index());
1393
const cudaStream_t stream = get_current_cuda_stream();
@@ -1396,20 +1398,25 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1398
STD_TORCH_CHECK(
1399
block_table.scalar_type() == torch::headeronly::ScalarType::Int,
1400
"block_table must be int32");
1399
- STD_TORCH_CHECK(seq_lens.scalar_type() == torch::headeronly::ScalarType::Int,
1400
- "seq_lens must be int32");
1401
STD_TORCH_CHECK(
1402
workspace_starts.scalar_type() == torch::headeronly::ScalarType::Int,
1403
"workspace_starts must be int32");
1404
+ if (seq_starts.has_value()) {
1405
+ STD_TORCH_CHECK(
1406
+ seq_starts.value().scalar_type() == torch::headeronly::ScalarType::Int,
1407
+ "seq_starts must be int32");
1408
+ }
1409
1410
STD_TORCH_CHECK(src_cache.device() == dst.device(),
1411
"src_cache and dst must be on the same device");
1412
STD_TORCH_CHECK(src_cache.device() == block_table.device(),
1413
"src_cache and block_table must be on the same device");
1409
- STD_TORCH_CHECK(src_cache.device() == seq_lens.device(),
1410
- "src_cache and seq_lens must be on the same device");
1414
STD_TORCH_CHECK(src_cache.device() == workspace_starts.device(),
1415
"src_cache and workspace_starts must be on the same device");
1416
+ if (seq_starts.has_value()) {
1417
+ STD_TORCH_CHECK(src_cache.device() == seq_starts.value().device(),
1418
+ "src_cache and seq_starts must be on the same device");
1419
+ }
1420
auto dtype = src_cache.scalar_type();
1421
STD_TORCH_CHECK(
1422
dtype == torch::headeronly::ScalarType::Byte || // uint8
@@ -1438,6 +1445,9 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1445
constexpr int warps_per_block = 8;
1446
const int grid_size = (total_tokens + warps_per_block - 1) / warps_per_block;
1447
const int block_size_threads = warps_per_block * 32; // 256 threads
1448
+ const int32_t* seq_starts_ptr =
1449
+ seq_starts.has_value() ? seq_starts.value().const_data_ptr<int32_t>()
1450
+ : nullptr;
1451
1452
vllm::cp_gather_and_upconvert_fp8_kv_cache<<<grid_size, block_size_threads, 0,
1453
stream>>>(
@@ -1446,7 +1456,7 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1456
workspace_starts.const_data_ptr<int32_t>(),
1457
static_cast<int32_t>(batch_size), block_size, total_tokens,
1458
block_table_stride, cache_block_stride, cache_entry_stride,
1449
- dst_entry_stride);
1459
+ dst_entry_stride, seq_starts_ptr);
1460
}
1461
1462
// Macro to dispatch the kernel based on the data type.
csrc/libtorch_stable/ops.h
+2
-2
@@ -527,9 +527,9 @@ void cp_gather_and_upconvert_fp8_kv_cache(
527
// 656]
528
torch::stable::Tensor const& dst, // [TOT_TOKENS, 576]
529
torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
530
- torch::stable::Tensor const& seq_lens, // [BATCH]
530
torch::stable::Tensor const& workspace_starts, // [BATCH]
532
- int64_t batch_size);
531
+ int64_t batch_size,
532
+ std::optional<torch::stable::Tensor> seq_starts = std::nullopt);
533
534
// Indexer K quantization and cache function
535
void indexer_k_quant_and_cache(
csrc/libtorch_stable/torch_bindings.cpp
+2
-2
@@ -847,8 +847,8 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C_cache_ops, ops) {
847
848
ops.def(
849
"cp_gather_and_upconvert_fp8_kv_cache(Tensor src_cache, Tensor! dst, "
850
- "Tensor block_table, Tensor seq_lens, Tensor workspace_starts, int "
851
- "batch_size) -> ()");
850
+ "Tensor block_table, Tensor workspace_starts, int batch_size, Tensor? "
851
+ "seq_starts) -> ()");
852
853
ops.def(
854
"indexer_k_quant_and_cache(Tensor k, Tensor! kv_cache, Tensor "
tests/kernels/test_cp_gather_fp8.py
+63
-13
@@ -27,8 +27,8 @@ def _build_test_case(seq_lens, block_size, seed=42):
27
seed: Random seed for reproducibility.
28
29
Returns:
30
- Tuple of (cache, block_table, seq_lens_t, workspace_starts_t,
31
- num_reqs, total_tokens, expected_output).
30
+ Tuple of (cache, block_table, workspace_starts_t, num_reqs,
31
+ total_tokens, expected_output).
32
"""
33
torch.manual_seed(seed)
34
@@ -112,7 +112,6 @@ def _build_test_case(seq_lens, block_size, seed=42):
112
# Expected output: exact copy
113
expected[out_idx, NOPE_DIM:] = rope
114
115
- seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device="cuda")
115
workspace_starts_t = torch.tensor(
116
workspace_starts, dtype=torch.int32, device="cuda"
117
)
@@ -120,7 +119,6 @@ def _build_test_case(seq_lens, block_size, seed=42):
119
return (
120
cache,
121
block_table,
123
- seq_lens_t,
122
workspace_starts_t,
123
num_reqs,
124
total_tokens,
@@ -197,7 +195,6 @@ def _build_test_case_fast(seq_lens, block_size, seed=42):
195
flat_cache[:sl] = token_data[ws : ws + sl]
196
block_start += nb
197
200
- seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device="cuda")
198
workspace_starts_t = torch.tensor(
199
workspace_starts, dtype=torch.int32, device="cuda"
200
)
@@ -205,7 +202,6 @@ def _build_test_case_fast(seq_lens, block_size, seed=42):
202
return (
203
cache,
204
block_table,
208
- seq_lens_t,
205
workspace_starts_t,
206
num_reqs,
207
total_tokens,
@@ -232,7 +228,6 @@ def test_cp_gather_and_upconvert_fp8_kv_cache(seq_lens, block_size):
228
(
229
cache,
230
block_table,
235
- seq_lens_t,
231
workspace_starts_t,
232
num_reqs,
233
total_tokens,
@@ -244,7 +239,7 @@ def test_cp_gather_and_upconvert_fp8_kv_cache(seq_lens, block_size):
239
)
240
241
ops.cp_gather_and_upconvert_fp8_kv_cache(
247
- cache, dst, block_table, seq_lens_t, workspace_starts_t, num_reqs
242
+ cache, dst, block_table, workspace_starts_t, num_reqs
243
)
244
245
# NoPE: fp8 dequant has rounding error, so we allow small tolerance.
@@ -277,8 +272,6 @@ def test_cp_gather_fp8_shuffled_blocks():
272
)
273
block_table = torch.tensor([[3, 1]], dtype=torch.int32, device="cuda")
274
workspace_starts = torch.tensor([0], dtype=torch.int32, device="cuda")
280
- seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32, device="cuda")
281
-
275
expected = torch.zeros(
276
total_tokens, NOPE_DIM + ROPE_DIM, dtype=torch.bfloat16, device="cuda"
277
)
@@ -314,7 +307,7 @@ def test_cp_gather_fp8_shuffled_blocks():
307
)
308
309
ops.cp_gather_and_upconvert_fp8_kv_cache(
317
- cache, dst, block_table, seq_lens_t, workspace_starts, len(seq_lens)
310
+ cache, dst, block_table, workspace_starts, len(seq_lens)
311
)
312
313
torch.testing.assert_close(
@@ -323,6 +316,64 @@ def test_cp_gather_fp8_shuffled_blocks():
316
assert torch.equal(dst[:, NOPE_DIM:], expected[:, NOPE_DIM:])
317
318
319
+@pytest.mark.parametrize(
320
+ "gather_seq_lens,seq_starts",
321
+ [
322
+ ([6, 5, 3], [3, 4, 5]),
323
+ ([0, 5, 3], [12, 4, 5]),
324
+ ],
325
+)
326
+def test_cp_gather_fp8_with_sequence_starts(gather_seq_lens, seq_starts):
327
+ """Gather request slices beginning at arbitrary cache positions."""
328
+ full_seq_lens = [12, 11, 9]
329
+ (
330
+ cache,
331
+ block_table,
332
+ _workspace_starts_t,
333
+ num_reqs,
334
+ _total_tokens,
335
+ full_expected,
336
+ ) = _build_test_case(full_seq_lens, block_size=4)
337
+
338
+ workspace_starts = torch.tensor(
339
+ [0, gather_seq_lens[0], sum(gather_seq_lens[:2])],
340
+ dtype=torch.int32,
341
+ device="cuda",
342
+ )
343
+ seq_starts_t = torch.tensor(seq_starts, dtype=torch.int32, device="cuda")
344
+ dst = torch.empty(
345
+ sum(gather_seq_lens),
346
+ NOPE_DIM + ROPE_DIM,
347
+ dtype=torch.bfloat16,
348
+ device="cuda",
349
+ )
350
+
351
+ ops.cp_gather_and_upconvert_fp8_kv_cache(
352
+ cache,
353
+ dst,
354
+ block_table,
355
+ workspace_starts,
356
+ num_reqs,
357
+ seq_starts_t,
358
+ )
359
+
360
+ full_workspace_starts = [0, full_seq_lens[0], sum(full_seq_lens[:2])]
361
+ expected = torch.cat(
362
+ [
363
+ full_expected[
364
+ full_workspace_starts[i] + seq_starts[i] : full_workspace_starts[i]
365
+ + seq_starts[i]
366
+ + gather_seq_lens[i]
367
+ ]
368
+ for i in range(num_reqs)
369
+ ]
370
+ )
371
+ torch.testing.assert_close(
372
+ dst[:, :NOPE_DIM], expected[:, :NOPE_DIM], atol=1e-3, rtol=1e-2
373
+ )
374
+ assert torch.equal(dst[:, NOPE_DIM:], expected[:, NOPE_DIM:])
375
+
376
+
377
@pytest.mark.parametrize(
378
"seq_lens,block_size",
379
[
@@ -342,7 +393,6 @@ def test_cp_gather_fp8_large_seqlens(seq_lens, block_size):
393
(
394
cache,
395
block_table,
345
- seq_lens_t,
396
workspace_starts_t,
397
num_reqs,
398
total_tokens,
@@ -354,7 +404,7 @@ def test_cp_gather_fp8_large_seqlens(seq_lens, block_size):
404
)
405
406
ops.cp_gather_and_upconvert_fp8_kv_cache(
357
- cache, dst, block_table, seq_lens_t, workspace_starts_t, num_reqs
407
+ cache, dst, block_table, workspace_starts_t, num_reqs
408
)
409
410
torch.testing.assert_close(
tests/v1/attention/test_mla_backends.py
+29
-1
@@ -42,7 +42,10 @@ from vllm.v1.attention.backends.mla.prefill import (
42
)
43
from vllm.v1.attention.backends.registry import AttentionBackendEnum
44
from vllm.v1.attention.ops.flashmla import is_flashmla_dense_supported
45
-from vllm.v1.kv_cache_interface import MLAAttentionSpec
45
+from vllm.v1.kv_cache_interface import (
46
+ KVQuantMode,
47
+ MLAAttentionSpec,
48
+)
49
50
BACKENDS_TO_TEST = [
51
AttentionBackendEnum.CUTLASS_MLA,
@@ -55,6 +58,31 @@ BACKENDS_TO_TEST = [
58
59
DEVICE_TYPE = current_platform.device_type
60
61
+
62
+@pytest.mark.parametrize(
63
+ ("cache_dtype", "expected_quant_mode"),
64
+ [
65
+ ("auto", KVQuantMode.NONE),
66
+ ("fp8_ds_mla", KVQuantMode.FP8_PER_TENSOR),
67
+ ],
68
+)
69
+def test_mla_kv_cache_spec_uses_layer_cache_dtype(
70
+ cache_dtype: str, expected_quant_mode: KVQuantMode
71
+):
72
+ layer = SimpleNamespace(kv_cache_dtype=cache_dtype, head_size=576)
73
+ vllm_config = SimpleNamespace(
74
+ cache_config=SimpleNamespace(block_size=64), model_config=None
75
+ )
76
+
77
+ spec = MLAAttention.get_kv_cache_spec(layer, vllm_config)
78
+
79
+ assert isinstance(spec, MLAAttentionSpec)
80
+ assert spec.cache_dtype_str == cache_dtype
81
+ assert spec.kv_quant_mode == expected_quant_mode
82
+ if cache_dtype == "fp8_ds_mla":
83
+ assert spec.page_size_bytes == 64 * 656
84
+
85
+
86
# Remove sm100 backends from the list if not using sm100
87
if not torch.cuda.is_available() or torch.cuda.get_device_properties(0).major < 10:
88
BACKENDS_TO_TEST.remove(AttentionBackendEnum.CUTLASS_MLA)
tests/v1/attention/test_sparse_mla_backends.py
+321
-1
@@ -40,10 +40,16 @@ from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
40
)
41
from vllm.v1.attention.backends.mla.flashmla_sparse import (
42
FlashMLASparseBackend,
43
+ FlashMLASparseImpl,
44
+ FlashMLASparseMetadata,
45
+ FlashMLASparseMetadataBuilder,
46
triton_convert_req_index_to_global_index,
47
)
48
from vllm.v1.attention.backends.mla.indexer import split_indexer_prefill_chunks
46
-from vllm.v1.attention.backends.utils import split_prefill_chunks
49
+from vllm.v1.attention.backends.utils import (
50
+ split_decodes_and_prefills,
51
+ split_prefill_chunks,
52
+)
53
from vllm.v1.attention.ops import flashmla
54
55
SPARSE_BACKEND_BATCH_SPECS = {
@@ -721,6 +727,120 @@ def test_triton_convert_req_index_to_global_index_with_prefill_workspace(block_s
727
torch.testing.assert_close(result, reference_result, rtol=0, atol=0)
728
729
730
+@pytest.mark.skipif(
731
+ torch.cuda.get_device_capability() < (9, 0),
732
+ reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
733
+)
734
+def test_triton_convert_rejects_req_id_longer_than_token_indices():
735
+ """Guard against the #47327 regression: the kernel grid is sized by
736
+ req_id but the output is allocated like token_indices, so a full-batch
737
+ req_id combined with an MQA-subset token_indices wrote past the end of
738
+ the output buffer. The wrapper must reject the length mismatch instead
739
+ of corrupting memory."""
740
+ device = torch.device(DEVICE_TYPE)
741
+ num_topk_tokens = 128
742
+ block_size = 64
743
+ block_table = torch.arange(40, dtype=torch.int32, device=device).view(4, 10)
744
+
745
+ # Full batch: 2 decode tokens + 10 prefill tokens
746
+ req_id_full = torch.tensor(
747
+ [0, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3], dtype=torch.int32, device=device
748
+ )
749
+ num_mqa_tokens = 2
750
+ token_indices = torch.randint(
751
+ 0,
752
+ block_size * 10,
753
+ (num_mqa_tokens, num_topk_tokens),
754
+ dtype=torch.int32,
755
+ device=device,
756
+ )
757
+
758
+ with pytest.raises(AssertionError, match="must cover the same tokens"):
759
+ triton_convert_req_index_to_global_index(
760
+ req_id_full,
761
+ block_table,
762
+ token_indices,
763
+ BLOCK_SIZE=block_size,
764
+ NUM_TOPK_TOKENS=num_topk_tokens,
765
+ )
766
+
767
+ # The sliced call is the intended usage and must match the reference.
768
+ result = triton_convert_req_index_to_global_index(
769
+ req_id_full[:num_mqa_tokens],
770
+ block_table,
771
+ token_indices,
772
+ BLOCK_SIZE=block_size,
773
+ NUM_TOPK_TOKENS=num_topk_tokens,
774
+ )
775
+ reference = _triton_convert_reference_impl(
776
+ req_id_full[:num_mqa_tokens],
777
+ block_table,
778
+ token_indices,
779
+ block_size,
780
+ num_topk_tokens,
781
+ )
782
+ torch.testing.assert_close(result, reference, rtol=0, atol=0)
783
+
784
+
785
+@pytest.mark.skipif(
786
+ torch.cuda.get_device_capability() < (9, 0),
787
+ reason="FlashMLASparseBackend requires CUDA 9.0 or higher",
788
+)
789
+def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
790
+ """Guard against the #47327 regression: when the dense-MHA prefill split
791
+ is active, forward_mqa only receives the leading decode tokens, but
792
+ _forward_bf16_kv passed the full-batch req_id_per_token to the index
793
+ conversion, making it write past the end of its output buffer. The call
794
+ site must slice req_id_per_token to the MQA tokens."""
795
+ device = torch.device(DEVICE_TYPE)
796
+ num_topk_tokens = 128
797
+ block_size = 64
798
+ num_batch_tokens = 12
799
+ num_mqa_tokens = 2
800
+
801
+ attn_metadata = SimpleNamespace(
802
+ req_id_per_token=torch.tensor(
803
+ [0, 1] + [2] * 5 + [3] * 5, dtype=torch.int32, device=device
804
+ ),
805
+ block_table=torch.arange(40, dtype=torch.int32, device=device).view(4, 10),
806
+ block_size=block_size,
807
+ )
808
+ assert attn_metadata.req_id_per_token.shape[0] == num_batch_tokens
809
+
810
+ q = torch.zeros(num_mqa_tokens, 4, 576, dtype=torch.bfloat16, device=device)
811
+ kv_cache = torch.zeros(40 * block_size, 576, dtype=torch.bfloat16, device=device)
812
+ topk_indices = torch.randint(
813
+ 0,
814
+ block_size * 10,
815
+ (num_mqa_tokens, num_topk_tokens),
816
+ dtype=torch.int32,
817
+ device=device,
818
+ )
819
+
820
+ captured = {}
821
+
822
+ def _stub_kernel(q, kv, indices, lengths):
823
+ captured["indices"] = indices
824
+ return torch.zeros(q.shape[0], q.shape[1], 512, dtype=q.dtype, device=q.device)
825
+
826
+ stub_impl = SimpleNamespace(_bf16_flash_mla_kernel=_stub_kernel)
827
+
828
+ out = FlashMLASparseImpl._forward_bf16_kv(
829
+ stub_impl, q, kv_cache, topk_indices, attn_metadata
830
+ )
831
+
832
+ assert out.shape[0] == num_mqa_tokens
833
+ assert captured["indices"].shape[0] == num_mqa_tokens
834
+ reference = _triton_convert_reference_impl(
835
+ attn_metadata.req_id_per_token[:num_mqa_tokens],
836
+ attn_metadata.block_table,
837
+ topk_indices,
838
+ block_size,
839
+ num_topk_tokens,
840
+ )
841
+ torch.testing.assert_close(captured["indices"], reference, rtol=0, atol=0)
842
+
843
+
844
@pytest.mark.parametrize(
845
"seq_lens,max_buf,expected",
846
[
@@ -1124,3 +1244,203 @@ def test_triton_convert_returns_valid_counts():
1244
)
1245
assert isinstance(result_only, torch.Tensor)
1246
torch.testing.assert_close(result_only, result, rtol=0, atol=0)
1247
+
1248
+
1249
+def test_flashmla_cache_dtype_aliases_use_ds_layout():
1250
+ from vllm.model_executor.layers.attention.mla_attention import (
1251
+ _canonicalize_sparse_mla_kv_cache_dtype,
1252
+ )
1253
+
1254
+ # kv-cache dtype aliases are canonicalized to fp8_ds_mla before the layer
1255
+ # stores kv_cache_dtype, so they cannot bypass the gate.
1256
+ for alias in ("fp8", "fp8_e4m3"):
1257
+ assert (
1258
+ _canonicalize_sparse_mla_kv_cache_dtype(FlashMLASparseBackend, alias)
1259
+ == "fp8_ds_mla"
1260
+ )
1261
+
1262
+
1263
+def test_flashmla_fp8_metadata_reuses_common_batch_split():
1264
+ builder = SimpleNamespace(
1265
+ device=torch.device(DEVICE_TYPE),
1266
+ vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=8)),
1267
+ )
1268
+ common_metadata = SimpleNamespace(
1269
+ num_actual_tokens=1,
1270
+ seq_lens_cpu_upper_bound=torch.tensor([1]),
1271
+ query_start_loc_cpu=torch.tensor([0, 1]),
1272
+ block_table_tensor=torch.zeros(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
1273
+ )
1274
+ metadata = FlashMLASparseMetadata(
1275
+ num_reqs=1,
1276
+ max_query_len=1,
1277
+ max_seq_len=1,
1278
+ num_actual_tokens=1,
1279
+ query_start_loc=torch.tensor([0, 1], device=DEVICE_TYPE),
1280
+ slot_mapping=torch.tensor([0], device=DEVICE_TYPE),
1281
+ block_table=torch.zeros(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
1282
+ req_id_per_token=torch.zeros(1, dtype=torch.int32, device=DEVICE_TYPE),
1283
+ num_decodes=0,
1284
+ num_prefills=1,
1285
+ num_decode_tokens=0,
1286
+ )
1287
+
1288
+ fp8_metadata = FlashMLASparseMetadataBuilder._build_fp8_separate_prefill_decode(
1289
+ builder, common_metadata, metadata
1290
+ )
1291
+
1292
+ assert fp8_metadata.num_decodes == 0
1293
+ assert fp8_metadata.num_prefills == 1
1294
+ assert fp8_metadata.num_decode_tokens == 0
1295
+ assert fp8_metadata.num_prefill_tokens == 1
1296
+
1297
+
1298
+def test_flashmla_common_metadata_requires_uniform_decodes():
1299
+ common_metadata = SimpleNamespace(
1300
+ max_query_len=3,
1301
+ num_reqs=3,
1302
+ num_actual_tokens=6,
1303
+ query_start_loc_cpu=torch.tensor([0, 1, 3, 6]),
1304
+ is_prefilling=None,
1305
+ )
1306
+
1307
+ split = split_decodes_and_prefills(
1308
+ common_metadata,
1309
+ decode_threshold=128,
1310
+ require_uniform=FlashMLASparseMetadataBuilder.require_uniform_decodes,
1311
+ )
1312
+
1313
+ assert split == (1, 2, 1, 5)
1314
+
1315
+
1316
+def test_flashmla_fp8_metadata_excludes_zero_token_decode_padding(monkeypatch):
1317
+ monkeypatch.setattr(
1318
+ "vllm.v1.attention.backends.mla.flashmla_sparse.get_mla_metadata",
1319
+ lambda: (object(), None),
1320
+ )
1321
+ builder = SimpleNamespace(
1322
+ device=torch.device(DEVICE_TYPE),
1323
+ dummy_block_table=torch.zeros(7, 1, device=DEVICE_TYPE),
1324
+ max_model_len_tensor=torch.zeros(7, device=DEVICE_TYPE),
1325
+ )
1326
+ query_start_loc_cpu = torch.tensor([0, 110, 220, 330, 440, 550, 660, 660])
1327
+ common_metadata = SimpleNamespace(
1328
+ num_actual_tokens=660,
1329
+ query_start_loc_cpu=query_start_loc_cpu,
1330
+ seq_lens=torch.arange(7, device=DEVICE_TYPE),
1331
+ )
1332
+ metadata = FlashMLASparseMetadata(
1333
+ num_reqs=7,
1334
+ max_query_len=110,
1335
+ max_seq_len=110,
1336
+ num_actual_tokens=660,
1337
+ query_start_loc=query_start_loc_cpu.to(DEVICE_TYPE),
1338
+ slot_mapping=torch.arange(660, device=DEVICE_TYPE),
1339
+ block_table=torch.zeros(7, 1, dtype=torch.int32, device=DEVICE_TYPE),
1340
+ req_id_per_token=torch.zeros(660, dtype=torch.int32, device=DEVICE_TYPE),
1341
+ num_decodes=7,
1342
+ num_prefills=0,
1343
+ num_decode_tokens=660,
1344
+ )
1345
+
1346
+ fp8_metadata = FlashMLASparseMetadataBuilder._build_fp8_separate_prefill_decode(
1347
+ builder, common_metadata, metadata
1348
+ )
1349
+
1350
+ assert fp8_metadata.num_decodes == 6
1351
+ assert fp8_metadata.num_decode_tokens == 660
1352
+ assert fp8_metadata.decode is not None
1353
+ assert fp8_metadata.decode.decode_query_len == 110
1354
+ torch.testing.assert_close(
1355
+ fp8_metadata.decode.seq_lens, torch.arange(6, device=DEVICE_TYPE)
1356
+ )
1357
+
1358
+
1359
+@pytest.mark.parametrize("use_mixed_batch", [False, True])
1360
+def test_flashmla_fp8_paths_accept_decode_subset(monkeypatch, use_mixed_batch: bool):
1361
+ num_decode_tokens = 2
1362
+ num_batch_tokens = 5
1363
+ q = torch.empty(num_decode_tokens, 2, 3, device=DEVICE_TYPE)
1364
+ topk_indices = torch.empty(num_decode_tokens, 4, device=DEVICE_TYPE)
1365
+ kernel_q_shapes = []
1366
+
1367
+ def convert_indices(*args, **kwargs): # noqa: ARG001
1368
+ assert not kwargs.get("HAS_PREFILL_WORKSPACE", False)
1369
+ if not kwargs.get("return_valid_counts", False):
1370
+ return topk_indices
1371
+ valid_counts = torch.full(
1372
+ (num_decode_tokens,), 4, dtype=torch.int32, device=DEVICE_TYPE
1373
+ )
1374
+ return topk_indices, valid_counts
1375
+
1376
+ monkeypatch.setattr(
1377
+ "vllm.v1.attention.backends.mla.flashmla_sparse."
1378
+ "triton_convert_req_index_to_global_index",
1379
+ convert_indices,
1380
+ )
1381
+
1382
+ def run_kernel(**kwargs):
1383
+ kernel_q_shapes.append(kwargs["q"].shape)
1384
+ return kwargs["q"][..., :1], None
1385
+
1386
+ if use_mixed_batch:
1387
+ fp8_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
1388
+ scheduler_metadata=object(), # type: ignore[arg-type]
1389
+ dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
1390
+ cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
1391
+ )
1392
+ else:
1393
+ FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
1394
+ fp8_metadata = FP8Meta(
1395
+ num_decodes=1,
1396
+ num_prefills=1,
1397
+ num_decode_tokens=num_decode_tokens,
1398
+ num_prefill_tokens=num_batch_tokens - num_decode_tokens,
1399
+ decode=FP8Meta.Decode(
1400
+ seq_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
1401
+ kernel_metadata=object(), # type: ignore[arg-type]
1402
+ decode_query_len=num_decode_tokens,
1403
+ ),
1404
+ prefill=FP8Meta.Prefill(
1405
+ request_ids=torch.empty(
1406
+ num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
1407
+ ),
1408
+ workspace_starts=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
1409
+ chunks=[],
1410
+ ),
1411
+ )
1412
+ metadata = SimpleNamespace(
1413
+ fp8_extra_metadata=fp8_metadata,
1414
+ fp8_use_mixed_batch=use_mixed_batch,
1415
+ num_actual_tokens=num_batch_tokens,
1416
+ req_id_per_token=torch.empty(
1417
+ num_batch_tokens, dtype=torch.int32, device=DEVICE_TYPE
1418
+ ),
1419
+ block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
1420
+ block_size=64,
1421
+ )
1422
+ impl = SimpleNamespace(
1423
+ kv_cache_dtype="fp8_ds_mla",
1424
+ topk_indices_buffer=topk_indices,
1425
+ num_heads=2,
1426
+ kv_lora_rank=1,
1427
+ _fp8_flash_mla_kernel=run_kernel,
1428
+ )
1429
+ impl._forward_fp8_kv_mixed_batch = MethodType(
1430
+ FlashMLASparseImpl._forward_fp8_kv_mixed_batch, impl
1431
+ )
1432
+ impl._forward_fp8_kv_separate_prefill_decode = MethodType(
1433
+ FlashMLASparseImpl._forward_fp8_kv_separate_prefill_decode, impl
1434
+ )
1435
+
1436
+ output, lse = FlashMLASparseImpl.forward_mqa(
1437
+ impl,
1438
+ q,
1439
+ torch.empty(0, device=DEVICE_TYPE),
1440
+ metadata,
1441
+ None,
1442
+ )
1443
+
1444
+ assert kernel_q_shapes == [(1, num_decode_tokens, 2, 3)]
1445
+ assert output.shape == (num_decode_tokens, 2, 1)
1446
+ assert lse is None
vllm/_custom_ops.py
+3
-3
@@ -2759,9 +2759,9 @@ def cp_gather_and_upconvert_fp8_kv_cache(
2759
src_cache: torch.Tensor,
2760
dst: torch.Tensor,
2761
block_table: torch.Tensor,
2762
- seq_lens: torch.Tensor,
2762
workspace_starts: torch.Tensor,
2763
batch_size: int,
2764
+ seq_starts: torch.Tensor | None = None,
2765
) -> None:
2766
"""Gather and upconvert FP8 KV cache to BF16 workspace.
2767
@@ -2769,12 +2769,12 @@ def cp_gather_and_upconvert_fp8_kv_cache(
2769
src_cache: FP8 KV cache [num_blocks, block_size, 656]
2770
dst: BF16 output workspace [total_tokens, 576]
2771
block_table: Block indices [num_reqs, max_blocks]
2772
- seq_lens: Sequence lengths [num_reqs]
2772
workspace_starts: Workspace start offsets [num_reqs]
2773
batch_size: Number of requests
2774
+ seq_starts: Optional source sequence offsets [num_reqs]
2775
"""
2776
torch.ops._C_cache_ops.cp_gather_and_upconvert_fp8_kv_cache(
2777
- src_cache, dst, block_table, seq_lens, workspace_starts, batch_size
2777
+ src_cache, dst, block_table, workspace_starts, batch_size, seq_starts
2778
)
2779
2780
vllm/model_executor/layers/attention/mla_attention.py
+25
-6
@@ -1749,6 +1749,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1749
self.determine_chunked_prefill_workspace_size(vllm_config)
1750
)
1751
1752
+ use_packed_fp8_cache = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
1753
if self.dcp_world_size > 1:
1754
# Note(hc): The local kvcache is incomplete when DCP is triggered,
1755
# an additional kvcache allgather across the DCP group is therefore
@@ -1761,7 +1762,9 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1762
+ self.chunked_prefill_workspace_size // self.dcp_world_size,
1763
self.model_config.get_head_size(),
1764
),
1764
- dtype=self.model_config.dtype,
1765
+ dtype=torch.bfloat16
1766
+ if use_packed_fp8_cache
1767
+ else self.model_config.dtype,
1768
device=device,
1769
)
1770
else:
@@ -1770,7 +1773,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1773
self.chunked_prefill_workspace_size,
1774
self.model_config.get_head_size(),
1775
),
1773
- dtype=self.q_data_type,
1776
+ dtype=torch.bfloat16 if use_packed_fp8_cache else self.q_data_type,
1777
device=device,
1778
)
1779
@@ -2115,7 +2118,16 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2118
2119
for i in range(iters):
2120
toks = prefill_metadata.chunked_context.seq_tot[i]
2118
- if not use_fp8_prefill:
2121
+ if self.kv_cache_dtype == "fp8_ds_mla":
2122
+ ops.cp_gather_and_upconvert_fp8_kv_cache(
2123
+ src_cache=kv_c_and_k_pe_cache,
2124
+ dst=workspace[:toks],
2125
+ block_table=prefill_metadata.block_table,
2126
+ workspace_starts=prefill_metadata.chunked_context.cu_seq_lens[i],
2127
+ batch_size=attn_metadata.num_prefills,
2128
+ seq_starts=prefill_metadata.chunked_context.starts[i],
2129
+ )
2130
+ elif not use_fp8_prefill:
2131
ops.gather_and_maybe_dequant_cache(
2132
src_cache=kv_c_and_k_pe_cache,
2133
dst=workspace,
@@ -2230,9 +2242,16 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
2242
padded_local_cu_seq_lens = (
2243
prefill_metadata.chunked_context.padded_local_cu_seq_lens[i]
2244
)
2233
- if is_quantized_kv_cache(self.kv_cache_dtype) and (
2234
- self.kv_cache_dtype != "fp8_ds_mla"
2235
- ):
2245
+ if self.kv_cache_dtype == "fp8_ds_mla":
2246
+ ops.cp_gather_and_upconvert_fp8_kv_cache(
2247
+ src_cache=kv_c_and_k_pe_cache,
2248
+ dst=workspace[:toks],
2249
+ block_table=prefill_metadata.block_table,
2250
+ workspace_starts=padded_local_cu_seq_lens,
2251
+ batch_size=attn_metadata.num_prefills,
2252
+ seq_starts=prefill_metadata.chunked_context.starts[i],
2253
+ )
2254
+ elif is_quantized_kv_cache(self.kv_cache_dtype):
2255
assert k_scale is not None
2256
ops.gather_and_maybe_dequant_cache(
2257
src_cache=kv_c_and_k_pe_cache,
vllm/model_executor/layers/attention/sparse_mla_attention.py
+3
-1
@@ -3,7 +3,7 @@
3
"""Shared forward_mha implementation and metadata builder for sparse MLA backends."""
4
5
from shutil import which
6
-from typing import TYPE_CHECKING, Generic, TypeVar
6
+from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
7
8
import numpy as np
9
import torch
@@ -38,6 +38,7 @@ T = TypeVar("T", bound=AttentionMetadata)
38
39
class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
40
metadata_cls: type[T]
41
+ require_uniform_decodes: ClassVar[bool] = False
42
43
def __init__(
44
self,
@@ -173,6 +174,7 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
174
num_decodes, num_prefills, num_decode_tokens, _ = split_decodes_and_prefills(
175
common_attn_metadata,
176
decode_threshold=self.reorder_batch_threshold or 1,
177
+ require_uniform=self.require_uniform_decodes,
178
)
179
(
180
prefill_query_start_loc,
vllm/v1/attention/backends/mla/flashmla_sparse.py
+40
-41
@@ -32,7 +32,6 @@ from vllm.v1.attention.backends.mla.sparse_utils import (
32
from vllm.v1.attention.backends.utils import (
33
reshape_attn_output_for_spec_decode,
34
reshape_query_for_spec_decode,
35
- split_decodes_and_prefills,
35
split_prefill_chunks,
36
)
37
from vllm.v1.attention.ops.flashmla import (
@@ -57,8 +56,8 @@ logger = init_logger(__name__)
56
# the FP8 decode kernel for decode.
57
# Currently we use #1 when the number of heads per rank is low (i.e. TP) since the BF16
58
# prefill kernel requires padding the number of heads to 128 while the decode does not
60
-# so when the per ranke head count is below MIN_HEADS_FOR_BF16_PREFILL we use the mixed
61
-# batch mode (#2).
59
+# so when the per-rank head count is below MIN_HEADS_FOR_BF16_PREFILL we use the mixed
60
+# batch mode (#1).
61
MIN_HEADS_FOR_BF16_PREFILL = 32
62
63
"""
@@ -182,10 +181,6 @@ class FlashMLASparseMetadata(AttentionMetadata):
181
182
@dataclass
183
class Prefill:
185
- # Sequence lengths (context + query) for prefill requests
186
- # Shape: [num_prefill_reqs]
187
- seq_lens: torch.Tensor
188
-
184
# Request ID for each token: -1 for decode tokens, request index
185
# (0, 1, 2, ...) for prefill tokens.
186
# Shape: [num_actual_tokens]
@@ -204,7 +199,6 @@ class FlashMLASparseMetadata(AttentionMetadata):
199
Prefill requests may be chunked to fit within the fixed workspace size.
200
"""
201
207
- seq_lens: torch.Tensor
202
tokens_slice: slice
203
block_table: torch.Tensor
204
req_start_idx: int
@@ -239,6 +233,7 @@ class FlashMLASparseMetadataBuilder(
233
SparseMLACommonMetadataBuilder[FlashMLASparseMetadata]
234
):
235
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
236
+ require_uniform_decodes: ClassVar[bool] = True
237
metadata_cls = FlashMLASparseMetadata
238
239
def __init__(
@@ -322,10 +317,11 @@ class FlashMLASparseMetadataBuilder(
317
self,
318
common_attn_metadata: CommonAttentionMetadata,
319
) -> "FlashMLASparseMetadata.FP8KernelMetadata":
325
- """Build FP8 metadata treating all tokens as one mixed batch.
320
+ """Build FP8 metadata treating MQA tokens as one batch.
321
327
- This matches main branch's approach and avoids the BF16 prefill kernel
328
- which has head padding overhead when num_heads is small (high TP case).
322
+ The scheduler initializes lazily from the runtime query shape, which may
323
+ be the full batch or only decodes when prefills use dense MHA. This avoids
324
+ the BF16 prefill kernel's head-padding overhead at high TP.
325
"""
326
num_tokens = common_attn_metadata.num_actual_tokens
327
@@ -353,20 +349,29 @@ class FlashMLASparseMetadataBuilder(
349
def _build_fp8_separate_prefill_decode(
350
self,
351
common_attn_metadata: CommonAttentionMetadata,
352
+ metadata: FlashMLASparseMetadata,
353
) -> "FlashMLASparseMetadata.FP8SeparatePrefillDecode":
354
num_tokens = common_attn_metadata.num_actual_tokens
355
356
(num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens) = (
360
- split_decodes_and_prefills(
361
- common_attn_metadata,
362
- decode_threshold=self.reorder_batch_threshold or 1,
363
- require_uniform=True,
364
- )
357
+ metadata.num_decodes,
358
+ metadata.num_prefills,
359
+ metadata.num_decode_tokens,
360
+ num_tokens - metadata.num_decode_tokens,
361
)
362
363
+ decode_query_len = 0
364
+ active_num_decodes = num_decodes
365
+ if num_decodes > 0:
366
+ query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
367
+ decode_query_len = (query_start_loc_cpu[1] - query_start_loc_cpu[0]).item()
368
+ assert decode_query_len > 0
369
+ active_num_decodes = num_decode_tokens // decode_query_len
370
+ assert active_num_decodes * decode_query_len == num_decode_tokens
371
+
372
FP8Meta = FlashMLASparseMetadata.FP8SeparatePrefillDecode
373
fp8_metadata = FP8Meta(
369
- num_decodes=num_decodes,
374
+ num_decodes=active_num_decodes,
375
num_prefills=num_prefills,
376
num_decode_tokens=num_decode_tokens,
377
num_prefill_tokens=num_prefill_tokens,
@@ -374,7 +379,6 @@ class FlashMLASparseMetadataBuilder(
379
380
# Extract prefill sequence lengths (context + query, not just query)
381
# Decode requests come first in the batch, prefill requests follow
377
- prefill_seq_lens = None
382
prefill_request_id = None
383
prefill_workspace_starts = None
384
prefill_chunks = None
@@ -386,11 +390,9 @@ class FlashMLASparseMetadataBuilder(
390
# slice below), so no D2H sync is needed.
391
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
392
assert seq_lens_cpu is not None
389
- seq_lens = common_attn_metadata.seq_lens
393
query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
394
395
prefill_seq_lens_cpu = seq_lens_cpu[num_decodes:]
393
- prefill_seq_lens = seq_lens[num_decodes:]
396
397
# Build prefill_request_id: -1 for decode, request index for
398
# prefill. This enables a single
@@ -438,7 +440,6 @@ class FlashMLASparseMetadataBuilder(
440
offset = prefill_workspace_starts_cpu[chunk_start].item()
441
prefill_workspace_starts_cpu[chunk_start:chunk_end] -= offset
442
441
- chunk_seq_lens = prefill_seq_lens[chunk_start:chunk_end]
443
chunk_tot_seqlen = prefill_seq_lens_cpu[chunk_start:chunk_end].sum()
444
token_start = query_start_loc_cpu[num_decodes + chunk_start].item()
445
token_end = query_start_loc_cpu[num_decodes + chunk_end].item()
@@ -452,7 +453,6 @@ class FlashMLASparseMetadataBuilder(
453
454
prefill_chunks.append(
455
FP8Meta.Prefill.Chunk(
455
- seq_lens=chunk_seq_lens,
456
tokens_slice=tokens_slice,
457
block_table=chunk_block_table,
458
req_start_idx=chunk_start,
@@ -466,27 +466,22 @@ class FlashMLASparseMetadataBuilder(
466
)
467
468
fp8_metadata.prefill = FP8Meta.Prefill(
469
- seq_lens=prefill_seq_lens,
469
request_ids=prefill_request_id,
470
workspace_starts=prefill_workspace_starts,
471
chunks=prefill_chunks,
472
)
473
474
if num_decodes > 0:
476
- # Compute decode_query_len for spec decode (uniform due to require_uniform)
477
- query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
478
- decode_query_len = (query_start_loc_cpu[1] - query_start_loc_cpu[0]).item()
479
-
475
# Use padded head count since that's what the kernel will see
476
scheduler_metadata, _ = get_mla_metadata()
477
478
kernel_meta = FlashMLASparseMetadata.FP8KernelMetadata(
479
scheduler_metadata=scheduler_metadata,
485
- dummy_block_table=self.dummy_block_table[:num_decodes],
486
- cache_lens=self.max_model_len_tensor[:num_decodes],
480
+ dummy_block_table=self.dummy_block_table[:active_num_decodes],
481
+ cache_lens=self.max_model_len_tensor[:active_num_decodes],
482
)
483
fp8_metadata.decode = FP8Meta.Decode(
489
- seq_lens=common_attn_metadata.seq_lens[:num_decodes],
484
+ seq_lens=common_attn_metadata.seq_lens[:active_num_decodes],
485
kernel_metadata=kernel_meta,
486
decode_query_len=decode_query_len,
487
)
@@ -510,7 +505,7 @@ class FlashMLASparseMetadataBuilder(
505
)
506
else:
507
metadata.fp8_extra_metadata = self._build_fp8_separate_prefill_decode(
513
- common_attn_metadata
508
+ common_attn_metadata, metadata
509
)
510
511
return metadata
@@ -597,9 +592,10 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
592
attn_metadata: FlashMLASparseMetadata,
593
) -> torch.Tensor:
594
# Convert per-request indices to global slots (decode) or workspace
600
- # offsets (prefill).
595
+ # offsets (prefill). req_id_per_token covers the whole batch; slice it
596
+ # to the MQA tokens (q may exclude prefill tokens routed to dense MHA).
597
topk_indices, topk_length = triton_convert_req_index_to_global_index(
602
- attn_metadata.req_id_per_token,
598
+ attn_metadata.req_id_per_token[: topk_indices.shape[0]],
599
attn_metadata.block_table,
600
topk_indices,
601
BLOCK_SIZE=attn_metadata.block_size,
@@ -624,11 +620,18 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
620
fp8_metadata = attn_metadata.fp8_extra_metadata
621
assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8SeparatePrefillDecode)
622
num_decodes = fp8_metadata.num_decodes
623
+ num_mqa_tokens = q.shape[0]
624
+ num_decode_tokens = fp8_metadata.num_decode_tokens
625
+ num_prefill_tokens = num_mqa_tokens - num_decode_tokens
626
+ assert num_prefill_tokens in (0, fp8_metadata.num_prefill_tokens), (
627
+ "FP8 sparse MLA expects either the decode subset or the full batch"
628
+ )
629
630
prefill_request_ids = None
631
prefill_workspace_starts = None
632
has_prefill_workspace = False
631
- if fp8_metadata.prefill is not None:
633
+ if num_prefill_tokens > 0:
634
+ assert fp8_metadata.prefill is not None
635
prefill_request_ids = fp8_metadata.prefill.request_ids
636
prefill_workspace_starts = fp8_metadata.prefill.workspace_starts
637
has_prefill_workspace = True
@@ -640,7 +643,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
643
# prefill_workspace_starts has been adjusted in-place per chunk so
644
# prefill indices automatically come out chunk-local
645
topk_indices, topk_length = triton_convert_req_index_to_global_index(
643
- attn_metadata.req_id_per_token,
646
+ attn_metadata.req_id_per_token[: topk_indices.shape[0]],
647
attn_metadata.block_table,
648
topk_indices,
649
BLOCK_SIZE=attn_metadata.block_size,
@@ -676,9 +679,6 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
679
# -> (num_decode_tokens, num_heads, head_dim_v)
680
return reshape_attn_output_for_spec_decode(attn_out)
681
679
- num_decode_tokens = fp8_metadata.num_decode_tokens
680
- num_prefill_tokens = fp8_metadata.num_prefill_tokens
681
-
682
# Pure decode: direct call without allocation
683
if num_decode_tokens > 0 and num_prefill_tokens == 0:
684
assert fp8_metadata.decode is not None
@@ -686,7 +686,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
686
else:
687
# Mixed or pure prefill: allocate output tensor
688
attn_out = q.new_empty(
689
- (attn_metadata.num_actual_tokens, self.num_heads, self.kv_lora_rank),
689
+ (num_mqa_tokens, self.num_heads, self.kv_lora_rank),
690
dtype=q.dtype,
691
device=q.device,
692
)
@@ -704,7 +704,6 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
704
kv_c_and_k_pe_cache,
705
chunk_workspace,
706
chunk.block_table,
707
- chunk.seq_lens,
707
chunk.workspace_starts,
708
len(chunk.block_table),
709
)
@@ -738,7 +737,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
737
# Convert per-request indices to global slots (decode) or workspace
738
# offsets (prefill).
739
topk_indices = triton_convert_req_index_to_global_index(
741
- attn_metadata.req_id_per_token,
740
+ attn_metadata.req_id_per_token[: topk_indices.shape[0]],
741
attn_metadata.block_table,
742
topk_indices,
743
BLOCK_SIZE=attn_metadata.block_size,
vllm/v1/attention/backends/mla/sparse_utils.py
+5
@@ -154,6 +154,11 @@ def triton_convert_req_index_to_global_index(
154
assert req_id.dtype == torch.int32
155
assert block_table.dtype == torch.int32
156
assert token_indices.dtype == torch.int32
157
+ assert req_id.shape[0] == token_indices.shape[0], (
158
+ f"req_id ({req_id.shape[0]}) and token_indices ({token_indices.shape[0]}) "
159
+ "must cover the same tokens; the grid is sized by req_id but the output "
160
+ "is allocated like token_indices, so a longer req_id writes out of bounds"
161
+ )
162
assert token_indices.shape[1] == NUM_TOPK_TOKENS
163
assert NUM_TOPK_TOKENS % BLOCK_N == 0, (
164
f"NUM_TOPK_TOKENS ({NUM_TOPK_TOKENS}) must be divisible by BLOCK_N ({BLOCK_N})"