[Attention][DSA] support dcp for FLASHINFER_MLA_SPARSE (#46076)

Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Signed-off-by: Jingyi Yang <girasoleyang@gmail.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Signed-off-by: GirasoleY <girasoleyang@gmail.com> Co-authored-by: Jingyi Yang <girasoleyang@gmail.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Jiangyun Zhu committed Jul 1, 2026 at 12:32 UTC c5200d3565e67fc4d50f0b91f2391234aa2400db
12 files changed +1996 -98
docs/design/attention_backends.md
+1 -1
@@ -222,7 +222,7 @@ MLA decode backends are selected using the standard
222 | ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | ------ | --------- | --- | --------------- | ------------ |
223 | `CUTLASS_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 128 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x |
224 | `FLASHINFER_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x |
225 -| `FLASHINFER_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | 10.x |
225 +| `FLASHINFER_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x |
226 | `FLASHINFER_MLA_SPARSE_SM120` | bf16 | `auto`, `fp8`, `fp8_e4m3`, `fp8_ds_mla` | 64, 256 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | 12.x |
227 | `FLASHMLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 9.x-10.x |
228 | `FLASHMLA_SPARSE` | bf16 | `auto`, `bfloat16`, `fp8_ds_mla` | 64 | 576 | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | 9.x-10.x |
tests/v1/attention/test_indexer_dcp_localize.py new
+951
@@ -0,0 +1,951 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +import torch
6 +
7 +import vllm.model_executor.layers.sparse_attn_indexer as sparse_indexer
8 +from vllm.platforms import current_platform
9 +from vllm.utils.import_utils import has_cutedsl
10 +from vllm.v1.attention.backends.mla.indexer import build_prefill_chunk_metadata
11 +from vllm.v1.attention.backends.mla.sparse_utils import (
12 + triton_filter_and_convert_dcp_index,
13 +)
14 +from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
15 +from vllm.v1.attention.ops.common import CPTritonContext, correct_attn_out
16 +
17 +
18 +def _local_count(length: int, rank: int, world: int, interleave: int) -> int:
19 + return sum(1 for pos in range(length) if (pos // interleave) % world == rank)
20 +
21 +
22 +def _global_to_local_indices(
23 + global_indices: torch.Tensor,
24 + rank: int,
25 + world: int,
26 + interleave: int,
27 +) -> torch.Tensor:
28 + valid = global_indices >= 0
29 + global_i64 = global_indices.to(torch.int64).clamp_min(0)
30 + owner = (global_i64 // interleave) % world
31 + local = (global_i64 // (world * interleave)) * interleave + global_i64 % interleave
32 + return torch.where(valid & (owner == rank), local, -1).to(torch.int64)
33 +
34 +
35 +def _local_to_global_indices(
36 + local_indices: torch.Tensor,
37 + rank: int,
38 + world: int,
39 + interleave: int,
40 +) -> torch.Tensor:
41 + valid = local_indices >= 0
42 + local = local_indices.to(torch.int64).clamp_min(0)
43 + global_indices = (
44 + (local // interleave) * (world * interleave)
45 + + rank * interleave
46 + + local % interleave
47 + )
48 + return torch.where(valid, global_indices, -1).to(torch.int64)
49 +
50 +
51 +def _ref_stable_topk_from_candidates_fp64(
52 + candidate_scores: torch.Tensor,
53 + candidate_token_ids: torch.Tensor,
54 + k: int,
55 +) -> torch.Tensor:
56 + """Pure-PyTorch reference for the CuteDSL stable-topk selector order
57 + (score desc, then lowest global token id). Selects the same SET as the
58 + kernel; only the set is compared in tests."""
59 + num_rows, num_candidates = candidate_scores.shape
60 + device = candidate_scores.device
61 + select_k = min(k, num_candidates)
62 + valid = candidate_token_ids >= 0
63 + bits = (
64 + candidate_scores.to(torch.float32).view(torch.int32).to(torch.int64)
65 + & 0xFFFFFFFF
66 + )
67 + sign = (bits >> 31) & 1
68 + score_key = (
69 + torch.where(sign.bool(), bits ^ 0xFFFFFFFF, bits ^ 0x80000000) & 0xFFFFFFFF
70 + )
71 + id_key = (~candidate_token_ids.to(torch.int64)) & 0xFFFFFFFF
72 + key = (score_key << 32) | id_key
73 + key = torch.where(valid, key, torch.zeros_like(key))
74 + topk_key = key ^ torch.iinfo(torch.int64).min
75 + _, topk_pos = topk_key.topk(select_k, dim=-1)
76 +
77 + selected = candidate_token_ids.gather(1, topk_pos).to(torch.int32)
78 + selected_valid = valid.gather(1, topk_pos)
79 + selected = torch.where(selected_valid, selected, selected.new_full((), -1))
80 + if select_k == k:
81 + return selected
82 + pad = torch.full((num_rows, k - select_k), -1, dtype=torch.int32, device=device)
83 + return torch.cat((selected, pad), dim=1)
84 +
85 +
86 +def _attention_from_indices(
87 + q: torch.Tensor,
88 + k: torch.Tensor,
89 + v: torch.Tensor,
90 + indices: torch.Tensor,
91 +) -> tuple[torch.Tensor, torch.Tensor]:
92 + valid = indices >= 0
93 + safe_indices = indices.clamp_min(0)
94 + selected_k = k[safe_indices]
95 + selected_v = v[safe_indices]
96 + scores = torch.einsum("td,tkd->tk", q, selected_k)
97 + scores = scores.masked_fill(~valid, float("-inf"))
98 + lse = torch.logsumexp(scores, dim=-1)
99 + probs = torch.softmax(scores, dim=-1).masked_fill(~valid, 0.0)
100 + out = torch.einsum("tk,tkd->td", probs, selected_v)
101 + empty_rows = ~valid.any(dim=-1)
102 + out[empty_rows] = 0
103 + lse[empty_rows] = float("-inf")
104 + return out, lse
105 +
106 +
107 +def _dcp_lse_merge(
108 + local_outs: list[torch.Tensor],
109 + local_lses: list[torch.Tensor],
110 +) -> tuple[torch.Tensor, torch.Tensor]:
111 + outs = torch.stack(local_outs, dim=0)
112 + lses = torch.stack(local_lses, dim=0)
113 + merged_lse = torch.logsumexp(lses, dim=0)
114 + weights = torch.exp(lses - merged_lse.unsqueeze(0))
115 + weights = torch.where(torch.isfinite(weights), weights, torch.zeros_like(weights))
116 + merged_out = (outs * weights.unsqueeze(-1)).sum(dim=0)
117 + return merged_out, merged_lse
118 +
119 +
120 +class _FakeDCPGroup:
121 + """Single-process stand-in: ``all_gather`` returns the pre-built
122 + concatenation of every rank's packed ``(score, global_id)`` candidates,
123 + mirroring the one packed all-gather the merge issues."""
124 +
125 + def __init__(self, gathered_packed: torch.Tensor) -> None:
126 + self.gathered_packed = gathered_packed
127 +
128 + def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
129 + assert dim == 1
130 + return self.gathered_packed.clone()
131 +
132 +
133 +def _run_decode_topk(
134 + logits: torch.Tensor,
135 + seq_lens: torch.Tensor,
136 + next_n: int,
137 + topk: int,
138 +) -> torch.Tensor:
139 + indices = torch.empty(
140 + (logits.shape[0], topk), dtype=torch.int32, device=logits.device
141 + )
142 + torch.ops._C.top_k_per_row_decode(
143 + logits,
144 + next_n,
145 + seq_lens,
146 + indices,
147 + logits.shape[0],
148 + logits.stride(0),
149 + logits.stride(1),
150 + topk,
151 + )
152 + return indices
153 +
154 +
155 +def _run_persistent_topk(
156 + logits: torch.Tensor,
157 + seq_lens: torch.Tensor,
158 + topk: int,
159 + max_seq_len: int,
160 +) -> torch.Tensor:
161 + indices = torch.empty(
162 + (logits.shape[0], topk), dtype=torch.int32, device=logits.device
163 + )
164 + workspace = torch.empty(1024 * 1024, dtype=torch.uint8, device=logits.device)
165 + torch.ops._C.persistent_topk(
166 + logits,
167 + seq_lens,
168 + indices,
169 + workspace,
170 + topk,
171 + max_seq_len,
172 + )
173 + return indices
174 +
175 +
176 +def _dcp_attention_from_local_topks(
177 + q: torch.Tensor,
178 + k: torch.Tensor,
179 + v: torch.Tensor,
180 + local_topks: list[torch.Tensor],
181 + world: int,
182 + interleave: int,
183 +) -> tuple[torch.Tensor, torch.Tensor]:
184 + local_outs = []
185 + local_lses = []
186 + for rank, local_topk in enumerate(local_topks):
187 + owned = [
188 + pos for pos in range(k.shape[0]) if (pos // interleave) % world == rank
189 + ]
190 + local_out, local_lse = _attention_from_indices(
191 + q, k[owned], v[owned], local_topk.to(torch.int64)
192 + )
193 + local_outs.append(local_out)
194 + local_lses.append(local_lse)
195 + return _dcp_lse_merge(local_outs, local_lses)
196 +
197 +
198 +def _merge_local_topks_global_with_fake_dcp(
199 + local_logits: list[torch.Tensor],
200 + local_topks: list[torch.Tensor],
201 + topk: int,
202 + world: int,
203 + interleave: int,
204 + row_starts: list[torch.Tensor | None] | None = None,
205 +) -> list[torch.Tensor]:
206 + """Run ``_merge_dcp_topk_global`` for every rank against a faked
207 + all-gather and return each rank's global-index result (all ranks should
208 + agree). The fake pre-builds the packed candidate concatenation the merge's
209 + single ``all_gather`` would return."""
210 + # The merge is now CuteDSL-only (no PyTorch fallback), so it runs the real
211 + # Triton pack + CuteDSL selector kernels even behind the faked all-gather.
212 + if not current_platform.is_cuda() or not has_cutedsl():
213 + pytest.skip("DCP merge requires CUDA and CuteDSL")
214 + packed_per_rank = []
215 + for rank, (logits, indices) in enumerate(zip(local_logits, local_topks)):
216 + score_indices = indices.clamp_min(0).to(torch.long)
217 + if row_starts is not None:
218 + rs = row_starts[rank]
219 + assert rs is not None
220 + score_indices = score_indices + rs.to(torch.long).view(-1, 1)
221 + if logits.shape[1] == 0:
222 + scores = torch.full_like(indices, float("-inf"), dtype=torch.float32)
223 + else:
224 + score_indices = score_indices.clamp_max(logits.shape[1] - 1)
225 + scores = logits.gather(1, score_indices).masked_fill(
226 + indices < 0, float("-inf")
227 + )
228 + global_ids = _local_to_global_indices(indices, rank, world, interleave)
229 + packed_per_rank.append(
230 + torch.stack((scores.float(), global_ids.to(torch.float32)), dim=-1)
231 + )
232 +
233 + fake_group = _FakeDCPGroup(torch.cat(packed_per_rank, dim=1).contiguous())
234 + original_get_dcp_group = sparse_indexer.get_dcp_group
235 + sparse_indexer.get_dcp_group = lambda: fake_group
236 + try:
237 + merged = []
238 + for rank, (logits, indices) in enumerate(zip(local_logits, local_topks)):
239 + rank_indices = indices.clone()
240 + result = sparse_indexer._merge_dcp_topk_global(
241 + logits,
242 + rank_indices,
243 + topk,
244 + rank,
245 + world,
246 + interleave,
247 + row_starts=None if row_starts is None else row_starts[rank],
248 + )
249 + assert result is None
250 + merged.append(rank_indices)
251 + return merged
252 + finally:
253 + sparse_indexer.get_dcp_group = original_get_dcp_group
254 +
255 +
256 +@pytest.mark.parametrize("world", [1, 2, 4])
257 +@pytest.mark.parametrize("interleave", [1, 2, 4])
258 +def test_get_dcp_local_seq_lens_matches_naive(world: int, interleave: int):
259 + seq_lens = torch.arange(0, 33, dtype=torch.int32)
260 +
261 + for rank in range(world):
262 + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave)
263 + expected = torch.tensor(
264 + [
265 + _local_count(int(seq_len), rank, world, interleave)
266 + for seq_len in seq_lens
267 + ],
268 + dtype=torch.int32,
269 + )
270 + torch.testing.assert_close(actual, expected)
271 +
272 +
273 +def test_get_dcp_local_seq_lens_can_localize_per_token_bounds():
274 + seq_lens = torch.tensor([0, 1, 2, 3, 4, 7, 8, 17], dtype=torch.int32)
275 + world = 4
276 + interleave = 2
277 +
278 + for rank in range(world):
279 + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave)
280 + expected = torch.tensor(
281 + [
282 + _local_count(int(seq_len), rank, world, interleave)
283 + for seq_len in seq_lens
284 + ],
285 + dtype=torch.int32,
286 + )
287 + torch.testing.assert_close(actual, expected)
288 +
289 +
290 +def test_get_dcp_local_seq_lens_preserves_mtp_bounds_shape():
291 + seq_lens = torch.tensor([[8, 9, 10], [11, 12, 13]], dtype=torch.int32)
292 + world = 2
293 + rank = 1
294 + interleave = 1
295 +
296 + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave)
297 + expected = torch.tensor(
298 + [
299 + [_local_count(int(seq_len), rank, world, interleave) for seq_len in row]
300 + for row in seq_lens
301 + ],
302 + dtype=torch.int32,
303 + )
304 +
305 + assert actual.shape == seq_lens.shape
306 + torch.testing.assert_close(actual, expected)
307 +
308 +
309 +def test_get_dcp_local_seq_lens_must_run_after_decode_expansion():
310 + world = 2
311 + rank = 1
312 + interleave = 1
313 + expanded_bounds = torch.tensor([8, 9, 10], dtype=torch.int32)
314 +
315 + localized_after_expansion = get_dcp_local_seq_lens(
316 + expanded_bounds, world, rank, interleave
317 + )
318 + localized_request_len_minus_offsets = get_dcp_local_seq_lens(
319 + torch.tensor([10], dtype=torch.int32), world, rank
320 + ) - torch.tensor([2, 1, 0], dtype=torch.int32)
321 +
322 + assert not torch.equal(
323 + localized_after_expansion, localized_request_len_minus_offsets
324 + )
325 + torch.testing.assert_close(
326 + localized_after_expansion, torch.tensor([4, 4, 5], dtype=torch.int32)
327 + )
328 +
329 +
330 +@pytest.mark.parametrize("interleave", [1, 2])
331 +def test_sparse_dcp_attention_matches_global_topk_attention(interleave: int):
332 + torch.manual_seed(0)
333 + world = 2
334 + topk = 3
335 + num_queries = 4
336 + max_seq_len = 13
337 + head_dim = 8
338 +
339 + q = torch.randn(num_queries, head_dim)
340 + k = torch.randn(max_seq_len, head_dim)
341 + v = torch.randn(max_seq_len, head_dim)
342 + seq_lens = torch.tensor([6, 8, 11, 13], dtype=torch.int64)
343 +
344 + scores = q @ k.T
345 + global_topk = torch.full((num_queries, topk), -1, dtype=torch.int64)
346 + for row, seq_len in enumerate(seq_lens.tolist()):
347 + global_topk[row, :topk] = scores[row, :seq_len].topk(topk).indices
348 +
349 + ref_out, ref_lse = _attention_from_indices(q, k, v, global_topk)
350 +
351 + local_outs = []
352 + local_lses = []
353 + local_topks = []
354 + for rank in range(world):
355 + owned = [
356 + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank
357 + ]
358 + k_local = k[owned]
359 + v_local = v[owned]
360 + local_topk = _global_to_local_indices(global_topk, rank, world, interleave)
361 + local_topks.append(local_topk)
362 + local_out, local_lse = _attention_from_indices(q, k_local, v_local, local_topk)
363 + local_outs.append(local_out)
364 + local_lses.append(local_lse)
365 +
366 + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses)
367 +
368 + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5)
369 + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5)
370 +
371 + gathered_global = torch.cat(
372 + [
373 + _local_to_global_indices(local_topks[rank], rank, world, interleave)
374 + for rank in range(world)
375 + ],
376 + dim=1,
377 + )
378 + assert set(gathered_global[gathered_global >= 0].tolist()) == set(
379 + global_topk.flatten().tolist()
380 + )
381 +
382 +
383 +def test_local_topk_union_is_not_equivalent_to_global_topk_attention():
384 + world = 2
385 + interleave = 1
386 + topk = 2
387 + q = torch.tensor([[1.0]])
388 + k = torch.tensor(
389 + [
390 + [1.00],
391 + [0.90],
392 + [0.95],
393 + [0.85],
394 + ]
395 + )
396 + v = torch.tensor([[0.0], [1000.0], [0.0], [1000.0]])
397 +
398 + scores = q @ k.T
399 + global_topk = scores.topk(topk, dim=-1).indices
400 + ref_out, ref_lse = _attention_from_indices(q, k, v, global_topk)
401 +
402 + local_topks = []
403 + for rank in range(world):
404 + owned = [
405 + pos for pos in range(k.shape[0]) if (pos // interleave) % world == rank
406 + ]
407 + local_topks.append(scores[:, owned].topk(topk, dim=-1).indices)
408 +
409 + local_union_out, local_union_lse = _dcp_attention_from_local_topks(
410 + q, k, v, local_topks, world, interleave
411 + )
412 +
413 + assert not torch.allclose(local_union_out, ref_out)
414 + assert not torch.allclose(local_union_lse, ref_lse)
415 +
416 +
417 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
418 +def test_sparse_decode_dcp_persistent_topk_matches_non_dcp():
419 + torch.manual_seed(3)
420 + device = torch.device("cuda")
421 + world = 2
422 + interleave = 1
423 + topk = 512
424 + num_rows = 2
425 + max_seq_len = 1025
426 + head_dim = 16
427 +
428 + q = torch.randn(num_rows, head_dim, device=device)
429 + k = torch.randn(max_seq_len, head_dim, device=device)
430 + v = torch.randn(max_seq_len, head_dim, device=device)
431 + logits = q @ k.T
432 + seq_lens = torch.tensor([[1024], [1025]], dtype=torch.int32, device=device)
433 +
434 + non_dcp_topk = torch.empty((num_rows, topk), dtype=torch.int64, device=device)
435 + for row, seq_len in enumerate(seq_lens.flatten().tolist()):
436 + non_dcp_topk[row] = logits[row, :seq_len].topk(topk).indices
437 + ref_out, ref_lse = _attention_from_indices(q, k, v, non_dcp_topk)
438 +
439 + local_logits = []
440 + local_topks = []
441 + for rank in range(world):
442 + owned = [
443 + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank
444 + ]
445 + rank_logits = logits[:, owned].contiguous()
446 + rank_seq_lens = get_dcp_local_seq_lens(
447 + seq_lens, world, rank, interleave
448 + ).contiguous()
449 + local_logits.append(rank_logits)
450 + local_topks.append(
451 + _run_persistent_topk(
452 + rank_logits,
453 + rank_seq_lens,
454 + topk,
455 + max_seq_len=rank_logits.shape[1],
456 + )
457 + )
458 +
459 + merged_global_topks = _merge_local_topks_global_with_fake_dcp(
460 + local_logits, local_topks, topk, world, interleave
461 + )
462 + # The radix top-K kernel selects a deterministic SET but writes it in
463 + # nondeterministic (atomicAdd) order; the production path is permutation-
464 + # invariant (compaction + softmax), so all ranks must agree on the set, not
465 + # the array order. (The fp64 fallback happens to return sorted order.)
466 + ref_topk = merged_global_topks[0]
467 + for rank_topk in merged_global_topks[1:]:
468 + for row in range(rank_topk.shape[0]):
469 + assert set(rank_topk[row].tolist()) == set(ref_topk[row].tolist())
470 +
471 + local_outs = []
472 + local_lses = []
473 + for rank, global_topk in enumerate(merged_global_topks):
474 + owned = [
475 + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank
476 + ]
477 + local_topk = _global_to_local_indices(
478 + global_topk.to(torch.int64),
479 + rank,
480 + world,
481 + interleave,
482 + )
483 + local_out, local_lse = _attention_from_indices(
484 + q, k[owned], v[owned], local_topk
485 + )
486 + local_outs.append(local_out)
487 + local_lses.append(local_lse)
488 +
489 + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses)
490 + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5)
491 + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5)
492 +
493 +
494 +@pytest.mark.skipif(
495 + not current_platform.is_cuda() or not has_cutedsl(),
496 + reason="This test requires CUDA and CuteDSL",
497 +)
498 +@pytest.mark.parametrize("use_row_starts", [False, True])
499 +def test_cutedsl_dcp_candidate_pack_and_select_matches_reference(
500 + use_row_starts: bool,
501 +):
502 + from vllm.model_executor.kernels.attention.dsa.dcp_indexer_cutedsl import (
503 + pack_dcp_topk_candidates_cutedsl,
504 + stable_topk_from_gathered_candidates_cutedsl,
505 + )
506 +
507 + torch.manual_seed(13)
508 + device = torch.device("cuda")
509 + rows = 4
510 + valid_width = 1024
511 + width = valid_width + (8 if use_row_starts else 0)
512 + topk = 512
513 + world = 2
514 + row_starts = (
515 + torch.tensor([0, 2, 4, 1], device=device, dtype=torch.int32)
516 + if use_row_starts
517 + else None
518 + )
519 + row_offsets = (
520 + row_starts
521 + if row_starts is not None
522 + else torch.zeros(rows, device=device, dtype=torch.int32)
523 + )
524 +
525 + packed_by_rank = []
526 + for rank in range(world):
527 + logits = torch.randn((rows, width), device=device, dtype=torch.float32)
528 + local_topks = []
529 + for row in range(rows):
530 + start = int(row_offsets[row].item())
531 + local_topks.append(
532 + logits[row, start : start + valid_width].topk(topk).indices
533 + )
534 + topk_indices = torch.stack(local_topks).to(torch.int32)
535 +
536 + packed = torch.empty((rows, topk, 2), device=device, dtype=torch.float32)
537 + pack_dcp_topk_candidates_cutedsl(
538 + logits,
539 + topk_indices,
540 + packed,
541 + rank,
542 + world,
543 + 1,
544 + row_starts,
545 + )
546 +
547 + expected_scores = logits.gather(
548 + 1, topk_indices.to(torch.long) + row_offsets.to(torch.long).view(-1, 1)
549 + )
550 + expected_ids = (topk_indices * world + rank).to(torch.float32)
551 + torch.testing.assert_close(packed[..., 0], expected_scores)
552 + torch.testing.assert_close(packed[..., 1], expected_ids)
553 + packed_by_rank.append(packed)
554 +
555 + gathered = torch.cat(packed_by_rank, dim=1).contiguous()
556 + actual = torch.empty((rows, topk), device=device, dtype=torch.int32)
557 + returned = stable_topk_from_gathered_candidates_cutedsl(gathered, topk, out=actual)
558 + assert returned is actual
559 + expected = _ref_stable_topk_from_candidates_fp64(
560 + gathered[..., 0],
561 + gathered[..., 1].to(torch.int32),
562 + topk,
563 + )
564 +
565 + for row in range(rows):
566 + assert set(actual[row].cpu().tolist()) == set(expected[row].cpu().tolist())
567 +
568 +
569 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
570 +def test_sparse_prefill_dcp_metadata_localizes_causal_bounds():
571 + device = torch.device("cuda")
572 + seq_len = 8
573 +
574 + query_start_loc = torch.tensor([0, seq_len], dtype=torch.int32, device=device)
575 + query_start_loc_cpu = torch.tensor([0, seq_len], dtype=torch.int32)
576 + seq_lens = torch.tensor([seq_len], dtype=torch.int32, device=device)
577 + seq_lens_cpu = torch.tensor([seq_len], dtype=torch.int32)
578 + block_table = torch.zeros((1, 1), dtype=torch.int32, device=device)
579 +
580 + def build(dcp_world_size, dcp_rank, interleave=1):
581 + chunk = build_prefill_chunk_metadata(
582 + start_idx=0,
583 + end_idx=1,
584 + query_start_loc=query_start_loc,
585 + query_start_loc_cpu=query_start_loc_cpu,
586 + uncompressed_seq_lens=seq_lens,
587 + compressed_seq_lens=seq_lens,
588 + compressed_seq_lens_cpu=seq_lens_cpu,
589 + block_table=block_table,
590 + compress_ratio=1,
591 + dcp_rank=dcp_rank,
592 + dcp_world_size=dcp_world_size,
593 + cp_kv_cache_interleave_size=interleave,
594 + )
595 + assert chunk is not None
596 + torch.accelerator.synchronize()
597 + return chunk
598 +
599 + # Non-DCP: local_cu_seq_lens aliases the global cu_seq_lens, and
600 + # cu_seqlen_ks/ke carry the global causal bounds.
601 + chunk = build(dcp_world_size=1, dcp_rank=0)
602 + assert chunk.local_cu_seq_lens is chunk.cu_seq_lens
603 + torch.testing.assert_close(
604 + chunk.cu_seqlen_ks.cpu(),
605 + torch.zeros(seq_len, dtype=torch.int32),
606 + )
607 + torch.testing.assert_close(
608 + chunk.cu_seqlen_ke.cpu(),
609 + torch.arange(1, seq_len + 1, dtype=torch.int32),
610 + )
611 +
612 + # DCP: cu_seqlen_ks/ke are localized in place to this rank's shard.
613 + chunk = build(dcp_world_size=4, dcp_rank=0)
614 + assert chunk.local_cu_seq_lens is not None
615 + torch.testing.assert_close(
616 + chunk.local_cu_seq_lens.cpu(),
617 + torch.tensor([0, 2], dtype=torch.int32),
618 + )
619 + torch.testing.assert_close(
620 + chunk.cu_seqlen_ks.cpu(),
621 + torch.zeros(seq_len, dtype=torch.int32),
622 + )
623 + torch.testing.assert_close(
624 + chunk.cu_seqlen_ke.cpu(),
625 + torch.tensor([1, 1, 1, 1, 2, 2, 2, 2], dtype=torch.int32),
626 + )
627 +
628 + # DCP with interleave=2: per-token causal bounds localize differently from
629 + # interleave=1 (groups of 2 consecutive tokens are owned together). For
630 + # world=4, rank=0, K=2, per-token global len L=1..8 -> local len
631 + # [1,2,2,2,2,2,2,2] (matches get_dcp_local_seq_lens).
632 + chunk = build(dcp_world_size=4, dcp_rank=0, interleave=2)
633 + assert chunk.local_cu_seq_lens is not None
634 + torch.testing.assert_close(
635 + chunk.cu_seqlen_ks.cpu(),
636 + torch.zeros(seq_len, dtype=torch.int32),
637 + )
638 + torch.testing.assert_close(
639 + chunk.cu_seqlen_ke.cpu(),
640 + torch.tensor([1, 2, 2, 2, 2, 2, 2, 2], dtype=torch.int32),
641 + )
642 +
643 +
644 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
645 +def test_dcp_filter_compacts_valid_slots_for_sparse_kernel():
646 + block_size = 4
647 + num_topk = 128
648 + dcp_size = 2
649 + req_id = torch.zeros(1, dtype=torch.int32, device="cuda")
650 + token_indices = torch.full((1, num_topk), -1, dtype=torch.int32, device="cuda")
651 + token_indices[0, :8] = torch.arange(8, dtype=torch.int32, device="cuda")
652 + block_table = torch.tensor([[10]], dtype=torch.int32, device="cuda")
653 +
654 + out, valid_counts = triton_filter_and_convert_dcp_index(
655 + req_id,
656 + block_table,
657 + token_indices,
658 + dcp_size=dcp_size,
659 + dcp_rank=0,
660 + BLOCK_SIZE=block_size,
661 + NUM_TOPK_TOKENS=num_topk,
662 + return_valid_counts=True,
663 + )
664 +
665 + valid = int(valid_counts.item())
666 + assert valid == 4
667 + assert (out[0, :valid] >= 0).all()
668 + assert (out[0, valid:] == -1).all()
669 + # In-kernel compaction packs valid slots to the front; prefix order is
670 + # unspecified, so compare as a set.
671 + assert set(out[0, :valid].cpu().tolist()) == {40, 41, 42, 43}
672 +
673 +
674 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
675 +@pytest.mark.parametrize("interleave", [1, 2])
676 +@pytest.mark.parametrize("dcp_rank", [0, 1])
677 +def test_dcp_filter_compaction_matches_reference(interleave: int, dcp_rank: int):
678 + """In-kernel compaction (atomic slot allocator across multiple column tiles)
679 + must, for every row, produce exactly the rank-owned physical slots packed
680 + into [0, valid_count) with -1 in the tail -- the same SET a reference filter
681 + + sort/gather produces. Uses wide rows (> BLOCK_N valid slots) so the
682 + cross-tile atomic allocation is exercised, with interior -1 gaps."""
683 + device = torch.device("cuda")
684 + torch.manual_seed(7)
685 + dcp_size = 2
686 + block_size = 8
687 + num_topk = 1024 # > BLOCK_N(128) -> multiple tiles per row
688 + num_rows = 5
689 + max_blocks = 64
690 + seq = max_blocks * block_size
691 +
692 + req_id = torch.randint(0, 3, (num_rows,), dtype=torch.int32, device=device)
693 + block_table = torch.randint(
694 + 0, 1000, (3, max_blocks), dtype=torch.int32, device=device
695 + )
696 + # Each row: a dense valid prefix of distinct global token ids, then -1 pad.
697 + token_indices = torch.full(
698 + (num_rows, num_topk), -1, dtype=torch.int32, device=device
699 + )
700 + for r in range(num_rows):
701 + n_valid = int(torch.randint(200, 600, (1,)).item())
702 + perm = torch.randperm(seq, device=device)[:n_valid].to(torch.int32)
703 + token_indices[r, :n_valid] = perm
704 +
705 + out, valid_counts = triton_filter_and_convert_dcp_index(
706 + req_id,
707 + block_table,
708 + token_indices,
709 + dcp_size=dcp_size,
710 + dcp_rank=dcp_rank,
711 + cp_kv_cache_interleave_size=interleave,
712 + BLOCK_SIZE=block_size,
713 + NUM_TOPK_TOKENS=num_topk,
714 + return_valid_counts=True,
715 + )
716 +
717 + for r in range(num_rows):
718 + toks = token_indices[r]
719 + toks = toks[toks >= 0]
720 + owner = (toks // interleave) % dcp_size
721 + owned = toks[owner == dcp_rank]
722 + local = (owned // (dcp_size * interleave)) * interleave + owned % interleave
723 + blk = local // block_size
724 + off = local % block_size
725 + expected = (
726 + block_table[int(req_id[r].item()), blk].to(torch.int64) * block_size + off
727 + )
728 + n = int(valid_counts[r].item())
729 + assert n == owned.numel()
730 + assert (out[r, :n] >= 0).all()
731 + assert (out[r, n:] == -1).all()
732 + assert set(out[r, :n].cpu().tolist()) == set(expected.cpu().tolist())
733 +
734 +
735 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
736 +@pytest.mark.parametrize("interleave", [1, 2, 4])
737 +def test_dcp_global_topk_physical_attention_matches_non_dcp(interleave: int):
738 + torch.manual_seed(2)
739 + device = torch.device("cuda")
740 + dcp_size = 2
741 + block_size = 4
742 + num_topk = 128
743 + selected_k = 8
744 + seq_len = 16
745 + head_dim = 16
746 + num_queries = 3
747 +
748 + q = torch.randn(num_queries, head_dim, device=device)
749 + k_global = torch.randn(seq_len, head_dim, device=device)
750 + v_global = torch.randn(seq_len, head_dim, device=device)
751 + scores = q @ k_global.T
752 + global_topk = torch.full(
753 + (num_queries, num_topk), -1, dtype=torch.int32, device=device
754 + )
755 + global_topk[:, :selected_k] = scores.topk(selected_k, dim=-1).indices.to(
756 + torch.int32
757 + )
758 +
759 + ref_out, ref_lse = _attention_from_indices(
760 + q, k_global, v_global, global_topk[:, :selected_k].to(torch.int64)
761 + )
762 +
763 + local_outs = []
764 + local_lses = []
765 + for rank in range(dcp_size):
766 + block_ids = torch.tensor(
767 + [[rank * 10 + 1, rank * 10 + 2]], dtype=torch.int32, device=device
768 + )
769 + num_slots = int((block_ids.max().item() + 1) * block_size)
770 + k_cache = torch.zeros(num_slots, head_dim, device=device)
771 + v_cache = torch.zeros(num_slots, head_dim, device=device)
772 + for global_idx in range(seq_len):
773 + if (global_idx // interleave) % dcp_size != rank:
774 + continue
775 + local_idx = (
776 + global_idx // (dcp_size * interleave)
777 + ) * interleave + global_idx % interleave
778 + block = local_idx // block_size
779 + offset = local_idx % block_size
780 + slot = int(block_ids[0, block].item()) * block_size + offset
781 + k_cache[slot] = k_global[global_idx]
782 + v_cache[slot] = v_global[global_idx]
783 +
784 + slots, valid_counts = triton_filter_and_convert_dcp_index(
785 + torch.zeros(num_queries, dtype=torch.int32, device=device),
786 + block_ids,
787 + global_topk,
788 + dcp_size=dcp_size,
789 + dcp_rank=rank,
790 + cp_kv_cache_interleave_size=interleave,
791 + BLOCK_SIZE=block_size,
792 + NUM_TOPK_TOKENS=num_topk,
793 + return_valid_counts=True,
794 + )
795 + row_ids = torch.arange(num_queries, device=device)
796 + assert (slots[row_ids, valid_counts] == -1).all()
797 + local_out, local_lse = _attention_from_indices(
798 + q, k_cache, v_cache, slots.to(torch.int64)
799 + )
800 + local_outs.append(local_out)
801 + local_lses.append(local_lse)
802 +
803 + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses)
804 + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5)
805 + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5)
806 +
807 +
808 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
809 +@pytest.mark.parametrize("is_lse_base_on_e", [True, False])
810 +def test_correct_attn_out_zeroes_empty_nan_partial(is_lse_base_on_e: bool):
811 + out = torch.full((1, 1, 4), float("nan"), device="cuda")
812 + lses = torch.tensor(
813 + [[[0.0]], [[float("-inf")]]],
814 + dtype=torch.float32,
815 + device="cuda",
816 + )
817 +
818 + corrected, final_lse = correct_attn_out(
819 + out,
820 + lses,
821 + cp_rank=1,
822 + ctx=CPTritonContext(),
823 + is_lse_base_on_e=is_lse_base_on_e,
824 + )
825 + torch.accelerator.synchronize()
826 +
827 + torch.testing.assert_close(corrected, torch.zeros_like(corrected))
828 + torch.testing.assert_close(final_lse, torch.zeros_like(final_lse))
829 +
830 +
831 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
832 +def test_decode_topk_pads_surplus_with_negative_one():
833 + """When a row's valid length < topk the top-k kernel must pad the surplus
834 + slots with -1: the DCP merge (`topk_indices >= 0`) and
835 + `triton_filter_and_convert_dcp_index` (`tok < 0`) both treat <0 as invalid,
836 + so a non-(-1) pad would be silently attended. This is the common case under
837 + DCP, where each rank's local seq_len = global / world is usually << topk.
838 + Checked on the *set* of valid indices (the kernel may order them by score,
839 + not position)."""
840 + torch.manual_seed(0)
841 + device = torch.device("cuda")
842 + topk = 8
843 + next_n = 1
844 + seq_lens_list = [0, 3, 5, 8]
845 + num_rows = len(seq_lens_list)
846 + logits = torch.randn(num_rows, 16, device=device)
847 + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=device).view(
848 + num_rows, next_n
849 + )
850 + idx = _run_decode_topk(logits, seq_lens, next_n, topk)
851 + for r, sl in enumerate(seq_lens_list):
852 + valid = idx[r][idx[r] >= 0]
853 + # seq_len <= topk, so top-k selects exactly the whole valid range.
854 + assert valid.numel() == min(sl, topk)
855 + assert set(valid.tolist()) == set(range(sl))
856 + assert (idx[r] == -1).sum().item() == topk - min(sl, topk)
857 +
858 +
859 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
860 +def test_persistent_topk_pads_surplus_with_negative_one():
861 + """Same surplus=-1 invariant for the persistent_topk kernel (k>=512)."""
862 + torch.manual_seed(0)
863 + device = torch.device("cuda")
864 + topk = 512 # persistent_topk requires k in {512, 1024, 2048}
865 + seq_lens_list = [100, 300, 512]
866 + num_rows = len(seq_lens_list)
867 + max_seq_len = 600
868 + logits = torch.randn(num_rows, max_seq_len, device=device)
869 + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=device).view(
870 + num_rows, 1
871 + )
872 + idx = _run_persistent_topk(logits, seq_lens, topk, max_seq_len)
873 + for r, sl in enumerate(seq_lens_list):
874 + valid = idx[r][idx[r] >= 0]
875 + assert valid.numel() == min(sl, topk)
876 + assert set(valid.tolist()) == set(range(sl))
877 + assert (idx[r] == -1).sum().item() == topk - min(sl, topk)
878 +
879 +
880 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA")
881 +def test_sparse_decode_dcp_short_context_matches_non_dcp():
882 + """End-to-end DCP decode where the global seq_len < topk (so every rank's
883 + local top-k is surplus-padded). Exercises the kernel surplus -> merge mask
884 + -> global top-k -> physical localize -> LSE merge chain for the common
885 + short-context decode case, vs the non-DCP reference."""
886 + torch.manual_seed(4)
887 + device = torch.device("cuda")
888 + world = 2
889 + interleave = 1
890 + topk = 512
891 + num_rows = 2
892 + max_seq_len = 300 # < topk -> surplus everywhere
893 + head_dim = 16
894 +
895 + q = torch.randn(num_rows, head_dim, device=device)
896 + k = torch.randn(max_seq_len, head_dim, device=device)
897 + v = torch.randn(max_seq_len, head_dim, device=device)
898 + logits = q @ k.T
899 + seq_lens = torch.tensor([[250], [300]], dtype=torch.int32, device=device)
900 +
901 + non_dcp_topk = torch.empty((num_rows, topk), dtype=torch.int64, device=device)
902 + for row, seq_len in enumerate(seq_lens.flatten().tolist()):
903 + sel = logits[row, :seq_len].topk(min(topk, seq_len)).indices
904 + non_dcp_topk[row, : sel.numel()] = sel
905 + non_dcp_topk[row, sel.numel() :] = -1
906 + ref_out, ref_lse = _attention_from_indices(q, k, v, non_dcp_topk)
907 +
908 + local_logits = []
909 + local_topks = []
910 + for rank in range(world):
911 + owned = [p for p in range(max_seq_len) if (p // interleave) % world == rank]
912 + rank_logits = logits[:, owned].contiguous()
913 + rank_seq_lens = get_dcp_local_seq_lens(seq_lens, world, rank, interleave)
914 + local_logits.append(rank_logits)
915 + local_topks.append(
916 + _run_persistent_topk(
917 + rank_logits,
918 + rank_seq_lens.contiguous(),
919 + topk,
920 + max_seq_len=rank_logits.shape[1],
921 + )
922 + )
923 +
924 + merged_global_topks = _merge_local_topks_global_with_fake_dcp(
925 + local_logits, local_topks, topk, world, interleave
926 + )
927 + # The radix top-K kernel selects a deterministic SET but writes it in
928 + # nondeterministic (atomicAdd) order; the production path is permutation-
929 + # invariant (compaction + softmax), so all ranks must agree on the set, not
930 + # the array order. (The fp64 fallback happens to return sorted order.)
931 + ref_topk = merged_global_topks[0]
932 + for rank_topk in merged_global_topks[1:]:
933 + for row in range(rank_topk.shape[0]):
934 + assert set(rank_topk[row].tolist()) == set(ref_topk[row].tolist())
935 +
936 + local_outs = []
937 + local_lses = []
938 + for rank, global_topk in enumerate(merged_global_topks):
939 + owned = [p for p in range(max_seq_len) if (p // interleave) % world == rank]
940 + local_topk = _global_to_local_indices(
941 + global_topk.to(torch.int64), rank, world, interleave
942 + )
943 + local_out, local_lse = _attention_from_indices(
944 + q, k[owned], v[owned], local_topk
945 + )
946 + local_outs.append(local_out)
947 + local_lses.append(local_lse)
948 +
949 + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses)
950 + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5)
951 + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5)
vllm/model_executor/kernels/attention/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/model_executor/kernels/attention/dsa/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/model_executor/kernels/attention/dsa/dcp_indexer_cutedsl.py new
+420
@@ -0,0 +1,420 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from functools import cache
5 +
6 +import cutlass
7 +import cutlass.cute as cute
8 +import torch
9 +from cuda.bindings.driver import CUstream
10 +from cutlass import Float32, Int32, Uint32, Uint64
11 +from quack.compile_utils import make_fake_tensor
12 +
13 +from vllm.cute_utils import recast_val
14 +from vllm.triton_utils import tl, triton
15 +
16 +
17 +def stable_topk_from_gathered_candidates_cutedsl(
18 + gathered: torch.Tensor,
19 + topk: int,
20 + out: torch.Tensor | None = None,
21 +) -> torch.Tensor:
22 + if out is None:
23 + out = torch.empty(
24 + (gathered.shape[0], topk),
25 + dtype=torch.int32,
26 + device=gathered.device,
27 + )
28 + StableTopKFromGatheredCandidatesKernel.compile(topk, gathered.shape[1])(
29 + gathered, out
30 + )
31 + return out
32 +
33 +
34 +def pack_dcp_topk_candidates_cutedsl(
35 + logits: torch.Tensor,
36 + topk_indices: torch.Tensor,
37 + packed: torch.Tensor,
38 + dcp_rank: int,
39 + dcp_world_size: int,
40 + cp_interleave: int,
41 + row_starts: torch.Tensor | None,
42 +) -> None:
43 + topk = topk_indices.shape[1]
44 + grid = (topk_indices.shape[0], triton.cdiv(topk, 512))
45 + row_starts_arg = row_starts if row_starts is not None else topk_indices
46 + _pack_dcp_topk_candidates_triton_kernel[grid](
47 + logits,
48 + topk_indices,
49 + packed,
50 + row_starts_arg,
51 + logits.stride(0),
52 + logits.stride(1),
53 + topk_indices.stride(0),
54 + topk_indices.stride(1),
55 + packed.stride(0),
56 + packed.stride(1),
57 + packed.stride(2),
58 + logits.shape[1],
59 + DCP_RANK=dcp_rank,
60 + DCP_WORLD_SIZE=dcp_world_size,
61 + CP_INTERLEAVE=cp_interleave,
62 + HAS_ROW_STARTS=row_starts is not None,
63 + TOPK=topk,
64 + BLOCK_SIZE=512,
65 + num_warps=8,
66 + )
67 +
68 +
69 +@triton.jit
70 +def _pack_dcp_topk_candidates_triton_kernel(
71 + logits,
72 + topk_indices,
73 + packed,
74 + row_starts,
75 + logits_stride0: tl.constexpr,
76 + logits_stride1: tl.constexpr,
77 + topk_stride0: tl.constexpr,
78 + topk_stride1: tl.constexpr,
79 + packed_stride0: tl.constexpr,
80 + packed_stride1: tl.constexpr,
81 + packed_stride2: tl.constexpr,
82 + num_cols,
83 + DCP_RANK: tl.constexpr,
84 + DCP_WORLD_SIZE: tl.constexpr,
85 + CP_INTERLEAVE: tl.constexpr,
86 + HAS_ROW_STARTS: tl.constexpr,
87 + TOPK: tl.constexpr,
88 + BLOCK_SIZE: tl.constexpr,
89 +):
90 + row = tl.program_id(0)
91 + tile = tl.program_id(1)
92 + cols = tile * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
93 + mask = cols < TOPK
94 +
95 + local_idx = tl.load(
96 + topk_indices + row * topk_stride0 + cols * topk_stride1,
97 + mask=mask,
98 + other=-1,
99 + )
100 + valid = local_idx >= 0
101 + safe_local_idx = tl.maximum(local_idx, 0)
102 +
103 + row_start = 0
104 + if HAS_ROW_STARTS:
105 + row_start = tl.load(row_starts + row)
106 +
107 + score_col = safe_local_idx + row_start
108 + score_col = tl.minimum(score_col, tl.maximum(num_cols - 1, 0))
109 + score = tl.load(
110 + logits + row * logits_stride0 + score_col * logits_stride1,
111 + mask=mask & valid,
112 + other=-float("inf"),
113 + )
114 +
115 + global_id = (
116 + (safe_local_idx // CP_INTERLEAVE) * (DCP_WORLD_SIZE * CP_INTERLEAVE)
117 + + DCP_RANK * CP_INTERLEAVE
118 + + safe_local_idx % CP_INTERLEAVE
119 + )
120 + global_id = tl.where(valid, global_id, -1)
121 +
122 + packed_base = packed + row * packed_stride0 + cols * packed_stride1
123 + tl.store(packed_base, score, mask=mask)
124 + tl.store(packed_base + packed_stride2, global_id.to(tl.float32), mask=mask)
125 +
126 +
127 +@cute.jit
128 +def _warp_scan_inclusive_i32(val: Int32, lane: Int32) -> Int32:
129 + for i in cutlass.range_constexpr(cute.arch.WARP_SIZE.bit_length() - 1):
130 + offset = 1 << i
131 + partial = cute.arch.shuffle_sync_up(val, offset=offset, mask_and_clamp=0)
132 + if lane >= offset:
133 + val += partial
134 + return val
135 +
136 +
137 +@cute.jit
138 +def _block_scan_inclusive_i32(
139 + val: Int32,
140 + lane: Int32,
141 + warp_id: Int32,
142 + warp_scratch: cute.Tensor,
143 + warps_per_block: int,
144 +) -> Int32:
145 + prefix = _warp_scan_inclusive_i32(val, lane)
146 + if lane == Int32(cute.arch.WARP_SIZE - 1):
147 + warp_scratch[0, warp_id] = prefix
148 + cute.arch.sync_threads()
149 +
150 + if warp_id == Int32(0):
151 + warp_total = Int32(0)
152 + if lane < Int32(warps_per_block):
153 + warp_total = warp_scratch[0, lane]
154 + warp_prefix = _warp_scan_inclusive_i32(warp_total, lane)
155 + if lane < Int32(warps_per_block):
156 + warp_scratch[0, lane] = warp_prefix - warp_total
157 + cute.arch.sync_threads()
158 +
159 + return prefix + warp_scratch[0, warp_id]
160 +
161 +
162 +class StableTopKFromGatheredCandidatesKernel:
163 + tb_size = 512
164 + hist_bins = 2048
165 + radix_bits = (hist_bins - 1).bit_length()
166 + assert hist_bins == 1 << radix_bits
167 + key_bits = Uint64.width
168 + radix_passes = (key_bits + radix_bits - 1) // radix_bits
169 + final_radix_bits = key_bits - radix_bits * (radix_passes - 1)
170 + hist_chunks = (hist_bins + tb_size - 1) // tb_size
171 + warps_per_block = tb_size // cute.arch.WARP_SIZE
172 +
173 + def __init__(self, topk: int, num_candidates: int):
174 + assert num_candidates % self.tb_size == 0, (
175 + "StableTopKFromGatheredCandidatesKernel requires candidate count "
176 + f"to be a multiple of {self.tb_size}, got {num_candidates}"
177 + )
178 + self.topk = topk
179 + self.keys_per_thread = num_candidates // self.tb_size
180 +
181 + @cute.struct
182 + class SharedStorage:
183 + hist: cute.struct.MemRange[Int32, self.hist_bins]
184 + committed_count: cute.struct.MemRange[Int32, 1]
185 + running_count: cute.struct.MemRange[Int32, 1]
186 + threshold_bin: cute.struct.MemRange[Int32, 1]
187 + threshold_found: cute.struct.MemRange[Int32, 1]
188 + include_threshold_bin: cute.struct.MemRange[Int32, 1]
189 + prefix_s: cute.struct.Align[cute.struct.MemRange[Uint64, 1], 8]
190 + warp_totals: cute.struct.MemRange[Int32, self.warps_per_block]
191 +
192 + self.shared_storage = SharedStorage
193 +
194 + @cute.jit
195 + def __call__(
196 + self,
197 + gathered: cute.Tensor,
198 + out: cute.Tensor,
199 + stream: CUstream,
200 + ):
201 + grid = (gathered.shape[0], 1, 1)
202 + self.kernel(gathered, out).launch(
203 + grid=grid,
204 + block=(self.tb_size, 1, 1),
205 + stream=stream,
206 + )
207 +
208 + @cute.jit
209 + def _stable_key(self, score: Float32, token_id: Int32) -> Uint64:
210 + bits = recast_val(score, Uint32)
211 + mask = Uint32(0x80000000)
212 + if (bits & Uint32(0x80000000)) != Uint32(0):
213 + mask = Uint32(0xFFFFFFFF)
214 + score_key = Uint64(bits ^ mask) << Uint64(32)
215 + id_key = Uint64(~Uint32(token_id))
216 + key = score_key | id_key
217 + if token_id < Int32(0):
218 + key = Uint64(0)
219 + return key
220 +
221 + @cute.jit
222 + def _prefix_matches(
223 + self,
224 + key: Uint64,
225 + prefix: Uint64,
226 + prefix_bits: Int32,
227 + ):
228 + matches = prefix_bits == Int32(0)
229 + if prefix_bits != Int32(0):
230 + shift = Int32(self.key_bits) - prefix_bits
231 + matches = (key >> Uint64(shift)) == (prefix >> Uint64(shift))
232 + return matches
233 +
234 + @cute.jit
235 + def _radix_pass(
236 + self,
237 + keys: cute.Tensor,
238 + output: cute.Tensor,
239 + storage,
240 + tid: Int32,
241 + step: Int32,
242 + bits: int,
243 + is_final_pass: bool,
244 + ):
245 + hist_smem = storage.hist.get_tensor(cute.make_layout((self.hist_bins,)))
246 + committed_count_smem = storage.committed_count.data_ptr()
247 + running_count_smem = storage.running_count.data_ptr()
248 + threshold_bin_smem = storage.threshold_bin.data_ptr()
249 + threshold_found_smem = storage.threshold_found.data_ptr()
250 + include_threshold_bin_smem = storage.include_threshold_bin.data_ptr()
251 + prefix_smem = storage.prefix_s.data_ptr()
252 + warp_totals_smem = storage.warp_totals.get_tensor(
253 + cute.make_layout((1, self.warps_per_block))
254 + )
255 +
256 + prefix_bits = step * Int32(self.radix_bits)
257 + num_bins = 1 << bits
258 + block_scan_iterations = (num_bins + self.tb_size - 1) // self.tb_size
259 + shift = Int32(self.key_bits) - prefix_bits - Int32(bits)
260 + bin_mask = Uint64(num_bins - 1)
261 + prefix = prefix_smem.load()
262 +
263 + for chunk in cutlass.range_constexpr(self.hist_chunks):
264 + hist_smem[tid + Int32(chunk * self.tb_size)] = Int32(0)
265 + if tid == Int32(0):
266 + running_count_smem.store(committed_count_smem.load())
267 + include_threshold_bin_smem.store(Int32(0))
268 + threshold_found_smem.store(Int32(0))
269 + cute.arch.sync_threads()
270 +
271 + for key_idx in cutlass.range_constexpr(self.keys_per_thread):
272 + key = keys[key_idx]
273 + if self._prefix_matches(key, prefix, prefix_bits):
274 + bin_idx = Int32((key >> Uint64(shift)) & bin_mask)
275 + cute.arch.atomic_add(
276 + hist_smem.iterator + bin_idx,
277 + Int32(1),
278 + sem="relaxed",
279 + scope="cta",
280 + )
281 + cute.arch.sync_threads()
282 +
283 + lane = cute.arch.lane_idx()
284 + warp_id = cute.arch.warp_idx()
285 + # Each iteration scans one tb_size-wide slice of bins, high to low.
286 + iter = Int32(0)
287 + threshold_found = threshold_found_smem.load()
288 + while threshold_found == Int32(0) and iter < Int32(block_scan_iterations):
289 + bin_idx = Int32(num_bins - 1) - (iter * Int32(self.tb_size) + tid)
290 + count = hist_smem[bin_idx]
291 + chunk_inclusive = _block_scan_inclusive_i32(
292 + count,
293 + lane,
294 + warp_id,
295 + warp_totals_smem,
296 + self.warps_per_block,
297 + )
298 + running_count = running_count_smem.load()
299 + prior_in_scan_slice = chunk_inclusive - count
300 + remaining = Int32(self.topk) - running_count - prior_in_scan_slice
301 + if count > Int32(0) and remaining > Int32(0) and remaining <= count:
302 + threshold_bin_smem.store(bin_idx)
303 + if count <= remaining or cutlass.const_expr(is_final_pass):
304 + include_threshold_bin_smem.store(Int32(1))
305 + threshold_found_smem.store(Int32(1))
306 + # Barrier: every thread must finish reading running_count for this
307 + # slice before tb_size-1 advances it, else a warp racing ahead to
308 + # the store makes a lagging thread double-count the slice total
309 + # (-> remaining too small -> threshold too high -> under-fill).
310 + cute.arch.sync_threads()
311 + if tid == Int32(self.tb_size - 1):
312 + running_count_smem.store(running_count + chunk_inclusive)
313 + cute.arch.sync_threads()
314 +
315 + threshold_found = threshold_found_smem.load()
316 + iter += Int32(1)
317 +
318 + threshold = threshold_bin_smem.load()
319 + should_include_threshold = include_threshold_bin_smem.load() != Int32(0)
320 + for key_idx in cutlass.range_constexpr(self.keys_per_thread):
321 + key = keys[key_idx]
322 + if self._prefix_matches(key, prefix, prefix_bits):
323 + bin_idx = Int32((key >> Uint64(shift)) & bin_mask)
324 + selected = bin_idx > threshold
325 + if should_include_threshold:
326 + selected = selected or bin_idx == threshold
327 + if selected:
328 + dst = cute.arch.atomic_add(
329 + committed_count_smem,
330 + Int32(1),
331 + sem="relaxed",
332 + scope="cta",
333 + )
334 + if dst < Int32(self.topk):
335 + output[dst] = recast_val(~Uint32(key), Int32)
336 + cute.arch.sync_threads()
337 +
338 + pass_finished = include_threshold_bin_smem.load()
339 + if tid == Int32(0) and pass_finished == Int32(0):
340 + prefix_smem.store(prefix | (Uint64(threshold) << Uint64(shift)))
341 + cute.arch.sync_threads()
342 + return pass_finished
343 +
344 + @cute.kernel
345 + def kernel(
346 + self,
347 + input: cute.Tensor,
348 + out: cute.Tensor,
349 + ):
350 + row, _, _ = cute.arch.block_idx()
351 + tid, _, _ = cute.arch.thread_idx()
352 + input_row = input[row, None, None]
353 + output_row = out[row, None]
354 + keys = cute.make_rmem_tensor((self.keys_per_thread,), Uint64)
355 +
356 + smem = cutlass.utils.SmemAllocator()
357 + storage = smem.allocate(self.shared_storage, 8)
358 + committed_count_smem = storage.committed_count.data_ptr()
359 + prefix_smem = storage.prefix_s.data_ptr()
360 + for i in range(tid, self.topk, self.tb_size):
361 + output_row[i] = Int32(-1)
362 +
363 + for key_idx in cutlass.range_constexpr(self.keys_per_thread):
364 + col = tid + Int32(key_idx * self.tb_size)
365 + score = Float32(input_row[col, 0])
366 + token_id = Int32(input_row[col, 1])
367 + keys[key_idx] = self._stable_key(score, token_id)
368 +
369 + if tid == Int32(0):
370 + committed_count_smem.store(Int32(0))
371 + prefix_smem.store(Uint64(0))
372 + cute.arch.sync_threads()
373 +
374 + step = Int32(0)
375 + finished = Int32(0)
376 + while finished == Int32(0) and step < Int32(self.radix_passes - 1):
377 + finished = self._radix_pass(
378 + keys,
379 + output_row,
380 + storage,
381 + tid,
382 + step,
383 + self.radix_bits,
384 + False,
385 + )
386 + step += Int32(1)
387 +
388 + if finished == Int32(0):
389 + self._radix_pass(
390 + keys,
391 + output_row,
392 + storage,
393 + tid,
394 + Int32(self.radix_passes - 1),
395 + self.final_radix_bits,
396 + True,
397 + )
398 +
399 + @cache
400 + @staticmethod
401 + def compile(topk: int, num_candidates: int):
402 + num_rows = cute.sym_int()
403 +
404 + gathered = cute.runtime.make_fake_tensor(
405 + Float32,
406 + (num_rows, num_candidates, 2),
407 + stride=(cute.sym_int64(divisibility=2), 2, 1),
408 + assumed_align=8,
409 + )
410 + out = make_fake_tensor(Int32, (num_rows, topk), divisibility=1)
411 +
412 + kernel = StableTopKFromGatheredCandidatesKernel(topk, num_candidates)
413 + stream = cute.runtime.make_fake_stream(use_tvm_ffi_env_stream=True)
414 + return cute.compile(
415 + kernel,
416 + gathered,
417 + out,
418 + stream,
419 + options="--enable-tvm-ffi",
420 + )
vllm/model_executor/layers/sparse_attn_indexer.py
+167 -44
@@ -8,6 +8,8 @@ import vllm.envs as envs
8 from vllm import _custom_ops as ops
9 from vllm._aiter_ops import rocm_aiter_ops
10 from vllm.compilation.breakable_cudagraph import eager_break_during_capture
11 +from vllm.config import get_current_vllm_config
12 +from vllm.distributed import get_dcp_group
13 from vllm.forward_context import get_forward_context
14 from vllm.logger import init_logger
15 from vllm.model_executor.custom_op import CustomOp
@@ -21,6 +23,7 @@ from vllm.utils.deep_gemm import (
23 fp8_fp4_paged_mqa_logits,
24 has_deep_gemm,
25 )
26 +from vllm.utils.import_utils import has_cutedsl
27 from vllm.utils.torch_utils import (
28 LayerNameType,
29 _encode_layer_name,
@@ -41,6 +44,85 @@ RADIX_TOPK_WORKSPACE_SIZE = 1024 * 1024
44 MXFP4_BLOCK_SIZE = 32
45
46
47 +def _assert_cutedsl_dcp_merge_supported(
48 + logits: torch.Tensor,
49 + topk_indices: torch.Tensor,
50 + k: int,
51 +) -> None:
52 + # The DCP merge only supports the CuteDSL path (Triton pack kernel + CuteDSL
53 + # stable-topk selector); there is no PyTorch fallback. The first cut targets
54 + # Blackwell/Hopper with index_topk in (512, 1024, 2048) (the selector's radix
55 + # sizing); the Triton pack itself has no shape/topk constraints.
56 + if not has_cutedsl():
57 + raise RuntimeError(
58 + "DCP sparse-indexer merge requires CuteDSL; install it or disable DCP."
59 + )
60 + if logits.device.type != "cuda":
61 + raise RuntimeError("DCP sparse-indexer merge requires CUDA tensors.")
62 + if logits.dtype != torch.float32 or topk_indices.dtype != torch.int32:
63 + raise RuntimeError(
64 + "DCP sparse-indexer merge requires fp32 logits and int32 indices."
65 + )
66 + if k not in (512, 1024, 2048):
67 + raise RuntimeError(
68 + f"DCP sparse-indexer merge requires index_topk in (512, 1024, 2048); "
69 + f"got {k}."
70 + )
71 +
72 +
73 +def _merge_dcp_topk_global(
74 + logits: torch.Tensor,
75 + topk_indices: torch.Tensor,
76 + topk_tokens: int,
77 + dcp_rank: int,
78 + dcp_world_size: int,
79 + cp_interleave: int,
80 + row_starts: torch.Tensor | None = None,
81 +) -> None:
82 + """Merge each DCP rank's local top-K into the global top-K.
83 +
84 + ``topk_indices`` are this rank's local top-K positions into its 1/N KV
85 + shard. A token in the global top-K must also be in its owning rank's local
86 + top-K (at most ``topk_tokens - 1`` tokens rank globally above it, hence at
87 + most that many on its own rank), so exchanging only the per-rank local
88 + candidates is exact -- equivalent to all-gathering the full logit matrix,
89 + but it ships ``dcp_world_size * topk_tokens`` candidates instead of the whole
90 + score row. Overwrites ``topk_indices`` with global token ids (``-1`` for
91 + padding); the attention backend localizes them back to physical slots per
92 + rank.
93 + """
94 + if dcp_world_size <= 1:
95 + return
96 +
97 + # CuteDSL-only path (no PyTorch fallback): Triton-pack each rank's
98 + # (score, global_id) candidates on-device, all-gather, then the CuteDSL
99 + # stable-topk selector.
100 + _assert_cutedsl_dcp_merge_supported(logits, topk_indices, topk_tokens)
101 + from vllm.model_executor.kernels.attention.dsa.dcp_indexer_cutedsl import (
102 + pack_dcp_topk_candidates_cutedsl,
103 + stable_topk_from_gathered_candidates_cutedsl,
104 + )
105 +
106 + packed = torch.empty(
107 + (*topk_indices.shape, 2),
108 + dtype=torch.float32,
109 + device=topk_indices.device,
110 + )
111 + pack_dcp_topk_candidates_cutedsl(
112 + logits,
113 + topk_indices,
114 + packed,
115 + dcp_rank,
116 + dcp_world_size,
117 + cp_interleave,
118 + row_starts,
119 + )
120 + gathered = get_dcp_group().all_gather(packed, dim=1)
121 + stable_topk_from_gathered_candidates_cutedsl(
122 + gathered, topk_tokens, out=topk_indices
123 + )
124 +
125 +
126 @triton.jit
127 def _fused_indexer_q_rope_quant_kernel(
128 positions,
@@ -227,6 +309,9 @@ def sparse_attn_indexer(
309 topk_indices_buffer: torch.Tensor,
310 skip_k_cache_insert: bool,
311 use_fp4_cache: bool = False,
312 + dcp_rank: int = 0,
313 + dcp_world_size: int = 1,
314 + cp_kv_cache_interleave_size: int = 1,
315 skip_topk_buffer_clear: bool = False,
316 ) -> torch.Tensor:
317 # careful! this will be None in dummy run
@@ -328,16 +413,18 @@ def sparse_attn_indexer(
413 scales_spec,
414 )
415 for chunk in prefill_metadata.chunks:
331 - k_quant = k_quant_full[: chunk.total_seq_lens]
332 - k_scale = k_scale_full[: chunk.total_seq_lens]
333 -
334 - if not chunk.skip_kv_gather:
416 + cu_seqlen_ks = chunk.cu_seqlen_ks
417 + cu_seqlen_ke = chunk.cu_seqlen_ke
418 + assert chunk.local_cu_seq_lens is not None
419 + k_quant = k_quant_full[: chunk.max_local_total_seq_lens]
420 + k_scale = k_scale_full[: chunk.max_local_total_seq_lens]
421 + if not chunk.skip_kv_gather and chunk.local_total_seq_lens > 0:
422 ops.cp_gather_indexer_k_quant_cache(
423 kv_cache,
424 k_quant,
425 k_scale,
426 chunk.block_table,
340 - chunk.cu_seq_lens,
427 + chunk.local_cu_seq_lens,
428 )
429
430 q_slice = q_quant[chunk.token_start : chunk.token_end]
@@ -346,51 +433,64 @@ def sparse_attn_indexer(
433 if q_scale is not None
434 else None
435 )
349 - # DeepGEMM scalar-type tags (zero-copy): MXFP4 values → int8
350 - # (kPackedFP4), scales → int32 squeezed to 1-D kv_sf / 2-D q_sf.
351 - if use_fp4_cache:
352 - q_slice_cast = q_slice.view(torch.int8)
353 - k_quant_cast = k_quant.view(torch.int8)
354 - k_scale_cast = k_scale.view(torch.int32).squeeze(-1)
355 - else:
356 - q_slice_cast = q_slice
357 - k_quant_cast = k_quant
358 - k_scale_cast = k_scale.view(torch.float32).squeeze(-1)
359 - if current_platform.is_xpu():
360 - if q_scale_slice is not None:
361 - raise RuntimeError("XPU fp8_mqa_logits does not support FP4 Q")
362 - logits = torch.ops.vllm.xpu_fp8_mqa_logits(
363 - q_slice_cast,
364 - k_quant_cast,
365 - k_scale_cast,
366 - weights[chunk.token_start : chunk.token_end],
367 - chunk.cu_seqlen_ks,
368 - chunk.cu_seqlen_ke,
369 - )
370 - else:
371 - logits = fp8_fp4_mqa_logits(
372 - (q_slice_cast, q_scale_slice),
373 - (k_quant_cast, k_scale_cast),
374 - weights[chunk.token_start : chunk.token_end],
375 - chunk.cu_seqlen_ks,
376 - chunk.cu_seqlen_ke,
377 - clean_logits=False,
378 - )
379 - num_rows = logits.shape[0]
380 -
436 topk_indices = topk_indices_buffer[
437 chunk.token_start : chunk.token_end, :topk_tokens
438 ]
439
385 - ops.top_k_per_row_prefill(
440 + if chunk.local_total_seq_lens == 0:
441 + logits = q_slice.new_empty((q_slice.shape[0], 0), dtype=torch.float32)
442 + topk_indices.fill_(-1)
443 + else:
444 + # DeepGEMM scalar-type tags (zero-copy): MXFP4 values → int8
445 + # (kPackedFP4), scales → int32 squeezed to 1-D kv_sf / 2-D q_sf.
446 + if use_fp4_cache:
447 + q_slice_cast = q_slice.view(torch.int8)
448 + k_quant_cast = k_quant.view(torch.int8)
449 + k_scale_cast = k_scale.view(torch.int32).squeeze(-1)
450 + else:
451 + q_slice_cast = q_slice
452 + k_quant_cast = k_quant
453 + k_scale_cast = k_scale.view(torch.float32).squeeze(-1)
454 + if current_platform.is_xpu():
455 + if q_scale_slice is not None:
456 + raise RuntimeError("XPU fp8_mqa_logits does not support FP4 Q")
457 + logits = torch.ops.vllm.xpu_fp8_mqa_logits(
458 + q_slice_cast,
459 + k_quant_cast,
460 + k_scale_cast,
461 + weights[chunk.token_start : chunk.token_end],
462 + cu_seqlen_ks,
463 + cu_seqlen_ke,
464 + )
465 + else:
466 + logits = fp8_fp4_mqa_logits(
467 + (q_slice_cast, q_scale_slice),
468 + (k_quant_cast, k_scale_cast),
469 + weights[chunk.token_start : chunk.token_end],
470 + cu_seqlen_ks,
471 + cu_seqlen_ke,
472 + clean_logits=False,
473 + )
474 + num_rows = logits.shape[0]
475 + ops.top_k_per_row_prefill(
476 + logits,
477 + cu_seqlen_ks,
478 + cu_seqlen_ke,
479 + topk_indices,
480 + num_rows,
481 + logits.stride(0),
482 + logits.stride(1),
483 + topk_tokens,
484 + )
485 +
486 + _merge_dcp_topk_global(
487 logits,
387 - chunk.cu_seqlen_ks,
388 - chunk.cu_seqlen_ke,
488 topk_indices,
390 - num_rows,
391 - logits.stride(0),
392 - logits.stride(1),
489 topk_tokens,
490 + dcp_rank,
491 + dcp_world_size,
492 + cp_kv_cache_interleave_size,
493 + row_starts=chunk.cu_seqlen_ks,
494 )
495
496 if has_decode:
@@ -508,7 +608,7 @@ def sparse_attn_indexer(
608 topk_indices,
609 topk_workspace,
610 topk_tokens,
511 - attn_metadata_narrowed.max_seq_len,
611 + logits.shape[1],
612 )
613 else:
614 ops.top_k_per_row_decode(
@@ -522,6 +622,16 @@ def sparse_attn_indexer(
622 topk_tokens,
623 )
624
625 + if decode_metadata.global_seq_lens is not None:
626 + _merge_dcp_topk_global(
627 + logits,
628 + topk_indices,
629 + topk_tokens,
630 + dcp_rank,
631 + dcp_world_size,
632 + cp_kv_cache_interleave_size,
633 + )
634 +
635 if decode_metadata.requires_padding:
636 # if padded, we need to unpack
637 # the topk indices removing padded tokens
@@ -553,6 +663,9 @@ def sparse_attn_indexer_fake(
663 topk_indices_buffer: torch.Tensor | None,
664 skip_k_cache_insert: bool,
665 use_fp4_cache: bool = False,
666 + dcp_rank: int = 0,
667 + dcp_world_size: int = 1,
668 + cp_kv_cache_interleave_size: int = 1,
669 skip_topk_buffer_clear: bool = False,
670 ) -> torch.Tensor:
671 return topk_indices_buffer
@@ -604,6 +717,13 @@ class SparseAttnIndexer(CustomOp):
717 self.topk_indices_buffer = topk_indices_buffer
718 self.skip_k_cache_insert = skip_k_cache_insert
719 self.use_fp4_cache = use_fp4_cache
720 + # DCP scalars are constant for the run; resolve them here (config is set
721 + # during model construction) and pass them into the custom op, rather
722 + # than threading them through per-step metadata.
723 + parallel_config = get_current_vllm_config().parallel_config
724 + self.dcp_world_size = parallel_config.decode_context_parallel_size
725 + self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
726 + self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
727 if current_platform.is_cuda() and not has_deep_gemm():
728 raise RuntimeError(
729 "Sparse Attention Indexer CUDA op requires DeepGEMM support in "
@@ -657,6 +777,9 @@ class SparseAttnIndexer(CustomOp):
777 self.topk_indices_buffer,
778 self.skip_k_cache_insert,
779 self.use_fp4_cache,
780 + self.dcp_rank,
781 + self.dcp_world_size,
782 + self.cp_kv_cache_interleave_size,
783 )
784
785 def forward_xpu(
vllm/models/deepseek_v32/nvidia/attention.py
+2 -1
@@ -496,7 +496,8 @@ class DeepseekV32Attention(MLAAttention):
496 self.topk_indices_buffer,
497 True, # skip_k_cache_insert
498 False, # use_fp4_cache
499 - True, # skip_topk_buffer_clear (fused_norm_rope already did it)
499 + # fused_norm_rope already cleared the topk buffer this forward.
500 + skip_topk_buffer_clear=True,
501 )
502
503 if attn_metadata is None:
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
+104 -17
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, ClassVar
8 import numpy as np
9 import torch
10
11 +from vllm import envs
12 from vllm.config import VllmConfig
13 from vllm.config.cache import CacheDType
14 from vllm.logger import init_logger
@@ -29,8 +30,12 @@ from vllm.v1.attention.backend import (
30 )
31 from vllm.v1.attention.backends.mla.sparse_utils import (
32 triton_convert_req_index_to_global_index,
33 + triton_filter_and_convert_dcp_index,
34 +)
35 +from vllm.v1.attention.backends.utils import (
36 + KVCacheLayoutType,
37 + split_decodes_and_prefills,
38 )
33 -from vllm.v1.attention.backends.utils import KVCacheLayoutType
39 from vllm.v1.kv_cache_interface import AttentionSpec
40
41 if TYPE_CHECKING:
@@ -38,8 +43,6 @@ if TYPE_CHECKING:
43
44 logger = init_logger(__name__)
45
41 -FLASHINFER_MLA_SPARSE_WORKSPACE_BUFFER_SIZE = 128 * 1024 * 1024
42 -
46
47 class _FlashInferMLASparseBackendBase(AttentionBackend):
48 """Common metadata for concrete FlashInfer sparse MLA backends."""
@@ -252,10 +255,13 @@ class FlashInferMLASparseMetadata(AttentionMetadata):
255
256 # Sequence lengths for all requests (context + query)
257 seq_lens: torch.Tensor
258 + num_decodes: int
259 + num_decode_tokens: int
260
261 # Sparse-specific
262 block_size: int = 64
263 topk_tokens: int = 2048
264 + cp_kv_cache_interleave_size: int = 1
265
266
267 class FlashInferMLASparseMetadataBuilder(
@@ -281,6 +287,12 @@ class FlashInferMLASparseMetadataBuilder(
287 self.mla_dims = get_mla_dims(self.model_config)
288 self.topk_tokens = vllm_config.model_config.hf_config.index_topk
289
290 + self._init_reorder_batch_threshold(
291 + 1,
292 + supports_spec_as_decode=True,
293 + supports_dcp_with_varlen=True,
294 + )
295 +
296 self.req_id_per_token_buffer = torch.empty(
297 (vllm_config.scheduler_config.max_num_batched_tokens,),
298 dtype=torch.int32,
@@ -295,6 +307,12 @@ class FlashInferMLASparseMetadataBuilder(
307 ) -> FlashInferMLASparseMetadata:
308 cm = common_attn_metadata
309 num_tokens = cm.num_actual_tokens
310 + assert self.reorder_batch_threshold is not None
311 + num_decodes, _, num_decode_tokens, _ = split_decodes_and_prefills(
312 + cm,
313 + decode_threshold=self.reorder_batch_threshold,
314 + treat_short_extends_as_decodes=True,
315 + )
316
317 # Build req_id_per_token mapping
318 starts = np.asarray(cm.query_start_loc_cpu, dtype=np.int32)
@@ -320,8 +338,13 @@ class FlashInferMLASparseMetadataBuilder(
338 block_table=cm.block_table_tensor,
339 req_id_per_token=req_id_per_token_tensor,
340 seq_lens=cm.seq_lens,
341 + num_decodes=num_decodes,
342 + num_decode_tokens=num_decode_tokens,
343 block_size=self.kv_cache_spec.block_size,
344 topk_tokens=self.topk_tokens,
345 + cp_kv_cache_interleave_size=(
346 + self.vllm_config.parallel_config.cp_kv_cache_interleave_size
347 + ),
348 )
349
350
@@ -333,7 +356,7 @@ def _get_workspace_buffer(device: torch.device) -> torch.Tensor:
356 global _fi_sparse_workspace
357 if _fi_sparse_workspace is None:
358 _fi_sparse_workspace = torch.zeros(
336 - FLASHINFER_MLA_SPARSE_WORKSPACE_BUFFER_SIZE,
359 + envs.VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE,
360 dtype=torch.uint8,
361 device=device,
362 )
@@ -347,6 +370,9 @@ class FlashInferMLASparseImpl(SparseMLAAttentionImpl[FlashInferMLASparseMetadata
370 sparse attention computation.
371 """
372
373 + can_return_lse_for_decode: bool = True
374 + lse_base_on_e: bool = False
375 +
376 def __init__(
377 self,
378 num_heads: int,
@@ -421,14 +447,27 @@ class FlashInferMLASparseImpl(SparseMLAAttentionImpl[FlashInferMLASparseMetadata
447 assert self.topk_indices_buffer is not None
448 topk_indices = self.topk_indices_buffer[:num_actual_toks]
449
424 - topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index(
425 - attn_metadata.req_id_per_token[:num_actual_toks],
426 - attn_metadata.block_table,
427 - topk_indices,
428 - BLOCK_SIZE=attn_metadata.block_size,
429 - NUM_TOPK_TOKENS=topk_indices.shape[1],
430 - return_valid_counts=True,
431 - )
450 + if self.dcp_world_size > 1:
451 + topk_indices_physical, seq_lens = triton_filter_and_convert_dcp_index(
452 + attn_metadata.req_id_per_token[:num_actual_toks],
453 + attn_metadata.block_table,
454 + topk_indices,
455 + dcp_size=self.dcp_world_size,
456 + dcp_rank=self.dcp_rank,
457 + cp_kv_cache_interleave_size=(attn_metadata.cp_kv_cache_interleave_size),
458 + BLOCK_SIZE=attn_metadata.block_size,
459 + NUM_TOPK_TOKENS=topk_indices.shape[1],
460 + return_valid_counts=True,
461 + )
462 + else:
463 + topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index(
464 + attn_metadata.req_id_per_token[:num_actual_toks],
465 + attn_metadata.block_table,
466 + topk_indices,
467 + BLOCK_SIZE=attn_metadata.block_size,
468 + NUM_TOPK_TOKENS=topk_indices.shape[1],
469 + return_valid_counts=True,
470 + )
471
472 if self._workspace_buffer is None:
473 self._workspace_buffer = _get_workspace_buffer(q.device)
@@ -444,18 +483,66 @@ class FlashInferMLASparseImpl(SparseMLAAttentionImpl[FlashInferMLASparseMetadata
483
484 from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
485
447 - o = trtllm_batch_decode_with_kv_cache_mla(
448 - query=q.unsqueeze(1),
486 + # Single-token sparse decode. trtllm-gen requires the q_len_per_request
487 + # dim, but the sparse attention mask is fully per-token (each query token
488 + # carries its own top-k index row), so unsqueeze is sufficient and
489 + # correct. The MTP/multi-token q_len grouping is a perf-only layout and is
490 + # deferred until MTP is validated end-to-end for this backend.
491 + query = q.unsqueeze(1)
492 + block_tables = topk_indices_physical.unsqueeze(1)
493 + seq_lens_arg = seq_lens
494 +
495 + kernel_out = trtllm_batch_decode_with_kv_cache_mla(
496 + query=query,
497 kv_cache=kv_c_and_k_pe_cache.unsqueeze(1),
498 workspace_buffer=self._workspace_buffer,
499 qk_nope_head_dim=self.qk_nope_head_dim,
500 kv_lora_rank=self.kv_lora_rank,
501 qk_rope_head_dim=self.qk_rope_head_dim,
454 - block_tables=topk_indices_physical.unsqueeze(1),
455 - seq_lens=seq_lens,
502 + block_tables=block_tables,
503 + seq_lens=seq_lens_arg,
504 max_seq_len=attn_metadata.topk_tokens,
505 bmm1_scale=self.bmm1_scale,
506 bmm2_scale=self.bmm2_scale,
507 sparse_mla_top_k=attn_metadata.topk_tokens,
508 + return_lse=self.need_to_return_lse_for_decode,
509 )
461 - return o.view(-1, o.shape[-2], o.shape[-1]), None
510 + if self.need_to_return_lse_for_decode:
511 + assert isinstance(kernel_out, tuple)
512 + o, lse = kernel_out
513 + else:
514 + assert isinstance(kernel_out, torch.Tensor)
515 + o = kernel_out
516 + lse = None
517 +
518 + out = o.view(-1, o.shape[-2], o.shape[-1])
519 + if lse is not None:
520 + lse = self._normalize_lse(lse, out.shape[0], out.shape[1])
521 + empty_rows = (topk_indices_physical == -1).all(dim=-1)
522 + out.masked_fill_(empty_rows.view(-1, 1, 1), 0.0)
523 + lse.masked_fill_(empty_rows.view(-1, 1), float("-inf"))
524 + return out, lse
525 +
526 + @staticmethod
527 + def _normalize_lse(
528 + lse: torch.Tensor,
529 + num_tokens: int,
530 + num_heads: int,
531 + ) -> torch.Tensor:
532 + # FlashInfer returns the decode LSE either as 2D (num_tokens, num_heads)
533 + # or 3D ((num_tokens, num_heads, 1) / (num_tokens, 1, num_heads)).
534 + # Collapse all of these to the (num_tokens, num_heads) the shared DCP
535 + # reducer expects.
536 + if lse.dim() == 3:
537 + if lse.shape[-1] == 1:
538 + lse = lse.squeeze(-1)
539 + elif lse.shape[1] == 1:
540 + lse = lse.squeeze(1)
541 + elif lse.shape[0] * lse.shape[1] == num_tokens:
542 + lse = lse.reshape(num_tokens, lse.shape[-1])
543 + if lse.shape != (num_tokens, num_heads):
544 + raise RuntimeError(
545 + "Unexpected FlashInfer sparse MLA LSE shape: "
546 + f"{tuple(lse.shape)}, expected ({num_tokens}, {num_heads})."
547 + )
548 + return lse
vllm/v1/attention/backends/mla/indexer.py
+176 -12
@@ -6,6 +6,7 @@ import torch
6
7 import vllm.envs as envs
8 from vllm.config import VllmConfig
9 +from vllm.distributed import get_dcp_group
10 from vllm.logger import init_logger
11 from vllm.platforms import current_platform
12 from vllm.triton_utils import tl, triton
@@ -24,6 +25,7 @@ from vllm.v1.attention.backend import (
25 )
26 from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping
27 from vllm.v1.attention.backends.utils import (
28 + get_dcp_local_seq_lens,
29 split_decodes_and_prefills,
30 )
31 from vllm.v1.kv_cache_interface import AttentionSpec, MLAAttentionSpec
@@ -168,6 +170,8 @@ class DeepseekV4IndexerBackend(DeepseekV32IndexerBackend):
170 @dataclass
171 class DeepseekV32IndexerPrefillChunkMetadata:
172 block_table: torch.Tensor
173 + # Under DCP (dcp_world_size > 1) these hold this rank's local row bounds;
174 + # otherwise they hold the global bounds.
175 cu_seqlen_ks: torch.Tensor
176 cu_seqlen_ke: torch.Tensor
177 cu_seq_lens: torch.Tensor
@@ -177,6 +181,9 @@ class DeepseekV32IndexerPrefillChunkMetadata:
181 token_end: int
182 num_reqs: int
183 skip_kv_gather: bool = False
184 + local_cu_seq_lens: torch.Tensor | None = None
185 + local_total_seq_lens: int = 0
186 + max_local_total_seq_lens: int = 0
187
188
189 @dataclass
@@ -195,6 +202,7 @@ class DeepSeekV32IndexerDecodeMetadata:
202 decode_lens: torch.Tensor
203 requires_padding: bool
204 schedule_metadata: torch.Tensor
205 + global_seq_lens: torch.Tensor | None = None
206
207
208 @dataclass
@@ -243,6 +251,19 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
251 def __init__(self, *args, **kwargs):
252 super().__init__(*args, **kwargs)
253 scheduler_config = self.vllm_config.scheduler_config
254 + parallel_config = self.vllm_config.parallel_config
255 + self.dcp_world_size = parallel_config.decode_context_parallel_size
256 + self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
257 + self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
258 + # The DCP sparse-indexer code is parameterized by interleave size, but
259 + # interleave > 1 is not yet validated end-to-end (gsm8k parity fails),
260 + # so fail closed here rather than silently produce wrong output.
261 + if self.dcp_world_size > 1 and self.cp_kv_cache_interleave_size > 1:
262 + raise NotImplementedError(
263 + "DCP sparse indexer currently supports only "
264 + f"cp_kv_cache_interleave_size=1 (got "
265 + f"{self.cp_kv_cache_interleave_size})."
266 + )
267 # NOTE(Chen):an estimated max size of flattened_kv. Need to double check.
268 self.max_prefill_buffer_size = get_max_prefill_buffer_size(self.vllm_config)
269 self.num_speculative_tokens = (
@@ -300,6 +321,11 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
321 dtype=torch.int32,
322 device=self.device,
323 )
324 + self.global_decode_seq_lens_buffer = torch.zeros(
325 + (scheduler_config.max_num_batched_tokens,),
326 + dtype=torch.int32,
327 + device=self.device,
328 + )
329 self.arange_buffer = torch.arange(
330 max(
331 scheduler_config.max_num_seqs * next_n,
@@ -331,6 +357,11 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
357 # Get compress_ratio for DeepseekV4 support
358 if isinstance(self.kv_cache_spec, MLAAttentionSpec):
359 self.compress_ratio = self.kv_cache_spec.compress_ratio
360 + if self.dcp_world_size > 1 and self.compress_ratio > 1:
361 + raise NotImplementedError(
362 + "DCP is not supported with sparse indexer KV compression "
363 + f"(compress_ratio={self.compress_ratio})."
364 + )
365
366 # Pre-allocate buffers for CUDA graph compatibility when
367 if self.compress_ratio > 1:
@@ -348,6 +379,26 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
379 device=self.device,
380 )
381
382 + def _dcp_localize_decode_seq_lens(
383 + self,
384 + seq_lens: torch.Tensor,
385 + num_decodes: int,
386 + seq_lens_is_buffer_view: bool,
387 + ) -> torch.Tensor:
388 + local_seq_lens = get_dcp_local_seq_lens(
389 + seq_lens,
390 + self.dcp_world_size,
391 + self.dcp_rank,
392 + self.cp_kv_cache_interleave_size,
393 + )
394 + if seq_lens_is_buffer_view:
395 + seq_lens.copy_(local_seq_lens)
396 + return seq_lens
397 +
398 + out = self.decode_seq_lens_buffer[:num_decodes]
399 + out.copy_(local_seq_lens)
400 + return out
401 +
402 def _prepare_decode_tensors(
403 self,
404 seq_lens: torch.Tensor,
@@ -468,6 +519,34 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
519 seq_lens = seq_lens_buffer
520 return seq_lens, block_table, decode_lens, num_decodes, requires_padding
521
522 + def _prepare_global_decode_seq_lens(
523 + self,
524 + global_seq_lens: torch.Tensor | None,
525 + decode_lens: torch.Tensor,
526 + decode_lens_cpu: torch.Tensor,
527 + query_start_loc: torch.Tensor,
528 + num_decode_tokens: int,
529 + use_native: bool,
530 + max_decode_len: int,
531 + ) -> torch.Tensor | None:
532 + if global_seq_lens is None:
533 + return None
534 + if use_native or max_decode_len <= 1:
535 + return global_seq_lens
536 +
537 + actual_expanded = int(decode_lens_cpu.sum().item())
538 + if actual_expanded > 0:
539 + expanded_offsets = torch.repeat_interleave(
540 + global_seq_lens - decode_lens - query_start_loc,
541 + decode_lens,
542 + output_size=actual_expanded,
543 + )
544 + self.global_decode_seq_lens_buffer[:actual_expanded] = (
545 + expanded_offsets + self.arange_buffer[:actual_expanded] + 1
546 + )
547 + self.global_decode_seq_lens_buffer[actual_expanded:num_decode_tokens] = 0
548 + return self.global_decode_seq_lens_buffer[:num_decode_tokens]
549 +
550 def build(
551 self,
552 common_prefix_len: int,
@@ -481,6 +560,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
560 seq_lens = common_attn_metadata.seq_lens
561 slot_mapping = common_attn_metadata.slot_mapping
562 block_table = common_attn_metadata.block_table_tensor
563 + dcp_local_seq_lens = common_attn_metadata.dcp_local_seq_lens
564
565 num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
566 split_decodes_and_prefills(
@@ -549,6 +629,9 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
629 self.compress_ratio,
630 query_slice=query_slice,
631 skip_kv_gather=query_slice.start > 0,
632 + dcp_rank=self.dcp_rank,
633 + dcp_world_size=self.dcp_world_size,
634 + cp_kv_cache_interleave_size=self.cp_kv_cache_interleave_size,
635 )
636 # Skip when total_seq_lens is 0 (i.e., no compressed token).
637 if metadata is not None:
@@ -566,6 +649,17 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
649 common_attn_metadata.query_start_loc_cpu[: num_decodes + 1]
650 )
651
652 + # Under DCP the per-token decode bounds must be localized AFTER the
653 + # per-token expansion below, not before. Expanding from a
654 + # request-level localized length subtracts decode offsets in local
655 + # space and yields too-short bounds (e.g. world=2, rank=1, global
656 + # per-token bounds [8, 9, 10] -> [3, 4, 5] instead of [4, 4, 5]), so
657 + # the first decode token would run top-k against too short a local KV
658 + # range and miss valid tokens. Keep the global seq_lens here and
659 + # localize the expanded bounds further down.
660 + global_seq_lens_for_decode: torch.Tensor | None = None
661 + if dcp_local_seq_lens is not None:
662 + global_seq_lens_for_decode = common_attn_metadata.seq_lens[:num_decodes]
663 seq_lens = common_attn_metadata.seq_lens[:num_decodes]
664 block_table = common_attn_metadata.block_table_tensor[:num_decodes, ...]
665
@@ -573,6 +667,16 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
667 next_n = 1 + self.num_speculative_tokens
668 use_native = not self.use_flattening and max_decode_len <= next_n
669
670 + global_seq_lens_for_decode = self._prepare_global_decode_seq_lens(
671 + global_seq_lens=global_seq_lens_for_decode,
672 + decode_lens=decode_lens,
673 + decode_lens_cpu=decode_lens_cpu,
674 + query_start_loc=common_attn_metadata.query_start_loc[:num_decodes],
675 + num_decode_tokens=num_decode_tokens,
676 + use_native=use_native,
677 + max_decode_len=max_decode_len,
678 + )
679 +
680 seq_lens, block_table, decode_lens, batch_size, requires_padding = (
681 self._prepare_decode_tensors(
682 seq_lens=seq_lens,
@@ -588,15 +692,22 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
692 )
693 )
694
695 + seq_lens_is_buffer_view = (use_native and next_n > 1) or (
696 + not use_native and max_decode_len > 1
697 + )
698 +
699 + # DCP: localize the now-expanded per-token global bounds to this
700 + # rank's owned KV. Done here (after expansion) so each token's global
701 + # causal length is localized individually; see the comment above.
702 + if dcp_local_seq_lens is not None:
703 + seq_lens = self._dcp_localize_decode_seq_lens(
704 + seq_lens, num_decodes, seq_lens_is_buffer_view
705 + )
706 +
707 # For DeepseekV4 (compress_ratio > 1), the indexer KV cache stores
708 # compressed tokens. Convert uncompressed seq_lens to compressed.
709 if self.compress_ratio > 1:
594 - # True iff seq_lens aliases decode_seq_lens_buffer (flatten or
595 - # native wrote it); False iff it aliases common_attn_metadata.
596 - seq_lens_is_local_view = (use_native and next_n > 1) or (
597 - not use_native and max_decode_len > 1
598 - )
599 - if seq_lens_is_local_view:
710 + if seq_lens_is_buffer_view:
711 seq_lens //= self.compress_ratio
712 else:
713 # Copy to avoid mutating shared state; keeps CG address stable.
@@ -626,6 +737,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
737 decode_lens=decode_lens,
738 requires_padding=requires_padding,
739 schedule_metadata=self.scheduler_metadata_buffer,
740 + global_seq_lens=global_seq_lens_for_decode,
741 )
742
743 attn_metadata = DeepseekV32IndexerMetadata(
@@ -655,6 +767,9 @@ def build_prefill_chunk_metadata(
767 compress_ratio: int,
768 query_slice: slice | None = None,
769 skip_kv_gather: bool = False,
770 + dcp_rank: int = 0,
771 + dcp_world_size: int = 1,
772 + cp_kv_cache_interleave_size: int = 1,
773 ) -> DeepseekV32IndexerPrefillChunkMetadata | None:
774 total_seq_lens = compressed_seq_lens_cpu[start_idx:end_idx].sum().item()
775 if total_seq_lens == 0:
@@ -669,6 +784,25 @@ def build_prefill_chunk_metadata(
784 cu_seq_lens[:1] = 0
785 torch.cumsum(compressed_seq_lens[start_idx:end_idx], dim=0, out=cu_seq_lens[1:])
786
787 + local_cu_seq_lens = cu_seq_lens
788 + local_total_seq_lens = total_seq_lens
789 + max_local_total_seq_lens = total_seq_lens
790 + if dcp_world_size > 1:
791 + # Per-rank local KV length under interleave-aware DCP sharding, shape
792 + # [num_reqs, dcp_world_size]. Reuse the canonical CP helper so the
793 + # sharding matches the rest of the DCP pipeline (decode/prefill).
794 + local_seq_lens = get_dcp_local_seq_lens(
795 + compressed_seq_lens[start_idx:end_idx],
796 + dcp_world_size,
797 + None,
798 + cp_kv_cache_interleave_size,
799 + )
800 + this_rank_counts = local_seq_lens[:, dcp_rank].to(torch.int32)
801 + local_cu_seq_lens = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device)
802 + torch.cumsum(this_rank_counts, dim=0, out=local_cu_seq_lens[1:])
803 + local_total_seq_lens = int(local_cu_seq_lens[-1].item())
804 + max_local_total_seq_lens = int(local_seq_lens.sum(dim=0).max().item())
805 +
806 query_start_loc = (
807 query_start_loc[start_idx : end_idx + 1] - query_start_loc[start_idx]
808 )
@@ -687,15 +821,21 @@ def build_prefill_chunk_metadata(
821 cu_seq_len_ks = torch.empty(output_query_len, dtype=torch.int32, device=device)
822 cu_seq_len_ke = torch.empty(output_query_len, dtype=torch.int32, device=device)
823
824 + # Under DCP the kernel writes this rank's local row bounds into
825 + # cu_seq_len_ks/ke; otherwise local_cu_seq_lens aliases cu_seq_lens.
826 _build_prefill_chunk_metadata_kernel[(num_reqs,)](
827 query_start_loc,
828 uncompressed_seq_lens[start_idx:end_idx],
829 cu_seq_lens,
830 + local_cu_seq_lens,
831 token_to_seq,
832 cu_seq_len_ks,
833 cu_seq_len_ke,
834 qs_start,
835 qs_stop,
836 + dcp_rank,
837 + dcp_world_size,
838 + cp_kv_cache_interleave_size,
839 BLOCK_SIZE=1024,
840 COMPRESS_RATIO=compress_ratio,
841 )
@@ -719,6 +859,9 @@ def build_prefill_chunk_metadata(
859 token_end=token_end,
860 num_reqs=num_reqs,
861 skip_kv_gather=skip_kv_gather,
862 + local_cu_seq_lens=local_cu_seq_lens,
863 + local_total_seq_lens=local_total_seq_lens,
864 + max_local_total_seq_lens=max_local_total_seq_lens,
865 )
866
867
@@ -728,12 +871,18 @@ def _build_prefill_chunk_metadata_kernel(
871 query_start_loc_ptr,
872 uncompressed_seq_lens_ptr,
873 cu_compressed_seq_lens_ptr,
874 + # Row-start base for cu_seq_len_ks/ke: local cumulative lens under DCP,
875 + # aliases cu_compressed_seq_lens_ptr otherwise.
876 + row_start_cu_compressed_seq_lens_ptr,
877 # Outputs
878 token_to_seq_ptr,
879 cu_compressed_seq_len_ks_ptr,
880 cu_compressed_seq_len_ke_ptr,
881 query_slice_start,
882 query_slice_stop,
883 + DCP_RANK,
884 + DCP_WORLD,
885 + DCP_INTERLEAVE,
886 BLOCK_SIZE: tl.constexpr,
887 COMPRESS_RATIO: tl.constexpr,
888 ):
@@ -747,6 +896,10 @@ def _build_prefill_chunk_metadata_kernel(
896 seq_end = tl.load(cu_compressed_seq_lens_ptr + batch_idx + 1)
897 compressed_seq_len = seq_end - seq_start
898
899 + # Row start for the (possibly localized) cu_seq_len_ks/ke. Equals seq_start
900 + # when DCP is disabled (the pointer aliases cu_compressed_seq_lens_ptr).
901 + row_start = tl.load(row_start_cu_compressed_seq_lens_ptr + batch_idx)
902 +
903 uncompressed_seq_len = tl.load(uncompressed_seq_lens_ptr + batch_idx)
904 start_pos = uncompressed_seq_len - query_len
905
@@ -760,14 +913,25 @@ def _build_prefill_chunk_metadata_kernel(
913 )
914 out_pos = abs_pos - query_slice_start
915
763 - # Compute cu_seq_len_ks
764 - tl.store(cu_compressed_seq_len_ks_ptr + out_pos, seq_start, mask=mask)
765 -
766 - # Compute cu_seq_len_ke
767 - seq_len_per_token = (start_pos + 1 + offset) // COMPRESS_RATIO
916 + # cu_seq_len_ks: row start in the gathered K buffer.
917 + tl.store(cu_compressed_seq_len_ks_ptr + out_pos, row_start, mask=mask)
918 +
919 + # cu_seq_len_ke: row start + per-token context length. Under DCP the
920 + # global per-token length is sharded across ranks.
921 + global_ctx = start_pos + 1 + offset
922 + len_per_token = global_ctx // COMPRESS_RATIO
923 + if DCP_WORLD > 1:
924 + # Per-rank local context length under interleave-aware DCP, matching
925 + # get_dcp_local_seq_lens. K == 1 reduces to (len + world-1-rank)//world.
926 + base = (len_per_token // DCP_INTERLEAVE // DCP_WORLD) * DCP_INTERLEAVE
927 + remainder = len_per_token - base * DCP_WORLD
928 + remainder = tl.minimum(
929 + tl.maximum(remainder - DCP_RANK * DCP_INTERLEAVE, 0), DCP_INTERLEAVE
930 + )
931 + len_per_token = base + remainder
932 tl.store(
933 cu_compressed_seq_len_ke_ptr + out_pos,
770 - seq_start + seq_len_per_token,
934 + row_start + len_per_token,
935 mask=mask,
936 )
937
vllm/v1/attention/backends/mla/sparse_utils.py
+158 -11
@@ -23,6 +23,16 @@ def _convert_req_index_to_global_index_kernel(
23 BLOCK_N: tl.constexpr, # tile width along columns
24 HAS_PREFILL: tl.constexpr,
25 COUNT_VALID: tl.constexpr, # whether to count valid indices
26 + # When set, scatter valid slots to a contiguous prefix [0, valid_count) using
27 + # valid_count_ptr as an atomic slot allocator (DCP filtering leaves interior
28 + # -1 gaps; the trtllm-gen sparse kernel reads the first valid_count entries).
29 + # Requires COUNT_VALID and an out buffer pre-filled with -1. Order within the
30 + # prefix is unspecified (only the selected set matters).
31 + COMPACT_TO_FRONT: tl.constexpr,
32 + # DCP de-interleave: with DCP_SIZE == 1 these are an exact no-op
33 + DCP_SIZE: tl.constexpr,
34 + DCP_RANK: tl.constexpr,
35 + DCP_INTERLEAVE: tl.constexpr,
36 # strides (in elements)
37 bt_stride0,
38 bt_stride1,
@@ -52,15 +62,27 @@ def _convert_req_index_to_global_index_kernel(
62 if HAS_PREFILL:
63 prefill_req_id = tl.load(prefill_request_id_ptr + token_id)
64 is_prefill = prefill_req_id >= 0
65 +
66 + # DCP de-interleave the global token id into this rank's local slot.
67 + # Tokens are interleaved in groups of DCP_INTERLEAVE across ranks. With
68 + # DCP_SIZE == 1 (and any interleave) owning_rank == 0 == DCP_RANK (never
69 + # remote) and local_idx == tok, so this reduces to the non-DCP path; with
70 + # DCP_INTERLEAVE == 1 it reduces to plain round-robin (tok % / // DCP_SIZE).
71 + owning_rank = (tok // DCP_INTERLEAVE) % DCP_SIZE
72 + is_remote = owning_rank != DCP_RANK
73 + local_idx = (
74 + tok // (DCP_SIZE * DCP_INTERLEAVE)
75 + ) * DCP_INTERLEAVE + tok % DCP_INTERLEAVE
76 +
77 # Compute block id and in-block offset
56 - block_id = tok // BLOCK_SIZE
57 - inblock_off = tok % BLOCK_SIZE
78 + block_id = local_idx // BLOCK_SIZE
79 + inblock_off = local_idx % BLOCK_SIZE
80
81 # Guard block_table access
82 valid_block = (block_id < max_num_blocks_per_req) & (block_id >= 0)
83 bt_ptr = block_table_ptr + req * bt_stride0 + block_id * bt_stride1
62 - is_invalid_tok |= ~valid_block
63 - base = tl.load(bt_ptr, mask=valid_block & ~is_prefill, other=0)
84 + is_invalid_tok |= ~valid_block | is_remote
85 + base = tl.load(bt_ptr, mask=valid_block & ~is_prefill & ~is_remote, other=0)
86 out_val = base * BLOCK_SIZE + inblock_off
87
88 # Override with prefill output if prefill is enabled
@@ -72,14 +94,27 @@ def _convert_req_index_to_global_index_kernel(
94 out_val = tl.where(is_prefill, prefill_out, out_val)
95 out_val = tl.where(is_invalid_tok, -1, out_val)
96
75 - # Store results
76 - out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1
77 - tl.store(out_ptr_ij, out_val)
97 + if COMPACT_TO_FRONT:
98 + # Scatter valid slots to a contiguous prefix. A per-tile exclusive prefix
99 + # sum gives each valid lane a distinct local offset; one atomic add of the
100 + # tile's valid count reserves a contiguous base across racing tiles. The
101 + # out buffer is pre-filled with -1, so unwritten tail slots stay -1.
102 + is_valid = (~is_invalid_tok).to(tl.int32)
103 + local_offset = tl.cumsum(is_valid) - is_valid
104 + tile_valid_count = tl.sum(is_valid)
105 + base = tl.atomic_add(valid_count_ptr + token_id, tile_valid_count)
106 + dest = base + local_offset
107 + out_ptr_dest = out_ptr + token_id * out_stride0 + dest * out_stride1
108 + tl.store(out_ptr_dest, out_val, mask=is_valid == 1)
109 + else:
110 + # Store results in place (input column == output column).
111 + out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1
112 + tl.store(out_ptr_ij, out_val)
113
79 - # Count valid indices in this tile and atomically add to row total
80 - if COUNT_VALID:
81 - tile_valid_count = tl.sum((~is_invalid_tok).to(tl.int32))
82 - tl.atomic_add(valid_count_ptr + token_id, tile_valid_count)
114 + # Count valid indices in this tile and atomically add to row total
115 + if COUNT_VALID:
116 + tile_valid_count = tl.sum((~is_invalid_tok).to(tl.int32))
117 + tl.atomic_add(valid_count_ptr + token_id, tile_valid_count)
118
119
120 def triton_convert_req_index_to_global_index(
@@ -176,6 +211,11 @@ def triton_convert_req_index_to_global_index(
211 BLOCK_N,
212 HAS_PREFILL_WORKSPACE,
213 return_valid_counts,
214 + False, # COMPACT_TO_FRONT: keep input column == output column
215 + # DCP disabled (no-op de-interleave)
216 + 1,
217 + 0,
218 + 1,
219 # strides
220 bt_stride0,
221 bt_stride1,
@@ -189,3 +229,110 @@ def triton_convert_req_index_to_global_index(
229 assert valid_counts is not None
230 return out, valid_counts
231 return out
232 +
233 +
234 +def triton_filter_and_convert_dcp_index(
235 + req_id: torch.Tensor,
236 + block_table: torch.Tensor,
237 + token_indices: torch.Tensor,
238 + dcp_size: int,
239 + dcp_rank: int,
240 + cp_kv_cache_interleave_size: int = 1,
241 + BLOCK_SIZE: int = 64,
242 + NUM_TOPK_TOKENS: int = 2048,
243 + BLOCK_N: int = 128,
244 + return_valid_counts: bool = False,
245 + compact_valid_to_front: bool = True,
246 +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
247 + """Filter global per-request indices to this DCP rank's local slots.
248 +
249 + With ``compact_valid_to_front`` (default), the conversion kernel scatters
250 + this rank's owned slots to a contiguous prefix ``[0, valid_count)`` and
251 + leaves the rest ``-1``. DCP filtering marks non-owned slots ``-1`` and so
252 + creates interior gaps; the trtllm-gen sparse kernel reads the first
253 + ``valid_count`` entries of each row, so they must be a contiguous prefix.
254 + Compaction is fused into the kernel (atomic slot allocator) rather than a
255 + separate sort/gather pass. Prefix order is unspecified (only the set matters).
256 + """
257 + assert dcp_size >= 1
258 + assert 0 <= dcp_rank < dcp_size
259 + # Interleave groups must align to KV blocks (globally enforced by
260 + # VllmConfig: block_size % cp_kv_cache_interleave_size == 0); assert the
261 + # local invariant so local_idx // BLOCK_SIZE never straddles a group.
262 + assert BLOCK_SIZE % cp_kv_cache_interleave_size == 0, (
263 + f"BLOCK_SIZE ({BLOCK_SIZE}) must be divisible by "
264 + f"cp_kv_cache_interleave_size ({cp_kv_cache_interleave_size})."
265 + )
266 + assert req_id.dtype == torch.int32
267 + assert block_table.dtype == torch.int32
268 + assert token_indices.dtype == torch.int32
269 + assert token_indices.shape[1] == NUM_TOPK_TOKENS
270 + assert NUM_TOPK_TOKENS % BLOCK_N == 0
271 +
272 + if dcp_size == 1:
273 + return triton_convert_req_index_to_global_index(
274 + req_id,
275 + block_table,
276 + token_indices,
277 + BLOCK_SIZE=BLOCK_SIZE,
278 + NUM_TOPK_TOKENS=NUM_TOPK_TOKENS,
279 + BLOCK_N=BLOCK_N,
280 + return_valid_counts=return_valid_counts,
281 + )
282 +
283 + num_tokens = req_id.shape[0]
284 + max_num_blocks_per_req = block_table.shape[1]
285 + tiles_per_row = NUM_TOPK_TOKENS // BLOCK_N
286 +
287 + req_id_c = req_id.contiguous()
288 + block_table_c = block_table.contiguous()
289 + token_indices_c = token_indices.contiguous()
290 +
291 + # The compaction uses the valid-count buffer as an atomic slot allocator, so
292 + # it requires counting. Pre-fill out with -1 so the unwritten tail stays -1.
293 + count_valid = return_valid_counts or compact_valid_to_front
294 + if compact_valid_to_front:
295 + out = torch.full_like(token_indices_c, -1)
296 + else:
297 + out = torch.empty_like(token_indices_c)
298 +
299 + valid_counts: torch.Tensor | None = None
300 + if count_valid:
301 + valid_counts = torch.zeros(
302 + num_tokens, dtype=torch.int32, device=token_indices.device
303 + )
304 +
305 + bt_stride0, bt_stride1 = block_table_c.stride()
306 + ti_stride0, ti_stride1 = token_indices_c.stride()
307 + out_stride0, out_stride1 = out.stride()
308 +
309 + _convert_req_index_to_global_index_kernel[(num_tokens, tiles_per_row)](
310 + req_id_c,
311 + block_table_c,
312 + token_indices_c,
313 + out,
314 + valid_counts,
315 + # No prefill workspace on the DCP decode path.
316 + None,
317 + None,
318 + max_num_blocks_per_req,
319 + BLOCK_SIZE,
320 + BLOCK_N,
321 + False, # HAS_PREFILL
322 + count_valid,
323 + compact_valid_to_front,
324 + dcp_size,
325 + dcp_rank,
326 + cp_kv_cache_interleave_size,
327 + bt_stride0,
328 + bt_stride1,
329 + ti_stride0,
330 + ti_stride1,
331 + out_stride0,
332 + out_stride1,
333 + )
334 +
335 + if return_valid_counts:
336 + assert valid_counts is not None
337 + return out, valid_counts
338 + return out
vllm/v1/attention/backends/utils.py
+12 -12
@@ -892,20 +892,20 @@ def get_dcp_local_seq_lens(
892 use this function to calculate split decode seq_lens of each dcp rank.
893 Only consider dcp now, we can extend the case of cp based on this.
894 """
895 - num_requests = seq_lens.size(0)
895 + seq_lens_i32 = seq_lens.to(torch.int32)
896 if dcp_rank is None:
897 - rank_offsets = (
898 - torch.arange(dcp_size, dtype=torch.int32, device=seq_lens.device)
899 - .unsqueeze(0)
900 - .repeat(num_requests, 1)
897 + rank_offsets = torch.arange(
898 + dcp_size,
899 + dtype=torch.int32,
900 + device=seq_lens.device,
901 + ).view(
902 + *((1,) * seq_lens_i32.dim()),
903 + dcp_size,
904 )
905 + seq_lens_tiled = seq_lens_i32.unsqueeze(-1)
906 else:
903 - rank_offsets = torch.tensor(
904 - [[dcp_rank]], dtype=torch.int32, device=seq_lens.device
905 - )
906 - seq_lens_tiled = (
907 - seq_lens.to(torch.int32).unsqueeze(-1).repeat(1, rank_offsets.shape[1])
908 - )
907 + rank_offsets = torch.tensor(dcp_rank, dtype=torch.int32, device=seq_lens.device)
908 + seq_lens_tiled = seq_lens_i32
909 base = (
910 seq_lens_tiled
911 // cp_kv_cache_interleave_size
@@ -919,7 +919,7 @@ def get_dcp_local_seq_lens(
919 cp_kv_cache_interleave_size,
920 )
921 dcp_local_seq_lens = base + remainder
922 - return dcp_local_seq_lens.squeeze(1)
922 + return dcp_local_seq_lens
923
924
925 def mamba_get_block_table_tensor(
vllm/v1/attention/ops/common.py
+1
@@ -90,6 +90,7 @@ def _correct_attn_cp_out_kernel(
90 factor = tl.exp(lse_finally) if IS_BASE_E else tl.exp2(lse_finally)
91 output = tl.load(outputs_ptr + output_offsets)
92 output = output * factor
93 + output = tl.where(factor == 0.0, 0.0, output)
94
95 tl.store(new_output_ptr + output_offsets, output)
96