[ROCm][Perf] DSv4 two-stage compressor kernel for HCA prefill (#47718)

Signed-off-by: kliuae <kuanfu.liu@embeddedllm.com>

kliuae committed Jul 16, 2026 at 07:31 UTC eb33ff34dd6501f23f5be92a5c842d34ab05d7fd
3 files changed +528
tests/kernels/test_compressor_kv_cache.py
+130
@@ -9,6 +9,7 @@ These tests cover:
9 C) Indexer: head_dim=128 (all FP8), quant_block=128
10 D) DeepseekV4 Attention magnitude range: correctness across small/large values
11 E) Indexer fused Triton kernel: compress+norm+rope+quant+insert
12 + F) Indexer fused two-stage Triton kernel: head=512 cr>=128 (no-overlap)
13 """
14
15 import math
@@ -24,7 +25,9 @@ from vllm.models.deepseek_v4.common.ops import (
25 from vllm.models.deepseek_v4.common.ops.fused_compress_quant_cache import (
26 _fused_kv_compress_norm_rope_insert_indexer_attn,
27 _fused_kv_compress_norm_rope_insert_indexer_mxfp4_attn,
28 + _launch_two_stage_sparse_attn_compressor,
29 )
30 +from vllm.platforms import current_platform
31
32 from .test_fused_indexer_q_rope_quant import quantize_to_mxfp4
33
@@ -816,3 +819,130 @@ def test_cutedsl_full_cache_store(compress_ratio: int, store_fp8: bool):
819 torch.testing.assert_close(actual.float(), ref_fp8.float(), rtol=0.0, atol=0.3)
820 else:
821 torch.testing.assert_close(actual.float(), ref.float(), rtol=3e-2, atol=3e-2)
822 +
823 +
824 +# ── Test F: DeepseekV4 Attention two-stage split compressor (Triton) ─────────
825 +#
826 +# Same full pipeline as Test E (state-cache gather -> softmax-weighted compress
827 +# -> RMSNorm -> GPT-J RoPE -> quant -> paged insert), but for the head=512
828 +# fp8_ds_mla layout via the two-stage split
829 +
830 +
831 +@pytest.mark.skipif(
832 + not current_platform.is_rocm(),
833 + reason="two-stage split compressor is only enabled for ROCm at the moment",
834 +)
835 +@pytest.mark.parametrize("num_tokens", [1, 4, 8, 17])
836 +@pytest.mark.parametrize("kv_block_size", [16, 64])
837 +def test_fused_kv_insert_split(num_tokens: int, kv_block_size: int):
838 + """Two-stage split compress+norm+rope+quant+insert for the head=512 KV cache."""
839 + HEAD_DIM = 512
840 + NOPE_DIM = 448
841 + ROPE_DIM = 64
842 + HEAD_BYTES = 584 # 448 fp8 + 128 bf16 + 8 uint8 scale
843 + RMS_EPS = 1e-6
844 + FP8_MAX = 448.0
845 + QUANT_BLOCK = 64
846 + TOKEN_STRIDE = 576
847 + SCALE_DIM = 8
848 + STATE_BLOCK_SIZE = 8 # CompressorStateCache block_size for cr=128
849 +
850 + device = "cuda"
851 + torch.manual_seed(42)
852 + compress_ratio = 128
853 + overlap = 0 # no overlap for cr=128
854 + coff = 1 + overlap
855 +
856 + num_pages = (compress_ratio * num_tokens - 1) // STATE_BLOCK_SIZE + 2
857 + state_cache = torch.randn(
858 + num_pages,
859 + STATE_BLOCK_SIZE,
860 + 2 * coff * HEAD_DIM, # kv_state + score_state
861 + dtype=torch.float32,
862 + device=device,
863 + )
864 + block_table = torch.arange(num_pages, dtype=torch.int32, device=device).unsqueeze(0)
865 + token_to_req = torch.zeros(num_tokens, dtype=torch.int32, device=device)
866 + slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device)
867 + positions = torch.arange(
868 + compress_ratio - 1,
869 + compress_ratio * num_tokens,
870 + compress_ratio,
871 + dtype=torch.int64,
872 + device=device,
873 + )
874 + rms_weight = torch.randn(HEAD_DIM, dtype=torch.bfloat16, device=device)
875 + cos_sin_cache = torch.randn(
876 + compress_ratio * num_tokens, ROPE_DIM, dtype=torch.float32, device=device
877 + )
878 +
879 + kv_n_blocks = (num_tokens + kv_block_size - 1) // kv_block_size + 1
880 + kv_cache = torch.zeros(
881 + kv_n_blocks, kv_block_size, HEAD_BYTES, dtype=torch.uint8, device=device
882 + )
883 + compress_scratch = torch.empty(
884 + num_tokens, HEAD_DIM, dtype=torch.float32, device=device
885 + )
886 +
887 + _launch_two_stage_sparse_attn_compressor(
888 + state_cache,
889 + token_to_req,
890 + positions,
891 + slot_mapping,
892 + block_table,
893 + STATE_BLOCK_SIZE,
894 + coff * HEAD_DIM,
895 + compress_ratio,
896 + cos_sin_cache,
897 + kv_cache,
898 + slot_mapping,
899 + rms_weight,
900 + RMS_EPS,
901 + QUANT_BLOCK,
902 + TOKEN_STRIDE,
903 + SCALE_DIM,
904 + HEAD_DIM,
905 + ROPE_DIM,
906 + num_tokens,
907 + compress_scratch,
908 + )
909 +
910 + # PyTorch reference: compress -> RMSNorm -> GPT-J RoPE (pre-quant bf16 row).
911 + ref = _reference_kv_compress_norm_rope(
912 + state_cache,
913 + block_table,
914 + positions,
915 + rms_weight,
916 + cos_sin_cache,
917 + compress_ratio,
918 + overlap,
919 + rms_eps=RMS_EPS,
920 + fp8_max=FP8_MAX,
921 + return_full_cache=True,
922 + ) # [num_tokens, HEAD_DIM] bf16
923 +
924 + # Dequant + gather the fp8_ds_mla cache back to bf16 (Test B op).
925 + out = torch.zeros(1, num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device)
926 + seq_lens = torch.tensor([num_tokens], dtype=torch.int32, device=device)
927 + gather_block_table = torch.arange(
928 + kv_n_blocks, dtype=torch.int32, device=device
929 + ).unsqueeze(0)
930 + dequantize_and_gather_k_cache(
931 + out, kv_cache, seq_lens, None, gather_block_table, kv_block_size, offset=0
932 + )
933 + recovered = out[0, :num_tokens]
934 +
935 + # NoPE (first 448): FP8 quantized, expect UE8M0 error (same bound as Test A).
936 + nope_diff = (recovered[:, :NOPE_DIM].float() - ref[:, :NOPE_DIM].float()).abs()
937 + for t in range(num_tokens):
938 + _, scales = _ue8m0_reference(ref[t, :NOPE_DIM].float(), QUANT_BLOCK, FP8_MAX)
939 + max_allowed = 16.0 * scales.max().item()
940 + token_diff = nope_diff[t].max().item()
941 + assert token_diff <= max_allowed, (
942 + f"Token {t} nope diff {token_diff} exceeds max_allowed "
943 + f"{max_allowed} (scale={scales.max().item()})"
944 + )
945 +
946 + # RoPE (last 64): stored as bf16. The kernel recomputes the rotation, so it
947 + # is bf16-close to the reference rather than bit-exact (cf. test_cutedsl).
948 + torch.testing.assert_close(recovered[:, NOPE_DIM:], ref[:, NOPE_DIM:])
vllm/models/deepseek_v4/common/ops/fused_compress_quant_cache.py
+355
@@ -19,6 +19,7 @@ even/odd halves, producing (N_QUANT_BLOCKS, MXFP4_BLOCK/2) packed nibbles
19 and N_QUANT_BLOCKS ue8m0 bytes.
20 """
21
22 +from functools import lru_cache
23 from typing import Any
24
25 import torch
@@ -296,6 +297,360 @@ def _fused_kv_compress_norm_rope_insert_sparse_attn(
297 tl.store(bf16_ptr + rope_local, result.to(tl.bfloat16), mask=is_rope)
298
299
300 +# =============================================================================
301 +# Split kernels variant of the head=512 compressor (deep cr=128 gather).
302 +# - compress gather: instead of launching one program per token, split along
303 +# the head dimension to maximize CU occupancy. The head dimension split
304 +# does not require cross-group reduction
305 +# - finalize norm rope quant store: same as the single pass kernel due to its
306 +# per-token nature
307 +# Mirrors the CUDA cutedsl split kernel where num_splits is occupancy-targeted.
308 +# Currently only tested and validated on ROCm gfx950
309 +# =============================================================================
310 +@lru_cache(maxsize=1)
311 +def _n_cu() -> int:
312 + return torch.cuda.get_device_properties(0).multi_processor_count
313 +
314 +
315 +def _pick_compress_num_splits(
316 + num_actual: int, compress_ratio: int, head_dim: int
317 +) -> int:
318 + """Occupancy-targeted column splits for the cr>=128 head=512 compressor.
319 +
320 + Sizes the per-token fan-out so (estimated computing tokens) * num_splits ~
321 + #CU, capped by head tiling at a 32-wide min tile, as a power-of-2 divisor of
322 + head_dim.
323 + """
324 + max_splits = head_dim // 32
325 + est_compute = max(1, num_actual // compress_ratio)
326 + target = -(-_n_cu() // est_compute) # ceil(#CU / est_compute)
327 + ns = 1
328 + while ns * 2 <= min(target, max_splits) and head_dim % (ns * 2) == 0:
329 + ns *= 2
330 + return ns
331 +
332 +
333 +@triton.jit
334 +def _compress_gather_split_sparse_attn(
335 + state_cache_ptr,
336 + state_cache_stride0,
337 + state_cache_stride1,
338 + positions_ptr,
339 + slot_mapping_ptr,
340 + token_to_req_indices_ptr,
341 + block_table_ptr,
342 + block_table_stride,
343 + block_size,
344 + scratch_ptr,
345 + scratch_stride,
346 + HEAD_SIZE: tl.constexpr,
347 + STATE_WIDTH: tl.constexpr,
348 + COMPRESS_RATIO: tl.constexpr,
349 + NUM_SPLITS: tl.constexpr,
350 + HEAD_TILE: tl.constexpr, # HEAD_SIZE // NUM_SPLITS
351 +):
352 + """Stage 1: per-(token, head-split) compress gather, write to fp32 scratch
353 +
354 + No-overlap gather (cr>=128) on rows [0, COMPRESS_RATIO)
355 + """
356 + pid = tl.program_id(0)
357 + token_idx = pid // NUM_SPLITS
358 + split_idx = pid % NUM_SPLITS
359 +
360 + slot_id = tl.load(slot_mapping_ptr + token_idx)
361 + if slot_id < 0:
362 + return
363 + position = tl.load(positions_ptr + token_idx)
364 + if (position + 1) % COMPRESS_RATIO != 0:
365 + return
366 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
367 +
368 + start = position - COMPRESS_RATIO + 1
369 + rows = tl.arange(0, COMPRESS_RATIO)
370 + pos = start + rows
371 + mask_pos = pos >= 0
372 + block_numbers = tl.load(
373 + block_table_ptr + req_idx * block_table_stride + pos // block_size,
374 + mask=mask_pos,
375 + other=0,
376 + ).to(tl.int64)
377 + block_offsets = pos % block_size
378 +
379 + col = split_idx * HEAD_TILE + tl.arange(0, HEAD_TILE)
380 + row_base = (
381 + state_cache_ptr
382 + + block_numbers * state_cache_stride0
383 + + block_offsets * state_cache_stride1
384 + )
385 + cmask = mask_pos[:, None]
386 +
387 + score = tl.load(
388 + row_base[:, None] + STATE_WIDTH + col[None, :],
389 + mask=cmask,
390 + other=float("-inf"),
391 + )
392 + score = tl.softmax(score, dim=0)
393 + kv = tl.load(row_base[:, None] + col[None, :], mask=cmask, other=0.0)
394 + compressed = tl.sum(kv * score, axis=0) # [HEAD_TILE] fp32
395 + tl.store(scratch_ptr + token_idx * scratch_stride + col, compressed)
396 +
397 +
398 +@triton.jit
399 +def _finalize_norm_rope_quant_store_sparse_attn(
400 + scratch_ptr,
401 + scratch_stride,
402 + positions_ptr,
403 + slot_mapping_ptr,
404 + rms_norm_weight_ptr,
405 + rms_norm_eps,
406 + cos_sin_cache_ptr,
407 + cos_sin_stride,
408 + k_cache_ptr,
409 + kv_slot_mapping_ptr,
410 + kv_cache_block_size,
411 + HEAD_SIZE: tl.constexpr,
412 + TRITON_BLOCK_SIZE: tl.constexpr,
413 + COMPRESS_RATIO: tl.constexpr,
414 + ROPE_HEAD_DIM: tl.constexpr,
415 + FP8_MAX: tl.constexpr,
416 + QUANT_BLOCK: tl.constexpr,
417 + TOKEN_STRIDE: tl.constexpr,
418 + SCALE_DIM: tl.constexpr,
419 + KV_BLOCK_STRIDE: tl.constexpr,
420 +):
421 + """Stage 2: read compressed_kv[512] from scratch buffer, then
422 + RMSNorm + FP8 quant (nope) + RoPE + bf16 store
423 + """
424 + token_idx = tl.program_id(0)
425 + slot_id = tl.load(slot_mapping_ptr + token_idx)
426 + if slot_id < 0:
427 + return
428 + position = tl.load(positions_ptr + token_idx)
429 + if (position + 1) % COMPRESS_RATIO != 0:
430 + return
431 +
432 + block = tl.arange(0, TRITON_BLOCK_SIZE)
433 + mask = block < HEAD_SIZE
434 + compressed_kv = tl.load(
435 + scratch_ptr + token_idx * scratch_stride + block, mask=mask, other=0.0
436 + )
437 +
438 + rms_w = tl.load(rms_norm_weight_ptr + block, mask=mask, other=0.0)
439 + variance = tl.sum(compressed_kv * compressed_kv, axis=0) / HEAD_SIZE
440 + rrms = tl.rsqrt(variance + rms_norm_eps)
441 + normed = compressed_kv * rrms * rms_w
442 +
443 + kv_slot_idx = tl.load(kv_slot_mapping_ptr + token_idx)
444 + if kv_slot_idx < 0:
445 + return
446 + kv_block_idx = kv_slot_idx // kv_cache_block_size
447 + kv_pos_in_block = kv_slot_idx % kv_cache_block_size
448 + cache_block_ptr = k_cache_ptr + kv_block_idx.to(tl.int64) * KV_BLOCK_STRIDE
449 + fp8_ptr = cache_block_ptr + kv_pos_in_block * TOKEN_STRIDE
450 + scale_ptr = (
451 + cache_block_ptr
452 + + kv_cache_block_size * TOKEN_STRIDE
453 + + kv_pos_in_block * SCALE_DIM
454 + )
455 +
456 + NOPE_HEAD_DIM: tl.constexpr = HEAD_SIZE - ROPE_HEAD_DIM
457 + HALF_ROPE: tl.constexpr = ROPE_HEAD_DIM // 2
458 + N_QUANT_BLOCKS: tl.constexpr = TRITON_BLOCK_SIZE // QUANT_BLOCK
459 + N_NOPE_BLOCKS: tl.constexpr = NOPE_HEAD_DIM // QUANT_BLOCK
460 + INV_FP8_MAX: tl.constexpr = 1.0 / FP8_MAX
461 +
462 + quant_input = normed.to(tl.bfloat16).to(tl.float32)
463 + quant_2d = tl.reshape(quant_input, (N_QUANT_BLOCKS, QUANT_BLOCK))
464 + block_absmax = tl.maximum(tl.max(tl.abs(quant_2d), axis=1), 1e-4)
465 + raw_scales = block_absmax * INV_FP8_MAX
466 + exponents = tl.ceil(tl.log2(raw_scales))
467 + inv_scales = tl.exp2(-exponents)
468 + x_scaled = quant_2d * tl.reshape(inv_scales, (N_QUANT_BLOCKS, 1))
469 + x_clamped = tl.clamp(x_scaled, -FP8_MAX, FP8_MAX)
470 + x_uint8 = tl.reshape(
471 + x_clamped.to(tl.float8e4nv).to(tl.uint8, bitcast=True),
472 + (TRITON_BLOCK_SIZE,),
473 + )
474 + tl.store(fp8_ptr + block, x_uint8, mask=block < NOPE_HEAD_DIM)
475 +
476 + scale_idx = tl.arange(0, N_QUANT_BLOCKS)
477 + encoded = tl.maximum(tl.minimum(exponents + 127.0, 255.0), 0.0)
478 + tl.store(
479 + scale_ptr + scale_idx, encoded.to(tl.uint8), mask=scale_idx < N_NOPE_BLOCKS
480 + )
481 + tl.store(scale_ptr + N_NOPE_BLOCKS, tl.zeros((), dtype=tl.uint8))
482 +
483 + NUM_PAIRS: tl.constexpr = TRITON_BLOCK_SIZE // 2
484 + NOPE_PAIRS: tl.constexpr = NOPE_HEAD_DIM // 2
485 + even, odd = tl.split(tl.reshape(normed, (NUM_PAIRS, 2)))
486 + pair_idx = tl.arange(0, NUM_PAIRS)
487 + rope_pair_local = pair_idx - NOPE_PAIRS
488 + is_rope_pair = rope_pair_local >= 0
489 + cs_idx = tl.maximum(rope_pair_local, 0)
490 + compressed_pos = (position // COMPRESS_RATIO) * COMPRESS_RATIO
491 + cache_base = cos_sin_cache_ptr + compressed_pos * cos_sin_stride
492 + cos_v = tl.load(cache_base + cs_idx, mask=is_rope_pair, other=1.0)
493 + sin_v = tl.load(cache_base + HALF_ROPE + cs_idx, mask=is_rope_pair, other=0.0)
494 + new_even = even * cos_v - odd * sin_v
495 + new_odd = odd * cos_v + even * sin_v
496 + result = tl.interleave(new_even, new_odd)
497 + bf16_ptr = (fp8_ptr + NOPE_HEAD_DIM).to(tl.pointer_type(tl.bfloat16))
498 + rope_local = block - NOPE_HEAD_DIM
499 + is_rope = (block >= NOPE_HEAD_DIM) & mask
500 + tl.store(bf16_ptr + rope_local, result.to(tl.bfloat16), mask=is_rope)
501 +
502 +
503 +def _launch_two_stage_sparse_attn_compressor(
504 + state_cache: torch.Tensor,
505 + token_to_req_indices: torch.Tensor,
506 + positions: torch.Tensor,
507 + slot_mapping: torch.Tensor,
508 + block_table: torch.Tensor,
509 + block_size: int,
510 + state_width: int,
511 + compress_ratio: int,
512 + cos_sin_cache: torch.Tensor,
513 + kv_cache: torch.Tensor,
514 + kv_slot_mapping: torch.Tensor,
515 + rms_norm_weight: torch.Tensor,
516 + rms_norm_eps: float,
517 + quant_block: int,
518 + token_stride: int,
519 + scale_dim: int,
520 + head_dim: int,
521 + rope_head_dim: int,
522 + num_actual: int,
523 + compress_scratch: torch.Tensor,
524 +) -> None:
525 + num_splits = _pick_compress_num_splits(num_actual, compress_ratio, head_dim)
526 + head_tile = head_dim // num_splits
527 + scratch = compress_scratch[:num_actual]
528 + _compress_gather_split_sparse_attn[(num_actual * num_splits,)](
529 + state_cache,
530 + state_cache.stride(0),
531 + state_cache.stride(1),
532 + positions,
533 + slot_mapping,
534 + token_to_req_indices,
535 + block_table,
536 + block_table.stride(0),
537 + block_size,
538 + scratch,
539 + scratch.stride(0),
540 + HEAD_SIZE=head_dim,
541 + STATE_WIDTH=state_width,
542 + COMPRESS_RATIO=compress_ratio,
543 + NUM_SPLITS=num_splits,
544 + HEAD_TILE=head_tile,
545 + )
546 + _finalize_norm_rope_quant_store_sparse_attn[(num_actual,)](
547 + scratch,
548 + scratch.stride(0),
549 + positions,
550 + slot_mapping,
551 + rms_norm_weight,
552 + rms_norm_eps,
553 + cos_sin_cache,
554 + cos_sin_cache.stride(0),
555 + kv_cache,
556 + kv_slot_mapping,
557 + kv_cache.shape[1],
558 + HEAD_SIZE=head_dim,
559 + TRITON_BLOCK_SIZE=triton.next_power_of_2(head_dim),
560 + COMPRESS_RATIO=compress_ratio,
561 + ROPE_HEAD_DIM=rope_head_dim,
562 + FP8_MAX=448.0,
563 + QUANT_BLOCK=quant_block,
564 + TOKEN_STRIDE=token_stride,
565 + SCALE_DIM=scale_dim,
566 + KV_BLOCK_STRIDE=kv_cache.stride(0),
567 + )
568 +
569 +
570 +def compress_norm_rope_store_two_stage_triton(
571 + state_cache: torch.Tensor,
572 + num_actual: int,
573 + token_to_req_indices: torch.Tensor,
574 + positions: torch.Tensor,
575 + slot_mapping: torch.Tensor,
576 + block_table: torch.Tensor,
577 + block_size: int,
578 + state_width: int,
579 + cos_sin_cache: torch.Tensor,
580 + kv_cache: torch.Tensor,
581 + k_cache_metadata: Any,
582 + pdl_kwargs: dict,
583 + head_dim: int,
584 + rope_head_dim: int,
585 + compress_ratio: int,
586 + overlap: bool,
587 + use_fp4_cache: bool,
588 + rms_norm_weight: torch.Tensor,
589 + rms_norm_eps: float,
590 + quant_block: int,
591 + token_stride: int,
592 + scale_dim: int,
593 + num_decode_tokens: int,
594 + compress_scratch: torch.Tensor,
595 +) -> None:
596 + """Two-stage split compressor dispatch for head=512 cr>=128 (no-overlap)
597 +
598 + Run the occupancy-fanned two-stage split for prefill [num_decodee_tokens:]
599 + to fill the CUs, and use the original single-pass launcher
600 + for decode [0, num_decode_tokens)
601 + """
602 + num_decodes = min(max(num_decode_tokens, 0), num_actual)
603 + num_prefills = num_actual - num_decodes
604 + if num_prefills > 0:
605 + _launch_two_stage_sparse_attn_compressor(
606 + state_cache=state_cache,
607 + token_to_req_indices=token_to_req_indices[num_decodes:],
608 + positions=positions[num_decodes:],
609 + slot_mapping=slot_mapping[num_decodes:],
610 + block_table=block_table,
611 + block_size=block_size,
612 + state_width=state_width,
613 + compress_ratio=compress_ratio,
614 + cos_sin_cache=cos_sin_cache,
615 + kv_cache=kv_cache,
616 + kv_slot_mapping=k_cache_metadata.slot_mapping[num_decodes:],
617 + rms_norm_weight=rms_norm_weight,
618 + rms_norm_eps=rms_norm_eps,
619 + quant_block=quant_block,
620 + token_stride=token_stride,
621 + scale_dim=scale_dim,
622 + head_dim=head_dim,
623 + rope_head_dim=rope_head_dim,
624 + num_actual=num_prefills,
625 + compress_scratch=compress_scratch,
626 + )
627 + if num_decodes > 0:
628 + compress_norm_rope_store_triton(
629 + state_cache=state_cache,
630 + num_actual=num_decodes,
631 + token_to_req_indices=token_to_req_indices,
632 + positions=positions,
633 + slot_mapping=slot_mapping,
634 + block_table=block_table,
635 + block_size=block_size,
636 + state_width=state_width,
637 + cos_sin_cache=cos_sin_cache,
638 + kv_cache=kv_cache,
639 + k_cache_metadata=k_cache_metadata,
640 + pdl_kwargs=pdl_kwargs,
641 + head_dim=head_dim,
642 + rope_head_dim=rope_head_dim,
643 + compress_ratio=compress_ratio,
644 + overlap=overlap,
645 + use_fp4_cache=use_fp4_cache,
646 + rms_norm_weight=rms_norm_weight,
647 + rms_norm_eps=rms_norm_eps,
648 + quant_block=quant_block,
649 + token_stride=token_stride,
650 + scale_dim=scale_dim,
651 + )
652 +
653 +
654 # =============================================================================
655 # Indexer path (head=128, all FP8, single quant block)
656 # =============================================================================
vllm/models/deepseek_v4/compressor.py
+43
@@ -14,6 +14,7 @@ from vllm.model_executor.layers.layernorm import RMSNorm
14 from vllm.model_executor.layers.linear import MergedColumnParallelLinear
15 from vllm.models.deepseek_v4.common.ops.fused_compress_quant_cache import (
16 compress_norm_rope_store_triton,
17 + compress_norm_rope_store_two_stage_triton,
18 )
19 from vllm.models.deepseek_v4.common.ops.fused_indexer_q import MXFP4_BLOCK_SIZE
20 from vllm.models.deepseek_v4.common.ops.save_partial_states import (
@@ -27,6 +28,7 @@ from vllm.v1.attention.backend import (
28 CommonAttentionMetadata,
29 MultipleOf,
30 )
31 +from vllm.v1.attention.backends.utils import split_decodes_and_prefills
32 from vllm.v1.kv_cache_interface import (
33 KVCacheSpec,
34 MLAAttentionSpec,
@@ -34,6 +36,12 @@ from vllm.v1.kv_cache_interface import (
36 )
37
38
39 +def _prefer_two_stage_compressor() -> bool:
40 + # Platforms that favor the triton variant of two-stage compressor split.
41 + # Currently only tested on ROCm
42 + return current_platform.is_rocm()
43 +
44 +
45 class CompressorBackend(AttentionBackend):
46 def __init__(self):
47 super().__init__()
@@ -81,6 +89,7 @@ class CompressorMetadata:
89 block_size: int
90
91 token_to_req_indices: torch.Tensor | None = None # [num_tokens]
92 + num_decode_tokens: int | None = None
93
94
95 class CompressorMetadataBuilder(AttentionMetadataBuilder):
@@ -107,11 +116,17 @@ class CompressorMetadataBuilder(AttentionMetadataBuilder):
116 token_to_req_indices = common_attn_metadata.token_to_req_indices(
117 self.token_to_req_indices
118 )
119 + num_decode_tokens = None
120 + if _prefer_two_stage_compressor():
121 + _, _, num_decode_tokens, _ = split_decodes_and_prefills(
122 + common_attn_metadata, decode_threshold=1
123 + )
124 return CompressorMetadata(
125 block_table=common_attn_metadata.block_table_tensor.clamp_(min=0),
126 slot_mapping=common_attn_metadata.slot_mapping,
127 block_size=self.block_size,
128 token_to_req_indices=token_to_req_indices,
129 + num_decode_tokens=num_decode_tokens,
130 )
131
132
@@ -213,6 +228,25 @@ class DeepseekCompressor(nn.Module):
228 self.overlap = compress_ratio == 4
229 self.coff = 1 + self.overlap
230
231 + # The head=512 cr>=128 no-overlap deep gather uses the two-stage
232 + # compressor, which needs an fp32 scratch [max_batched, 512] for
233 + # the intermediate compressed_kv.
234 + # Currently only tested on ROCm
235 + self._use_two_stage_fused_compressor = (
236 + _prefer_two_stage_compressor() and head_dim == 512 and not self.overlap
237 + )
238 + self.max_num_batched_tokens = (
239 + vllm_config.scheduler_config.max_num_batched_tokens
240 + )
241 + self._compress_scratch: torch.Tensor | None = None
242 + if self._use_two_stage_fused_compressor:
243 + self._compress_scratch = torch.empty(
244 + self.max_num_batched_tokens,
245 + self.head_dim,
246 + dtype=torch.float32,
247 + device=self.device,
248 + )
249 +
250 state_dtype = torch.float32
251 self.ape = nn.Parameter(
252 torch.empty(
@@ -364,6 +398,15 @@ class DeepseekCompressor(nn.Module):
398 store_full_fp8=store_full_fp8,
399 fp8_scale=fp8_scale,
400 )
401 + elif self._use_two_stage_fused_compressor:
402 + # head=512 cr>=128 (no overlap): two-pass split compressor on the
403 + # prefill suffix, single-pass on the decode prefix.
404 + assert state_metadata.num_decode_tokens is not None
405 + compress_norm_rope_store_fn = compress_norm_rope_store_two_stage_triton
406 + extra_kwargs = {
407 + "num_decode_tokens": state_metadata.num_decode_tokens,
408 + "compress_scratch": self._compress_scratch,
409 + }
410 else:
411 # Indexer path (head_dim == 128) or non-CUDA GPUs (AMD, XPU, etc.).
412 compress_norm_rope_store_fn = compress_norm_rope_store_triton