[nv] add pcp support in dsv3.2 (#52046)
Signed-off-by: Summer Yang <girasoleyang@gmail.com>
Summer Yang committed
Aug 18, 2026 at 14:59 UTC
5f7a20b3162ef88d531ada03aa1174643bb97c11
6 files changed
+376
-119
tests/kernels/test_fused_deepseek_v32_norm_rope.py
+90
-6
@@ -292,6 +292,81 @@ def test_fused_norm_rope_no_indexer(num_tokens: int):
292
assert (topk == 7).all(), "topk buffer should be untouched on shared layer"
293
294
295
+@pytest.mark.parametrize("has_indexer", [False, True])
296
+def test_fused_norm_rope_materializes_pcp_cache_inputs(has_indexer: bool):
297
+ """PCP gets local normalized/rotated K rows without direct cache writes."""
298
+ torch.manual_seed(6)
299
+ dev = "cuda"
300
+ num_tokens = 17
301
+ max_pos = 8192
302
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
303
+ q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16)
304
+ kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16)
305
+ k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16)
306
+ qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16)
307
+ kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16)
308
+ ik = (
309
+ torch.randn(num_tokens, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16)
310
+ if has_indexer
311
+ else None
312
+ )
313
+ ikw = (
314
+ torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
315
+ if has_indexer
316
+ else None
317
+ )
318
+ ikb = (
319
+ torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
320
+ if has_indexer
321
+ else None
322
+ )
323
+ mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
324
+ idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) if has_indexer else None
325
+ q_out = torch.empty_like(q_c)
326
+ kv_out = torch.empty_like(kv_c)
327
+ kpe_out = torch.empty_like(k_pe)
328
+ ik_out = torch.empty_like(ik) if ik is not None else None
329
+ topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32)
330
+
331
+ actual_q = K.fused_norm_rope(
332
+ pos,
333
+ q_c,
334
+ qw,
335
+ EPS,
336
+ kv_c,
337
+ kvw,
338
+ EPS,
339
+ k_pe,
340
+ mla_cos_sin,
341
+ ik,
342
+ ikw,
343
+ ikb,
344
+ EPS,
345
+ idx_cos_sin,
346
+ topk,
347
+ has_indexer=has_indexer,
348
+ index_rope_interleave=True,
349
+ q_c_out=q_out,
350
+ kv_c_out=kv_out,
351
+ k_pe_out=kpe_out,
352
+ index_k_out=ik_out,
353
+ )
354
+
355
+ assert actual_q.data_ptr() == q_out.data_ptr()
356
+ assert_bf16(actual_q, rms_norm(q_c, qw), "PCP q norm")
357
+ assert_bf16(kv_out, rms_norm(kv_c, kvw), "PCP kv norm")
358
+ assert_bf16(
359
+ kpe_out,
360
+ rope(k_pe.float(), pos, mla_cos_sin, interleave=True),
361
+ "PCP k_pe RoPE",
362
+ )
363
+ if has_indexer:
364
+ assert ik is not None and ikw is not None and ikb is not None
365
+ assert ik_out is not None and idx_cos_sin is not None
366
+ ik_ref = rope(layer_norm(ik, ikw, ikb), pos, idx_cos_sin, interleave=True)
367
+ assert_bf16(ik_out, ik_ref, "PCP indexer-K")
368
+
369
+
370
@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512])
371
def test_fused_norm_rope_ds_mla(num_tokens: int):
372
"""fp8_ds_mla MLA cache layout (FlashMLA sparse, bf16-query path; SM90/SM100).
@@ -405,19 +480,29 @@ def test_fused_norm_rope_supports_large_token_count():
480
# ── fused_q ──────────────────────────────────────────────────────────────────
481
482
408
-@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512, 4096])
483
+@pytest.mark.parametrize(
484
+ ("num_tokens", "num_q_heads"),
485
+ [
486
+ (1, NUM_HEADS),
487
+ (4, NUM_HEADS),
488
+ (17, NUM_HEADS),
489
+ (512, NUM_HEADS),
490
+ (4096, NUM_HEADS),
491
+ (17, 64),
492
+ ],
493
+)
494
@pytest.mark.parametrize("index_interleave", [True, False])
410
-def test_fused_q(num_tokens: int, index_interleave: bool):
495
+def test_fused_q(num_tokens: int, num_q_heads: int, index_interleave: bool):
496
torch.manual_seed(2)
497
dev = "cuda"
498
max_pos = 8192
499
pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos
500
501
q_pe = torch.randn(
417
- num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16
502
+ num_tokens, num_q_heads, ROPE_DIM, device=dev, dtype=torch.bfloat16
503
)
504
ql_nope = torch.randn(
420
- num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16
505
+ num_tokens, num_q_heads, KV_LORA, device=dev, dtype=torch.bfloat16
506
)
507
index_q = torch.randn(
508
num_tokens, INDEX_HEADS, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16
@@ -449,7 +534,7 @@ def test_fused_q(num_tokens: int, index_interleave: bool):
534
mqa_nope_ref = (ql_nope.float() / s).to(FP8)
535
qpe_ref = rope(
536
q_pe.float(),
452
- pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS),
537
+ pos.unsqueeze(-1).expand(num_tokens, num_q_heads),
538
q_cos_sin,
539
interleave=True,
540
)
@@ -484,7 +569,6 @@ def test_fused_q_no_indexer(num_tokens: int):
569
)
570
q_scale = torch.tensor([0.5], device=dev, dtype=torch.float32)
571
q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev)
487
-
572
_, _, mqa = K.fused_q(
573
pos,
574
q_pe,
tests/model_executor/layers/test_mla_short_prefill_indexer.py
+45
@@ -177,3 +177,48 @@ def test_short_prefill_updates_k_cache_before_scoring_decision(
177
# K cache is always updated before the scoring decision.
178
torch.testing.assert_close(observed["k"], k[: slot_mapping.numel()])
179
assert observed["slots"] is slot_mapping
180
+
181
+
182
+def test_skipped_k_cache_insert_accepts_no_k(
183
+ monkeypatch: pytest.MonkeyPatch,
184
+) -> None:
185
+ indexer_metadata = make_indexer_metadata(
186
+ num_prefills=0,
187
+ num_prefill_tokens=0,
188
+ slot_mapping=torch.empty(0, dtype=torch.long),
189
+ )
190
+ monkeypatch.setattr(
191
+ sparse_indexer,
192
+ "get_forward_context",
193
+ lambda: SimpleNamespace(
194
+ attn_metadata={INDEXER_LAYER: indexer_metadata},
195
+ cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE,
196
+ ),
197
+ )
198
+ monkeypatch.setattr(
199
+ sparse_indexer.current_platform, "fp8_dtype", lambda: torch.float16
200
+ )
201
+
202
+ topk_indices = torch.full((1, 2048), 17, dtype=torch.int32)
203
+ result = sparse_indexer.sparse_attn_indexer(
204
+ torch.empty(1, 1),
205
+ INDEXER_LAYER,
206
+ torch.empty(1),
207
+ torch.empty(1, 1),
208
+ None,
209
+ None,
210
+ torch.empty(1, 1),
211
+ 128,
212
+ "ue8m0",
213
+ 2048,
214
+ 4,
215
+ 4096,
216
+ 4096,
217
+ topk_indices,
218
+ True,
219
+ False,
220
+ "",
221
+ )
222
+
223
+ assert result is topk_indices
224
+ assert torch.all(topk_indices == -1)
vllm/model_executor/layers/sparse_attn_indexer.py
+6
-6
@@ -299,7 +299,7 @@ def sparse_attn_indexer(
299
kv_cache: torch.Tensor,
300
q_quant: torch.Tensor,
301
q_scale: torch.Tensor | None,
302
- k: torch.Tensor,
302
+ k: torch.Tensor | None,
303
weights: torch.Tensor,
304
quant_block_size: int,
305
scale_fmt: str | None,
@@ -695,7 +695,7 @@ def sparse_attn_indexer_fake(
695
kv_cache: torch.Tensor,
696
q_quant: torch.Tensor,
697
q_scale: torch.Tensor | None,
698
- k: torch.Tensor,
698
+ k: torch.Tensor | None,
699
weights: torch.Tensor,
700
quant_block_size: int,
701
scale_fmt: str | None,
@@ -781,7 +781,7 @@ class SparseAttnIndexer(CustomOp):
781
self,
782
hidden_states: torch.Tensor,
783
q_quant: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
784
- k: torch.Tensor,
784
+ k: torch.Tensor | None,
785
weights: torch.Tensor,
786
):
787
if current_platform.is_cuda() or current_platform.is_xpu():
@@ -798,7 +798,7 @@ class SparseAttnIndexer(CustomOp):
798
self,
799
hidden_states: torch.Tensor,
800
q_quant: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
801
- k: torch.Tensor,
801
+ k: torch.Tensor | None,
802
weights: torch.Tensor,
803
):
804
# FP8 path: single tensor (per-token scale is folded into `weights`).
@@ -835,7 +835,7 @@ class SparseAttnIndexer(CustomOp):
835
self,
836
hidden_states: torch.Tensor,
837
q_fp8: torch.Tensor,
838
- k: torch.Tensor,
838
+ k: torch.Tensor | None,
839
weights: torch.Tensor,
840
):
841
return self.forward_cuda(hidden_states, q_fp8, k, weights)
@@ -844,7 +844,7 @@ class SparseAttnIndexer(CustomOp):
844
self,
845
hidden_states: torch.Tensor,
846
q_quant: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
847
- k: torch.Tensor,
847
+ k: torch.Tensor | None,
848
weights: torch.Tensor,
849
):
850
assert not self.use_fp4_cache, "AMD platform doesn't support fp4 cache yet"
vllm/models/deepseek_v32/attention.py
+103
-20
@@ -1,5 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import TYPE_CHECKING, cast
4
+
5
import torch
6
import torch.nn as nn
7
from transformers import DeepseekV2Config, DeepseekV3Config
@@ -7,8 +9,14 @@ from transformers import DeepseekV2Config, DeepseekV3Config
9
from vllm.compilation.breakable_cudagraph import eager_break_during_capture
10
from vllm.config import CacheConfig, VllmConfig
11
from vllm.distributed import get_tensor_model_parallel_world_size
12
+from vllm.distributed.parallel_state import get_tp_group
13
from vllm.forward_context import get_forward_context
14
from vllm.model_executor.layers.attention import MLAAttention
15
+from vllm.model_executor.layers.attention.attention import get_attention_context
16
+from vllm.model_executor.layers.attention.pcp import (
17
+ finalize_mla_pcp_decode,
18
+ maybe_gather_mla_latent_cache_inputs,
19
+)
20
from vllm.model_executor.layers.layernorm import LayerNorm, RMSNorm
21
from vllm.model_executor.layers.linear import (
22
ColumnParallelLinear,
@@ -35,6 +43,9 @@ from vllm.models.deepseek_v32.common.kernels import fused_norm_rope, fused_q
43
from vllm.platforms import current_platform
44
from vllm.utils.torch_utils import is_quantized_kv_cache
45
46
+if TYPE_CHECKING:
47
+ from vllm.model_executor.layers.attention.mla_attention import MLACommonMetadata
48
+
49
50
class DeepseekV32Indexer(nn.Module):
51
indexer_cache_cls = DeepseekV32IndexerCache
@@ -363,6 +374,7 @@ class DeepseekV32Attention(MLAAttention):
374
attn_metadata = attn_metadata_raw[0].get(self.layer_name)
375
else:
376
attn_metadata = attn_metadata_raw
377
+ attn_metadata = cast("MLACommonMetadata | None", attn_metadata)
378
379
slot_mapping = forward_context.slot_mapping
380
assert isinstance(slot_mapping, dict)
@@ -374,7 +386,8 @@ class DeepseekV32Attention(MLAAttention):
386
indexer_k_norm_bias = self.indexer.k_norm.bias
387
indexer_k_norm_eps = self.indexer.k_norm.eps
388
indexer_k_rope_cos_sin_cache = self.indexer_rope_emb.cos_sin_cache
377
- indexer_k_cache = self.indexer.k_cache.kv_cache
389
+ indexer_k_cache = None if self.use_pcp else self.indexer.k_cache.kv_cache
390
+ index_k_out = torch.empty_like(index_k) if self.use_pcp else None
391
indexer_softmax_scale = self.indexer.softmax_scale
392
indexer_n_head_scale = self.indexer.n_head**-0.5
393
else:
@@ -384,10 +397,11 @@ class DeepseekV32Attention(MLAAttention):
397
indexer_k_norm_eps = 1e-6
398
indexer_k_rope_cos_sin_cache = None
399
indexer_k_cache = None
400
+ index_k_out = None
401
indexer_softmax_scale = 0.0
402
indexer_n_head_scale = 0.0
403
390
- if attn_metadata is None:
404
+ if attn_metadata is None or self.use_pcp:
405
mla_kv_cache = None
406
mla_k_scale = None
407
indexer_k_cache = None
@@ -396,6 +410,8 @@ class DeepseekV32Attention(MLAAttention):
410
mla_kv_cache = self.kv_cache
411
mla_k_scale = self._k_scale
412
413
+ kv_c_out = torch.empty_like(kv_c) if self.use_pcp else None
414
+ k_pe_out = torch.empty_like(k_pe) if self.use_pcp else None
415
q_c = fused_norm_rope(
416
positions,
417
q_c,
@@ -419,12 +435,14 @@ class DeepseekV32Attention(MLAAttention):
435
mla_k_scale=mla_k_scale,
436
has_indexer=has_indexer,
437
index_rope_interleave=self._index_rope_interleave,
438
+ kv_c_out=kv_c_out,
439
+ k_pe_out=k_pe_out,
440
+ index_k_out=index_k_out,
441
)
442
443
q = self.q_b_proj(q_c)[0].view(-1, self.num_local_heads, self.qk_head_dim)
444
q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
426
- q_nope = q_nope.transpose(0, 1)
427
- ql_nope = torch.bmm(q_nope, self.W_UK_T).transpose(0, 1)
445
+ ql_nope = torch.bmm(q_nope.transpose(0, 1), self.W_UK_T).transpose(0, 1)
446
447
if self.indexer is not None and not self.skip_topk:
448
index_q = self.indexer.wq_b(q_c)[0]
@@ -451,7 +469,10 @@ class DeepseekV32Attention(MLAAttention):
469
self._sparse_indexer_and_attn(
470
q_c,
471
index_q_fp8,
472
+ index_k_out,
473
index_weights_out,
474
+ kv_c_out,
475
+ k_pe_out,
476
ql_nope,
477
mqa_q,
478
output,
@@ -463,19 +484,26 @@ class DeepseekV32Attention(MLAAttention):
484
self,
485
q_c: torch.Tensor,
486
index_q_fp8: torch.Tensor | None,
487
+ index_k: torch.Tensor | None,
488
index_weights_out: torch.Tensor | None,
489
+ kv_c: torch.Tensor | None,
490
+ k_pe: torch.Tensor | None,
491
ql_nope: torch.Tensor,
492
mqa_q: torch.Tensor,
493
output: torch.Tensor,
494
) -> None:
495
if self.indexer is not None and not self.skip_topk:
496
+ assert index_q_fp8 is not None
497
+ assert index_weights_out is not None
498
+ if self.use_pcp:
499
+ assert index_k is not None
500
sparse_attn_indexer(
501
q_c,
502
self.indexer.k_cache.prefix,
503
self.indexer.k_cache.kv_cache,
504
index_q_fp8,
477
- None, # q_scale folded into weights on the fp8 path
478
- None, # k unused when skip_k_cache_insert=True
505
+ None,
506
+ index_k,
507
index_weights_out,
508
self.indexer.quant_block_size,
509
self.indexer.scale_fmt,
@@ -484,28 +512,56 @@ class DeepseekV32Attention(MLAAttention):
512
self.indexer.max_model_len,
513
self.indexer.max_total_seq_len,
514
self.topk_indices_buffer,
487
- skip_k_cache_insert=True,
488
- use_pcp=False,
515
+ skip_k_cache_insert=not self.use_pcp,
516
+ use_pcp=self.use_pcp,
517
dense_mha_metadata_layer_name=self._dense_mha_metadata_layer_name,
490
- use_fp4_cache=False,
491
- # fused_norm_rope already cleared the topk buffer this forward.
518
+ dcp_rank=(
519
+ self.dcp_manager.group.rank_in_group
520
+ if self.dcp_manager is not None
521
+ else 0
522
+ ),
523
+ dcp_world_size=(
524
+ self._vllm_config.parallel_config.decode_context_parallel_size
525
+ ),
526
+ cp_kv_cache_interleave_size=(
527
+ self._vllm_config.parallel_config.cp_kv_cache_interleave_size
528
+ ),
529
skip_topk_buffer_clear=True,
530
)
531
495
- attn_metadata_raw = get_forward_context().attn_metadata
496
- if isinstance(attn_metadata_raw, dict):
497
- attn_metadata = attn_metadata_raw.get(self.layer_name)
498
- elif isinstance(attn_metadata_raw, list):
499
- attn_metadata = attn_metadata_raw[0].get(self.layer_name)
500
- else:
501
- attn_metadata = attn_metadata_raw
502
-
532
+ attn_metadata, _, kv_cache, layer_slot_mapping = get_attention_context(
533
+ self.layer_name
534
+ )
535
if attn_metadata is None:
536
output.zero_()
537
return
538
+ attn_metadata = cast("MLACommonMetadata", attn_metadata)
539
+
540
+ if self.use_pcp:
541
+ assert kv_c is not None and k_pe is not None
542
+ kv_for_cache, kpe_for_cache, cache_slot_mapping = (
543
+ maybe_gather_mla_latent_cache_inputs(
544
+ kv_c,
545
+ k_pe.unsqueeze(1),
546
+ layer_slot_mapping,
547
+ attn_metadata.num_decode_tokens,
548
+ True,
549
+ )
550
+ )
551
+ self.impl.do_kv_cache_update( # type: ignore[attr-defined]
552
+ kv_for_cache,
553
+ kpe_for_cache,
554
+ kv_cache,
555
+ cache_slot_mapping,
556
+ self.kv_cache_dtype,
557
+ self._k_scale,
558
+ )
559
560
num_actual = attn_metadata.num_actual_tokens # type: ignore[attr-defined]
508
- kv_cache = self.kv_cache
561
+ if num_actual == 0:
562
+ output.zero_()
563
+ return
564
+
565
if self._fp8_kv_needs_view:
566
kv_cache = kv_cache.view(torch.float8_e4m3fn)
567
if self._fp8_query:
@@ -515,10 +571,35 @@ class DeepseekV32Attention(MLAAttention):
571
]
572
else:
573
mqa_q_arg = (ql_nope[:num_actual], mqa_q[:num_actual])
518
- attn_out, _ = self.impl.forward_mqa( # type: ignore[attr-defined]
574
+
575
+ if self.use_pcp and self.impl.dcp_world_size > self.impl.pcp_world_size:
576
+ if isinstance(mqa_q_arg, tuple):
577
+ mqa_q_arg = torch.cat(mqa_q_arg, dim=-1)
578
+ mqa_q_arg = get_tp_group().all_gather(mqa_q_arg, dim=1)
579
+ attn_out, lse = self.impl.forward_mqa( # type: ignore[attr-defined]
580
mqa_q_arg, kv_cache, attn_metadata, self
581
)
582
583
+ if self.use_pcp and self.impl.dcp_world_size > 1:
584
+ assert lse is not None and self.dcp_manager is not None
585
+ seq_lens = (
586
+ attn_metadata.decode.seq_lens
587
+ if attn_metadata.decode is not None
588
+ else cast(torch.Tensor, attn_metadata.seq_lens)[ # type: ignore[attr-defined]
589
+ : attn_metadata.num_decodes
590
+ ]
591
+ )
592
+ query_start_loc = attn_metadata.query_start_loc[
593
+ : attn_metadata.num_decodes + 1
594
+ ]
595
+ attn_out = self.dcp_manager.combine(
596
+ attn_out,
597
+ lse,
598
+ seq_lens=seq_lens,
599
+ query_start_loc=query_start_loc,
600
+ )
601
+ attn_out = finalize_mla_pcp_decode(attn_out, self.num_heads)
602
+
603
# NOTE(woosuk): While the below does not need to be in the eager region,
604
# we put it here to avoid copying the attention output. Move this back to the
605
# captured region once forward_mqa supports `out` argument.
@@ -531,3 +612,5 @@ class DeepseekV32Attention(MLAAttention):
612
.transpose(0, 1)
613
)
614
torch.bmm(x, self.W_UV, out=out)
615
+ if self.use_pcp and num_actual < output.shape[0]:
616
+ output[num_actual:].zero_()
vllm/models/deepseek_v32/common/kernels.py
+130
-85
@@ -103,12 +103,16 @@ def _fused_norm_rope_kernel(
103
kv_stride,
104
kv_rms_norm_w_ptr,
105
kv_rms_eps,
106
+ kv_out_ptr,
107
+ kv_out_stride,
108
KV_DIM: tl.constexpr,
109
# KV RoPE
110
kpe_ptr,
111
kpe_stride,
112
kpe_rope_cos_sin_cache_ptr,
113
kpe_rope_cos_sin_cache_stride,
114
+ kpe_out_ptr,
115
+ kpe_out_stride,
116
KPE_HALF_ROT_DIM: tl.constexpr,
117
# Index K layer norm
118
index_k_ptr,
@@ -121,6 +125,8 @@ def _fused_norm_rope_kernel(
125
# Index K RoPE
126
index_k_rope_cos_sin_cache_ptr,
127
index_k_rope_cos_sin_cache_stride,
128
+ index_k_out_ptr,
129
+ index_k_out_stride,
130
INDEX_K_HALF_ROT_DIM: tl.constexpr,
131
# Cache params (shared by indexer K and MLA)
132
slot_mapping_ptr,
@@ -174,10 +180,9 @@ def _fused_norm_rope_kernel(
180
return
181
182
if slot_mapping_ptr is None:
177
- # Memory profiling run.
178
- return
179
- slot_idx = tl.load(slot_mapping_ptr + tok_idx)
180
- if slot_idx < 0:
183
+ if kv_out_ptr is None and kpe_out_ptr is None and index_k_out_ptr is None:
184
+ return
185
+ elif tl.load(slot_mapping_ptr + tok_idx) < 0:
186
# Padding
187
return
188
@@ -217,63 +222,81 @@ def _fused_norm_rope_kernel(
222
r1 = x1 * cos - x2 * sin
223
r2 = x2 * cos + x1 * sin
224
220
- # MLA concat_and_cache: write [kv_c_normed, k_pe_roped] to cache.
221
- if mla_cache_entry_stride == 0:
222
- return
223
-
224
- mla_block_size = mla_cache_block_stride // mla_cache_entry_stride
225
- mla_block_idx = slot_idx // mla_block_size
226
- mla_block_off = slot_idx % mla_block_size
227
-
228
- if MLA_CACHE_DS_MLA:
229
- # fp8_ds_mla layout (DeepSeek-V3.2, KV_DIM == 512): per-128-element
230
- # tile of the NoPE is dynamically quantized to fp8 with its own
231
- # float32 scale; the RoPE tail is stored unquantized in bf16.
232
- # bytes [0, KV_DIM) : KV_DIM fp8 NoPE values
233
- # bytes [KV_DIM, KV_DIM + 16) : MLA_NUM_TILES float32 scales
234
- # bytes [KV_DIM + 16, ...) : 2 * KPE_HALF_ROT_DIM bf16 RoPE
235
- # mla_cache_block_stride / mla_cache_entry_stride are byte strides
236
- # (mla_cache_ptr is the 1-byte fp8 view of the uint8 cache).
237
- byte_base = (
238
- mla_block_idx * mla_cache_block_stride
239
- + mla_block_off * mla_cache_entry_stride
225
+ # PCP materializes K for the cross-rank gather, then inserts it into cache.
226
+ if kv_out_ptr is not None:
227
+ tl.store(kv_out_ptr + tok_idx * kv_out_stride + kv_block, kv_c)
228
+ if kpe_out_ptr is not None:
229
+ tl.store(
230
+ kpe_out_ptr + tok_idx * kpe_out_stride + dim_off * 2,
231
+ r1.to(kpe_out_ptr.dtype.element_ty),
232
)
241
- kv_2d = tl.reshape(kv_c, (MLA_NUM_TILES, MLA_TILE_DIM))
242
- tile_amax = tl.max(tl.abs(kv_2d), axis=1, keep_dims=True)
243
- # scale = amax / 448 (fp8 e4m3 max), matching the reference
244
- # concat_and_cache_ds_mla kernel; floored to FLT_MIN.
245
- tile_scale = tl.maximum(tile_amax * (1.0 / 448.0), 1.1754944e-38)
246
- kv_c_fp8 = tl.reshape((kv_2d / tile_scale).to(tl.float8e4nv), (KV_DIM,))
247
- tl.store(mla_cache_ptr + byte_base + kv_block, kv_c_fp8)
248
- tile_off = tl.arange(0, MLA_NUM_TILES)
233
tl.store(
250
- mla_cache_ds_scale_ptr + byte_base // 4 + KV_DIM // 4 + tile_off,
251
- tl.reshape(tile_scale, (MLA_NUM_TILES,)),
234
+ kpe_out_ptr + tok_idx * kpe_out_stride + dim_off * 2 + 1,
235
+ r2.to(kpe_out_ptr.dtype.element_ty),
236
)
253
- rope_dst = mla_cache_ds_rope_ptr + byte_base // 2 + (KV_DIM // 2 + 8)
254
- tl.store(rope_dst + dim_off * 2, r1.to(tl.bfloat16))
255
- tl.store(rope_dst + dim_off * 2 + 1, r2.to(tl.bfloat16))
256
- return
237
258
- dst = (
259
- mla_cache_ptr
260
- + mla_block_idx * mla_cache_block_stride
261
- + mla_block_off * mla_cache_entry_stride
262
- )
263
- # kv_c_normed (KV_DIM elements)
264
- if MLA_CACHE_FP8:
265
- scale = tl.load(mla_cache_scale_ptr)
266
- kv_c_fp8 = (kv_c.to(tl.float32) / scale).to(tl.float8e4nv)
267
- tl.store(dst + kv_block, kv_c_fp8)
268
- else:
269
- tl.store(dst + kv_block, kv_c)
270
- # k_pe_roped (from registers, interleaved layout)
271
- if MLA_CACHE_FP8:
272
- tl.store(dst + KV_DIM + dim_off * 2, (r1 / scale).to(tl.float8e4nv))
273
- tl.store(dst + KV_DIM + dim_off * 2 + 1, (r2 / scale).to(tl.float8e4nv))
274
- else:
275
- tl.store(dst + KV_DIM + dim_off * 2, r1)
276
- tl.store(dst + KV_DIM + dim_off * 2 + 1, r2)
238
+ if slot_mapping_ptr is not None:
239
+ # MLA concat_and_cache: write [kv_c_normed, k_pe_roped] to cache.
240
+ if mla_cache_entry_stride == 0:
241
+ return
242
+
243
+ slot_idx = tl.load(slot_mapping_ptr + tok_idx)
244
+ mla_block_size = mla_cache_block_stride // mla_cache_entry_stride
245
+ mla_block_idx = slot_idx // mla_block_size
246
+ mla_block_off = slot_idx % mla_block_size
247
+
248
+ if MLA_CACHE_DS_MLA:
249
+ # fp8_ds_mla layout (DeepSeek-V3.2, KV_DIM == 512): per-128-element
250
+ # tile of the NoPE is dynamically quantized to fp8 with its own
251
+ # float32 scale; the RoPE tail is stored unquantized in bf16.
252
+ # bytes [0, KV_DIM) : KV_DIM fp8 NoPE values
253
+ # bytes [KV_DIM, KV_DIM + 16) : MLA_NUM_TILES float32 scales
254
+ # bytes [KV_DIM + 16, ...) : 2 * KPE_HALF_ROT_DIM bf16 RoPE
255
+ # mla_cache_block_stride / mla_cache_entry_stride are byte strides
256
+ # (mla_cache_ptr is the 1-byte fp8 view of the uint8 cache).
257
+ byte_base = (
258
+ mla_block_idx * mla_cache_block_stride
259
+ + mla_block_off * mla_cache_entry_stride
260
+ )
261
+ kv_2d = tl.reshape(kv_c, (MLA_NUM_TILES, MLA_TILE_DIM))
262
+ tile_amax = tl.max(tl.abs(kv_2d), axis=1, keep_dims=True)
263
+ # scale = amax / 448 (fp8 e4m3 max), matching the reference
264
+ # concat_and_cache_ds_mla kernel; floored to FLT_MIN.
265
+ tile_scale = tl.maximum(tile_amax * (1.0 / 448.0), 1.1754944e-38)
266
+ kv_c_fp8 = tl.reshape((kv_2d / tile_scale).to(tl.float8e4nv), (KV_DIM,))
267
+ tl.store(mla_cache_ptr + byte_base + kv_block, kv_c_fp8)
268
+ tile_off = tl.arange(0, MLA_NUM_TILES)
269
+ tl.store(
270
+ mla_cache_ds_scale_ptr + byte_base // 4 + KV_DIM // 4 + tile_off,
271
+ tl.reshape(tile_scale, (MLA_NUM_TILES,)),
272
+ )
273
+ rope_dst = mla_cache_ds_rope_ptr + byte_base // 2 + (KV_DIM // 2 + 8)
274
+ tl.store(rope_dst + dim_off * 2, r1.to(tl.bfloat16))
275
+ tl.store(rope_dst + dim_off * 2 + 1, r2.to(tl.bfloat16))
276
+ return
277
+
278
+ dst = (
279
+ mla_cache_ptr
280
+ + mla_block_idx * mla_cache_block_stride
281
+ + mla_block_off * mla_cache_entry_stride
282
+ )
283
+ # kv_c_normed (KV_DIM elements)
284
+ if MLA_CACHE_FP8:
285
+ scale = tl.load(mla_cache_scale_ptr)
286
+ kv_c_fp8 = (kv_c.to(tl.float32) / scale).to(tl.float8e4nv)
287
+ tl.store(dst + kv_block, kv_c_fp8)
288
+ else:
289
+ tl.store(dst + kv_block, kv_c)
290
+ # k_pe_roped (from registers, interleaved layout)
291
+ if MLA_CACHE_FP8:
292
+ tl.store(dst + KV_DIM + dim_off * 2, (r1 / scale).to(tl.float8e4nv))
293
+ tl.store(
294
+ dst + KV_DIM + dim_off * 2 + 1,
295
+ (r2 / scale).to(tl.float8e4nv),
296
+ )
297
+ else:
298
+ tl.store(dst + KV_DIM + dim_off * 2, r1)
299
+ tl.store(dst + KV_DIM + dim_off * 2 + 1, r2)
300
elif pid == 0:
301
if not HAS_INDEXER:
302
# Shared layer: no indexer K to process.
@@ -354,20 +377,27 @@ def _fused_norm_rope_kernel(
377
roped = normed * cos_full + sign * normed_partner * sin_full
378
result = tl.where(in_rope, roped, normed)
379
357
- # 3. FP8 quantize + cache write from registers.
358
- # No need to write back to index_k_ptr — the only consumer
359
- # (sparse_attn_indexer) reads from the cache, not index_k.
360
- _fp8_quant_and_cache_write(
361
- result,
362
- index_k_mask,
363
- slot_idx,
364
- indexer_cache_ptr,
365
- indexer_cache_scale_ptr,
366
- indexer_cache_block_size,
367
- indexer_cache_stride,
368
- index_k_block,
369
- INDEX_K_DIM,
370
- )
380
+ if index_k_out_ptr is not None:
381
+ tl.store(
382
+ index_k_out_ptr + tok_idx * index_k_out_stride + index_k_block,
383
+ result.to(index_k_out_ptr.dtype.element_ty),
384
+ mask=index_k_mask,
385
+ )
386
+
387
+ # PCP inserts index K after gathering; other paths write it directly.
388
+ if indexer_cache_ptr is not None and slot_mapping_ptr is not None:
389
+ slot_idx = tl.load(slot_mapping_ptr + tok_idx)
390
+ _fp8_quant_and_cache_write(
391
+ result,
392
+ index_k_mask,
393
+ slot_idx,
394
+ indexer_cache_ptr,
395
+ indexer_cache_scale_ptr,
396
+ indexer_cache_block_size,
397
+ indexer_cache_stride,
398
+ index_k_block,
399
+ INDEX_K_DIM,
400
+ )
401
402
403
def fused_norm_rope(
@@ -395,6 +425,9 @@ def fused_norm_rope(
425
has_indexer: bool = True,
426
index_rope_interleave: bool = False,
427
q_c_out: torch.Tensor | None = None,
428
+ kv_c_out: torch.Tensor | None = None,
429
+ k_pe_out: torch.Tensor | None = None,
430
+ index_k_out: torch.Tensor | None = None,
431
) -> torch.Tensor:
432
assert positions.ndim == 1
433
assert q_c.ndim == 2
@@ -420,6 +453,10 @@ def fused_norm_rope(
453
assert index_k_rope_cos_sin_cache is not None
454
index_k_dim = index_k.shape[-1]
455
topk = topk_indices_buffer.shape[-1]
456
+ if indexer_k_cache is not None or mla_kv_cache is not None:
457
+ assert slot_mapping is not None
458
+ else:
459
+ slot_mapping = None
460
461
# --- Indexer K cache setup ---
462
if indexer_k_cache is not None:
@@ -430,17 +467,9 @@ def fused_norm_rope(
467
if indexer_k_cache.dtype == torch.uint8:
468
indexer_k_cache = indexer_k_cache.view(torch.float8_e4m3fn)
469
else:
433
- # No indexer cache (shared layer / MLA-only fusion). Use dummies but
434
- # KEEP the caller's slot_mapping so the MLA write (pid 1) still runs.
435
- idx_cache_scale_view = torch.empty(0, dtype=torch.float32, device=device)
436
- indexer_k_cache = torch.empty(0, dtype=torch.float8_e4m3fn, device=device)
470
+ idx_cache_scale_view = None
471
idx_cache_block_size = 1
438
- idx_cache_stride = 1
439
- if mla_kv_cache is None:
440
- # Pure profiling run (no caches at all): skip all per-token writes.
441
- slot_mapping = torch.full(
442
- (num_tokens,), -1, dtype=torch.int64, device=device
443
- )
472
+ idx_cache_stride = 0
473
474
# --- MLA KV cache setup ---
475
mla_cache_ds_mla = mla_kv_cache_dtype == "fp8_ds_mla"
@@ -469,15 +498,26 @@ def fused_norm_rope(
498
if mla_k_scale is None:
499
mla_k_scale = torch.ones(1, dtype=torch.float32, device=device)
500
else:
472
- # Dummy values — pid 2 will skip the MLA cache write because
473
- # slot_mapping is all -1.
501
+ # Dummy cache values; a zero entry stride disables the cache write.
502
mla_kv_cache = torch.empty(0, dtype=torch.bfloat16, device=device)
503
mla_block_stride = 0
504
mla_entry_stride = 0
477
- mla_k_scale = torch.ones(1, dtype=torch.float32, device=device)
505
+ mla_k_scale = _dummy((1,), torch.float32, device)
506
507
if q_c_out is None:
508
q_c_out = torch.empty_like(q_c)
509
+ kv_c_out_stride = 0
510
+ k_pe_out_stride = 0
511
+ index_k_out_stride = 0
512
+ if kv_c_out is not None:
513
+ assert kv_c_out.shape == kv_c.shape
514
+ kv_c_out_stride = kv_c_out.stride(0)
515
+ if k_pe_out is not None:
516
+ assert k_pe_out.shape == k_pe.shape
517
+ k_pe_out_stride = k_pe_out.stride(0)
518
+ if index_k_out is not None:
519
+ assert index_k_out.shape == index_k.shape
520
+ index_k_out_stride = index_k_out.stride(0)
521
use_pdl = current_platform.is_arch_support_pdl()
522
_fused_norm_rope_kernel[(num_tokens, 4)](
523
positions,
@@ -495,12 +535,16 @@ def fused_norm_rope(
535
kv_c.stride(0),
536
kv_rms_norm_w,
537
kv_rms_eps,
538
+ kv_c_out,
539
+ kv_c_out_stride,
540
kv_dim,
541
# KV RoPE
542
k_pe,
543
k_pe.stride(0),
544
k_rope_cos_sin_cache,
545
k_rope_cos_sin_cache.stride(0),
546
+ k_pe_out,
547
+ k_pe_out_stride,
548
k_rope_cos_sin_cache.shape[-1] // 2,
549
# Index K layer norm + RoPE + FP8 quant
550
index_k,
@@ -512,6 +556,8 @@ def fused_norm_rope(
556
triton.next_power_of_2(index_k_dim),
557
index_k_rope_cos_sin_cache,
558
index_k_rope_cos_sin_cache.stride(0),
559
+ index_k_out,
560
+ index_k_out_stride,
561
index_k_rope_cos_sin_cache.shape[-1] // 2,
562
# Cache params
563
slot_mapping,
@@ -793,7 +839,6 @@ def fused_q(
839
assert ql_nope.ndim == 3
840
assert ql_nope.shape[:2] == q_pe.shape[:2]
841
assert q_scale.dtype == torch.float32 and q_scale.numel() == 1
796
-
842
num_tokens = positions.shape[0]
843
num_q_heads = q_pe.shape[1]
844
# Grid's 3rd dim must cover the MQA-pack heads (pid 0/2 iterate 2 heads
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+2
-2
@@ -682,7 +682,7 @@ def rocm_aiter_sparse_attn_indexer_fake(
682
k_cache_prefix: LayerNameType,
683
kv_cache: torch.Tensor,
684
q_fp8: torch.Tensor,
685
- k: torch.Tensor,
685
+ k: torch.Tensor | None,
686
weights: torch.Tensor,
687
quant_block_size: int,
688
scale_fmt: str | None,
@@ -702,7 +702,7 @@ def rocm_aiter_sparse_attn_indexer(
702
k_cache_prefix: LayerNameType,
703
kv_cache: torch.Tensor,
704
q_fp8: torch.Tensor,
705
- k: torch.Tensor,
705
+ k: torch.Tensor | None,
706
weights: torch.Tensor,
707
quant_block_size: int,
708
scale_fmt: str | None,