Files
vLLM/vllm/v1/attention/backends/mla/indexer.py
T
2026-10-12 23:24:43 -07:00

1977 lines
79 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from dataclasses import dataclass
from typing import Any
import numpy as np
import torch
import vllm.envs as envs
from vllm.config import CUDAGraphMode, VllmConfig, get_current_vllm_config
from vllm.distributed import get_dcp_group, get_pcp_group
from vllm.logger import init_logger
from vllm.model_executor.warmup.jit_warmup import kernel_launcher, zip_inputs
from vllm.model_executor.warmup.jit_warmup_triton_helper import (
LaunchSpec,
TritonPointerInputVariant,
TritonWarmupTensor,
VllmTritonJitKernel,
triton_scalar_specialization_rep,
)
from vllm.platforms import current_platform
from vllm.triton_utils import tl, triton
from vllm.utils.deep_gemm import (
get_paged_mqa_logits_metadata,
get_paged_mqa_page_sizes,
has_deep_gemm,
native_next_n_supported,
)
from vllm.utils.math_utils import round_down
from vllm.utils.platform_utils import num_compute_units
from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d
from vllm.v1.attention.backend import (
AttentionBackend,
AttentionCGSupport,
AttentionMetadataBuilder,
CommonAttentionMetadata,
MultipleOf,
)
from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping
from vllm.v1.attention.backends.mla.sparse_utils import request_row_bounds
from vllm.v1.attention.backends.utils import (
get_dcp_local_seq_lens,
split_decodes_and_prefills,
)
from vllm.v1.kv_cache_interface import (
AttentionSpec,
KVCacheLayout,
KVCacheSpec,
MLAAttentionSpec,
)
logger = init_logger(__name__)
# The DSA indexer K cache is always quantized; "auto" means fp8 (V3.2 layout)
# and mxfp4 is the opt-in Blackwell and gfx950 path.
DSA_INDEXER_KV_DTYPES = ("fp8", "mxfp4")
def dsa_indexer_uses_fp4(vllm_config: VllmConfig) -> bool:
"""Whether the DeepSeek sparse indexer should use the MXFP4 K cache."""
kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype("fp8")
if kv_dtype not in DSA_INDEXER_KV_DTYPES:
raise ValueError(
f"indexer_kv_dtype={kv_dtype!r} is not supported by the DeepSeek "
f"sparse indexer (expected one of {DSA_INDEXER_KV_DTYPES})."
)
use_fp4 = kv_dtype == "mxfp4"
if use_fp4 and current_platform.is_rocm():
from vllm._aiter_ops import rocm_aiter_ops
from vllm.platforms.rocm import get_cdna_version
# Only DeepSeek-V4.1 is wired to the ROCm MXFP4 cache; other DSA models
# would silently keep their FP8 one.
model_config = vllm_config.model_config
if model_config is None or model_config.hf_config.model_type != "deepseek_v41":
raise ValueError(
"indexer_kv_dtype='mxfp4' on ROCm is only supported for "
"DeepSeek-V4.1-Flash."
)
if get_cdna_version() != 4:
raise ValueError(
"indexer_kv_dtype='mxfp4' on ROCm requires CDNA4 (MI350X/MI355X)."
)
if not rocm_aiter_ops.is_enabled():
raise ValueError(
"indexer_kv_dtype='mxfp4' on ROCm runs on aiter's kernels; enable "
"aiter with VLLM_ROCM_USE_AITER=1."
)
return True
if use_fp4 and not current_platform.is_device_capability_family(100):
raise ValueError(
"indexer_kv_dtype='mxfp4' requires Blackwell datacenter GPUs "
"(sm_10x, e.g. B200/GB200); sm_120 (consumer Blackwell) and "
"earlier architectures are not supported."
)
return use_fp4
class PrepareUniformDecodeKernel(
VllmTritonJitKernel["PrepareUniformDecodeKernel.CompileKey"]
):
BLOCK_SIZE = 1024
@dataclass(frozen=True)
class CompileKey:
block_size: int
block_table_stride: int
expanded_bt_stride: int
max_decode_len: int
@staticmethod
@triton.jit
def kernel(
seq_lens_ptr,
decode_seq_lens_ptr,
block_table_ptr,
block_table_stride,
expanded_block_table_ptr,
expanded_bt_stride,
decode_lens_ptr,
max_decode_len,
BLOCK_SIZE: tl.constexpr,
):
idx = tl.program_id(0)
req_id = idx // max_decode_len
local_idx = idx % max_decode_len
# Compute number of KVs attended to by this token. Padding requests have
# seq_len == 0, which would otherwise make the first token of each padded
# request negative (e.g. next_n=2 gives 0-2+0+1 = -1). Downstream kernels
# read these as uint32, turning -1 into ~4e9.
seq_len = tl.load(seq_lens_ptr + req_id)
per_token_seq_len = tl.maximum(seq_len - max_decode_len + local_idx + 1, 0)
tl.store(decode_seq_lens_ptr + idx, per_token_seq_len)
# Copy block table row.
src = block_table_ptr + req_id * block_table_stride
dst = expanded_block_table_ptr + idx * expanded_bt_stride
for i in tl.range(0, expanded_bt_stride, BLOCK_SIZE):
off = i + tl.arange(0, BLOCK_SIZE)
mask = off < expanded_bt_stride
src_block = tl.load(src + off, mask=mask)
tl.store(dst + off, src_block, mask=mask)
# All reqs now have decode_len = 1.
tl.store(decode_lens_ptr + idx, 1)
def dispatch( # type: ignore[override]
self,
*,
block_size: int,
block_table_stride: int,
expanded_bt_stride: int,
max_decode_len: int,
) -> CompileKey:
return self.CompileKey(
block_size=block_size,
block_table_stride=triton_scalar_specialization_rep(block_table_stride),
expanded_bt_stride=triton_scalar_specialization_rep(expanded_bt_stride),
max_decode_len=triton_scalar_specialization_rep(max_decode_len),
)
def get_warmup_keys(self) -> list[CompileKey]:
return self._trace_dispatch(self.dispatch)(
block_size=self.BLOCK_SIZE,
block_table_stride=(1, 2, 16),
expanded_bt_stride=(1, 2, 16),
max_decode_len=(1, 2, 16),
)
def warmup_inputs(self, compile_key: CompileKey) -> dict[str, Any]:
int32_ptr = TritonWarmupTensor(torch.int32)
return dict(
seq_lens=int32_ptr,
decode_seq_lens=int32_ptr,
block_table=TritonWarmupTensor(
torch.int32,
shape=(1, compile_key.block_table_stride),
),
expanded_block_table=TritonWarmupTensor(
torch.int32,
shape=(1, compile_key.expanded_bt_stride),
),
decode_lens=int32_ptr,
num_decode_tokens=1,
max_decode_len=compile_key.max_decode_len,
)
@kernel_launcher
def __call__(
self,
seq_lens: torch.Tensor,
decode_seq_lens: torch.Tensor,
block_table: torch.Tensor,
expanded_block_table: torch.Tensor,
decode_lens: torch.Tensor,
num_decode_tokens: int,
max_decode_len: int,
) -> LaunchSpec:
return (num_decode_tokens,), dict(
block_table_stride=block_table.stride(0),
expanded_bt_stride=expanded_block_table.stride(0),
BLOCK_SIZE=self.BLOCK_SIZE,
)
# Below this many rows per rank, stay replicated (TP4 engages at 64K rows).
MIN_TP_SHARD_ROWS_PER_RANK = 16_384
def balanced_prefill_row_shard(
seq_lens_cpu: torch.Tensor,
query_lens_cpu: torch.Tensor,
compress_ratio: int,
tp_size: int,
) -> list[int] | None:
"""Return contiguous TP row counts balanced by indexer MQA work."""
num_rows = int(query_lens_cpu.sum())
if tp_size < 2 or num_rows < MIN_TP_SHARD_ROWS_PER_RANK * tp_size:
return None
query_lens = query_lens_cpu.to(torch.int64)
first_key = torch.repeat_interleave(
seq_lens_cpu.to(torch.int64) - query_lens + 1, query_lens
)
row_in_request = torch.arange(num_rows) - torch.repeat_interleave(
torch.cumsum(query_lens, 0) - query_lens, query_lens
)
cost = torch.cumsum((first_key + row_in_request) // compress_ratio, 0)
total = int(cost[-1]) if cost.numel() else 0
if total <= 0:
return None
targets = torch.arange(1, tp_size) * total // tp_size
bounds = [0, *torch.searchsorted(cost, targets).tolist(), num_rows]
# Keep >= 1 row per rank (the floor above leaves ample room); this also
# handles repeated costs from pool compression.
for i in range(1, tp_size):
bounds[i] = max(bounds[i], bounds[i - 1] + 1)
for i in range(tp_size - 1, 0, -1):
bounds[i] = min(bounds[i], bounds[i + 1] - 1)
return [bounds[i + 1] - bounds[i] for i in range(tp_size)]
def tp_prefill_row_sharding_supported(
vllm_config: VllmConfig,
dcp_world_size: int,
use_pcp: bool,
tp_size: int,
) -> bool:
"""Whether replicated prefill rows may be sharded across TP ranks."""
cudagraph_mode = vllm_config.compilation_config.cudagraph_mode or CUDAGraphMode.NONE
return (
current_platform.is_cuda()
and dcp_world_size == 1
and not use_pcp
and tp_size > 1
and not envs.VLLM_DISABLE_PYNCCL
and not envs.VLLM_USE_NCCL_SYMM_MEM
and not envs.VLLM_BATCH_INVARIANT
and cudagraph_mode.mixed_mode() != CUDAGraphMode.FULL
)
class DeepseekV32IndexerBackend(AttentionBackend):
@classmethod
def supports_pcp(cls) -> bool:
return True
@classmethod
def supports_device_cpu_query_lens_mismatch(cls) -> bool:
# Only the varlen paged MQA logits kernel takes per-request query
# lengths from device tensors natively. Hopper can instead flatten each
# query into a single-token row using device-built metadata.
return _supports_varlen_paged_mqa_logits() or (
_supports_flattened_device_query_lens()
)
@staticmethod
def get_name() -> str:
return "DEEPSEEK_V32_INDEXER"
@staticmethod
def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
return [1, MultipleOf(16)] if current_platform.is_rocm() else [64]
@classmethod
def get_supported_head_sizes(cls) -> list[int]:
return [32, 64, 128]
@staticmethod
def get_builder_cls() -> type["DeepseekV32IndexerMetadataBuilder"]:
return DeepseekV32IndexerMetadataBuilder
class Glm5NextIndexerBackend(DeepseekV32IndexerBackend):
@staticmethod
def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
if kv_cache_spec is not None:
# Worker get_kv_cache_spec: runs without the current config.
index_kpool = int(kv_cache_spec.tokens_per_state)
else:
# Platform block-size selection: no spec yet, under the current config.
model_config = get_current_vllm_config().model_config
index_kpool = model_config.hf_text_config.index_kpool
return [index_kpool * n for n in get_paged_mqa_page_sizes()]
class KpoolTailBackend(DeepseekV32IndexerBackend):
"""Storage-only backend for the GLM-5.3-Flash kpool tail cache."""
@classmethod
def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
return (KVCacheLayout.BLHNC,)
@staticmethod
def get_name() -> str:
return "KPOOL_TAIL"
@classmethod
def get_supported_head_sizes(cls) -> list[int]:
return []
@staticmethod
def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
return [MultipleOf(1)]
@staticmethod
def get_builder_cls() -> type["KpoolTailMetadataBuilder"]: # type: ignore[override]
return KpoolTailMetadataBuilder
class DeepseekV4IndexerBackend(DeepseekV32IndexerBackend):
@classmethod
def supports_device_cpu_query_lens_mismatch(cls) -> bool:
# ROCm runs adaptive verification through the per-token flattened
# indexer path, which derives row ownership from device decode lengths.
return (
current_platform.is_rocm()
or super().supports_device_cpu_query_lens_mismatch()
)
@staticmethod
def get_name() -> str:
return "DEEPSEEK_V4_INDEXER"
@classmethod
def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
# DeepSeek-V4 packs the indexer pages beside the MLA latent pages inside
# each block, so the layer dim must sit inside the block dim.
return (KVCacheLayout.BLHNC, KVCacheLayout.BLNHC)
@staticmethod
def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
# Block sizes count uncompressed tokens: C4 indexer pages hold 64 rows.
return [256]
class DeepseekV41IndexerBackend(DeepseekV4IndexerBackend):
@classmethod
def supports_device_cpu_query_lens_mismatch(cls) -> bool:
# The ROCm flattened-query support above is validated for the
# DeepSeek-V4 adaptive DSpark path only.
return DeepseekV32IndexerBackend.supports_device_cpu_query_lens_mismatch()
@staticmethod
def get_name() -> str:
return "DEEPSEEK_V41_INDEXER"
@staticmethod
def get_supported_kernel_block_sizes(kv_cache_spec=None) -> list[int | MultipleOf]:
return [64 if current_platform.is_device_capability_family(90) else 128]
@dataclass(frozen=True)
class PCPGlobalChunkPlan:
"""PCP packing for one indexer prefill chunk under PCP + DCP."""
# [num_reqs+1] cumsum of the padded per-request context (shard rows x W):
# the global layout.
row_start_cu: torch.Tensor
# [num_reqs+1] like row_start_cu, but only a region's FIRST row carries its
# extent.
global_cu: torch.Tensor
# [num_reqs+1] cumsum of shard rows: this rank's padded layout.
padded_local_cu: torch.Tensor
padded_local_total: int
total: int
# [total] for each global row, its position in the rank-major gathered buffer.
deinterleave_idx: torch.Tensor
def build_pcp_global_chunk_plan(
row_req_idx: np.ndarray,
row_shard_rows: np.ndarray,
dcp_world_size: int,
device: torch.device,
interleave: int = 1,
) -> PCPGlobalChunkPlan:
"""Plan the PCP packing for one chunk from its requests' KV shard rows.
The global layout is padded to ``shard rows x W`` per request. Positions
past a request's true extent lie beyond every token's causal bound, so
the kernel never reads them.
"""
shard_rows = np.ascontiguousarray(row_shard_rows, dtype=np.int64)
assert row_req_idx.shape == shard_rows.shape
num_rows = len(shard_rows)
row_bounds = request_row_bounds(row_req_idx)
region_first_row = row_bounds[:-1]
region_of_row = np.repeat(np.arange(len(region_first_row)), np.diff(row_bounds))
region_padded = shard_rows[region_first_row]
assert np.all(region_padded > 0), (
f"PCP+DCP prefill got an empty context: {region_padded.tolist()}"
)
# Pinned host buffers, filled in place so the copies below are async.
cu = torch.zeros((3, num_rows + 1), dtype=torch.int32, pin_memory=PIN_MEMORY)
row_start_cu, global_cu, padded_cu = cu
# Only a region's first row carries its extent.
first_row = torch.from_numpy(region_first_row)
row_padded = torch.zeros(num_rows, dtype=torch.int32)
row_padded[first_row] = torch.from_numpy(region_padded).int()
torch.cumsum(row_padded * dcp_world_size, 0, out=global_cu[1:])
torch.cumsum(row_padded, 0, out=padded_cu[1:])
row_start_cu[:num_rows] = global_cu[first_row][torch.from_numpy(region_of_row)]
row_start_cu[num_rows] = global_cu[num_rows]
total = int(global_cu[num_rows])
padded_total = int(padded_cu[num_rows])
idx = torch.empty(total, dtype=torch.int64, pin_memory=PIN_MEMORY)
idx_np = idx.numpy()
region_start = global_cu[first_row].numpy()
region_padded_start = padded_cu[first_row].numpy()
for i in range(len(region_first_row)):
g = int(region_padded[i]) * dcp_world_size
t = np.arange(g, dtype=np.int64)
local = round_down(t // dcp_world_size, interleave) + t % interleave
# Padded positions are outside causal bounds but must remain in-bounds.
local = np.minimum(local, region_padded[i] - 1)
idx_np[region_start[i] : region_start[i] + g] = (
((t // interleave) % dcp_world_size) * padded_total
+ region_padded_start[i]
+ local
)
cu = async_tensor_h2d(cu, device=device)
return PCPGlobalChunkPlan(
row_start_cu=cu[0],
global_cu=cu[1],
padded_local_cu=cu[2],
padded_local_total=padded_total,
total=total,
deinterleave_idx=async_tensor_h2d(idx, device=device),
)
@dataclass
class DeepseekV32IndexerPrefillChunkMetadata:
block_table: torch.Tensor
# Under DCP (dcp_world_size > 1) these hold this rank's local row bounds;
# otherwise they hold the global bounds.
cu_seqlen_ks: torch.Tensor
cu_seqlen_ke: torch.Tensor
cu_seq_lens: torch.Tensor
token_to_seq: torch.Tensor
total_seq_lens: int
# Host upper bound on cu_seqlen_ke - cu_seqlen_ks, sizing the compressed
# DeepGEMM prefill logits.
max_seqlen_k: int
token_start: int
token_end: int
num_reqs: int
skip_kv_gather: bool = False
local_cu_seq_lens: torch.Tensor | None = None
local_total_seq_lens: int = 0
max_local_total_seq_lens: int = 0
pcp_deinterleave_idx: torch.Tensor | None = None
class BuildPrefillChunkMetadataKernel(
VllmTritonJitKernel["BuildPrefillChunkMetadataKernel.CompileKey"]
):
BLOCK_SIZE = 1024
@dataclass(frozen=True)
class CompileKey:
query_slice_start: int
query_slice_stop: int
dcp_rank: int
dcp_world: int
dcp_interleave: int
block_size: int
compress_ratio: int
input_variant: TritonPointerInputVariant
@staticmethod
@triton.jit
def kernel(
# Inputs
query_start_loc_ptr,
uncompressed_seq_lens_ptr,
cu_compressed_seq_lens_ptr,
# Row-start base for cu_seq_len_ks/ke: local cumulative lens under DCP,
# aliases cu_compressed_seq_lens_ptr otherwise.
row_start_cu_compressed_seq_lens_ptr,
# Outputs
token_to_seq_ptr,
cu_compressed_seq_len_ks_ptr,
cu_compressed_seq_len_ke_ptr,
query_slice_start,
query_slice_stop,
DCP_RANK,
DCP_WORLD,
DCP_INTERLEAVE,
BLOCK_SIZE: tl.constexpr,
COMPRESS_RATIO: tl.constexpr,
):
batch_idx = tl.program_id(0)
query_start = tl.load(query_start_loc_ptr + batch_idx)
query_end = tl.load(query_start_loc_ptr + batch_idx + 1)
query_len = query_end - query_start
seq_start = tl.load(cu_compressed_seq_lens_ptr + batch_idx)
seq_end = tl.load(cu_compressed_seq_lens_ptr + batch_idx + 1)
compressed_seq_len = seq_end - seq_start
# Row start for the (possibly localized) cu_seq_len_ks/ke. Equals seq_start
# when DCP is disabled (the pointer aliases cu_compressed_seq_lens_ptr).
row_start = tl.load(row_start_cu_compressed_seq_lens_ptr + batch_idx)
uncompressed_seq_len = tl.load(uncompressed_seq_lens_ptr + batch_idx)
start_pos = uncompressed_seq_len - query_len
for i in range(0, query_len, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
abs_pos = query_start + offset
mask = (
(offset < query_len)
& (abs_pos >= query_slice_start)
& (abs_pos < query_slice_stop)
)
out_pos = abs_pos - query_slice_start
# cu_seq_len_ks: row start in the gathered K buffer.
tl.store(cu_compressed_seq_len_ks_ptr + out_pos, row_start, mask=mask)
# cu_seq_len_ke: row start + per-token context length. Under DCP the
# global per-token length is sharded across ranks.
global_ctx = start_pos + 1 + offset
len_per_token = global_ctx // COMPRESS_RATIO
if DCP_WORLD > 1:
# Per-rank local context length under interleave-aware DCP, matching
# get_dcp_local_seq_lens. K == 1 reduces to (len + world-1-rank)//world.
base = (len_per_token // DCP_INTERLEAVE // DCP_WORLD) * DCP_INTERLEAVE
remainder = len_per_token - base * DCP_WORLD
remainder = tl.minimum(
tl.maximum(remainder - DCP_RANK * DCP_INTERLEAVE, 0), DCP_INTERLEAVE
)
len_per_token = base + remainder
tl.store(
cu_compressed_seq_len_ke_ptr + out_pos,
row_start + len_per_token,
mask=mask,
)
# Compute token_to_seq
for i in range(0, compressed_seq_len, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < compressed_seq_len
tl.store(token_to_seq_ptr + seq_start + offset, batch_idx, mask=mask)
def dispatch( # type: ignore[override]
self,
*,
query_slice_start: int,
query_slice_stop: int,
DCP_RANK: int,
DCP_WORLD: int,
DCP_INTERLEAVE: int,
BLOCK_SIZE: int,
COMPRESS_RATIO: int,
input_variant: TritonPointerInputVariant,
) -> CompileKey:
return self.CompileKey(
query_slice_start=triton_scalar_specialization_rep(query_slice_start),
query_slice_stop=triton_scalar_specialization_rep(query_slice_stop),
dcp_rank=triton_scalar_specialization_rep(DCP_RANK),
dcp_world=triton_scalar_specialization_rep(DCP_WORLD),
dcp_interleave=triton_scalar_specialization_rep(DCP_INTERLEAVE),
block_size=BLOCK_SIZE,
compress_ratio=COMPRESS_RATIO,
input_variant=input_variant,
)
def get_warmup_keys(self, vllm_config: VllmConfig) -> list[CompileKey]:
max_tokens = max(1, min(vllm_config.scheduler_config.max_num_batched_tokens, 8))
hf_text_config = vllm_config.model_config.hf_text_config
parallel_config = vllm_config.parallel_config
dcp_world = parallel_config.decode_context_parallel_size
dcp_interleave = parallel_config.cp_kv_cache_interleave_size
dcp_rank = get_dcp_group().rank_in_group if dcp_world > 1 else 0
dcp_cases = [dict(DCP_RANK=dcp_rank, DCP_WORLD=dcp_world)]
if parallel_config.prefill_context_parallel_size > 1 and dcp_world > 1:
# PCP+DCP prefill constructs a global PCP chunk plan and therefore
# dispatches this kernel with already-localized row starts. The
# runtime key is normalized to rank 0/world 1 in that path; warm it
# explicitly so one PCP rank cannot enter a collective while
# another rank is still compiling the first real inference batch.
dcp_cases.append(dict(DCP_RANK=0, DCP_WORLD=1))
compress_ratios = tuple(
dict.fromkeys(
max(1, int(ratio))
for ratio in (
*(getattr(hf_text_config, "compress_ratios", None) or (1,)),
getattr(hf_text_config, "index_kpool", 1) or 1,
)
)
)
index_kpool = getattr(hf_text_config, "index_kpool", None)
if index_kpool and index_kpool > 1 and index_kpool not in compress_ratios:
compress_ratios = compress_ratios + (index_kpool,)
return self._trace_dispatch(self.dispatch)(
zip_inputs(*dcp_cases),
# Cover Triton's divisible, exact-one, and generic i32 classes.
query_slice_start=(0, 1, 2),
query_slice_stop=(1, 2 * max_tokens - 1, 2 * max_tokens),
# Compressed indexers localize in units of compressed states.
DCP_INTERLEAVE=list(
dict.fromkeys(
dcp_interleave // ratio
for ratio in compress_ratios
if dcp_interleave % ratio == 0
)
)
or [dcp_interleave],
BLOCK_SIZE=self.BLOCK_SIZE,
COMPRESS_RATIO=list(compress_ratios),
# PCP's global cumulative lengths are the second row of one packed
# allocation, so their pointer is not always 16-byte aligned.
# Prefill chunking can independently unalign uncompressed lengths.
input_variant=(
TritonPointerInputVariant.from_alignment(
uncompressed_seq_lens=True,
cu_compressed_seq_lens=True,
),
TritonPointerInputVariant.from_alignment(
uncompressed_seq_lens=True,
cu_compressed_seq_lens=False,
),
TritonPointerInputVariant.from_alignment(
uncompressed_seq_lens=False,
cu_compressed_seq_lens=True,
),
TritonPointerInputVariant.from_alignment(
uncompressed_seq_lens=False,
cu_compressed_seq_lens=False,
),
),
)
def warmup_inputs(self, compile_key: CompileKey) -> dict[str, Any]:
int32_ptr = TritonWarmupTensor(torch.int32)
return dict(
query_start_loc=int32_ptr,
uncompressed_seq_lens=compile_key.input_variant.pointer(
"uncompressed_seq_lens", torch.int32
),
cu_compressed_seq_lens=compile_key.input_variant.pointer(
"cu_compressed_seq_lens", torch.int32
),
row_start_cu_compressed_seq_lens=int32_ptr,
token_to_seq=int32_ptr,
cu_compressed_seq_len_ks=int32_ptr,
cu_compressed_seq_len_ke=int32_ptr,
query_slice_start=compile_key.query_slice_start,
query_slice_stop=compile_key.query_slice_stop,
DCP_RANK=compile_key.dcp_rank,
DCP_WORLD=compile_key.dcp_world,
DCP_INTERLEAVE=compile_key.dcp_interleave,
num_reqs=1,
COMPRESS_RATIO=compile_key.compress_ratio,
)
@kernel_launcher
def __call__(
self,
query_start_loc: torch.Tensor,
uncompressed_seq_lens: torch.Tensor,
cu_compressed_seq_lens: torch.Tensor,
row_start_cu_compressed_seq_lens: torch.Tensor,
token_to_seq: torch.Tensor,
cu_compressed_seq_len_ks: torch.Tensor,
cu_compressed_seq_len_ke: torch.Tensor,
query_slice_start: int,
query_slice_stop: int,
DCP_RANK: int,
DCP_WORLD: int,
DCP_INTERLEAVE: int,
*,
num_reqs: int,
COMPRESS_RATIO: int,
) -> LaunchSpec:
return (num_reqs,), dict(
BLOCK_SIZE=self.BLOCK_SIZE,
COMPRESS_RATIO=COMPRESS_RATIO,
)
@dataclass
class DeepseekV32IndexerPrefillMetadata:
chunks: list[DeepseekV32IndexerPrefillChunkMetadata]
max_prefill_seq_len: int = -1
# Per-TP-rank contiguous row counts, or None for the replicated path.
row_shard_sizes: list[int] | None = None
@dataclass
class DeepSeekV32IndexerDecodeMetadata:
block_table: torch.Tensor
# seq_lens: per-token effective context lengths.
# - flatten path / plain decode: 1D (batch_size,)
# - native MTP path: 2D (B, next_n) where [b,j] = L_b - next_n + j + 1
# Both fp8_fp4_paged_mqa_logits and the topk kernels accept both shapes.
seq_lens: torch.Tensor
decode_lens: torch.Tensor
requires_padding: bool
schedule_metadata: torch.Tensor
global_seq_lens: torch.Tensor | None = None
per_req_decode_lens: torch.Tensor | None = None
decode_is_uniform: bool = True
write_max_decode_len: int = 0
indices: torch.Tensor | None = None
@dataclass
class DeepseekV32IndexerMetadata:
# FIXME (zyongye)
# hacky way to access the data now, need to be in chunked meta
seq_lens: torch.Tensor
max_seq_len: int
slot_mapping: torch.Tensor
# New for MLA (compared to FlashAttention)
# For handling prefill decode split
num_decodes: int
num_decode_tokens: int
num_prefills: int
num_prefill_tokens: int
decode: DeepSeekV32IndexerDecodeMetadata | None = None
prefill: DeepseekV32IndexerPrefillMetadata | None = None
positions: torch.Tensor | None = None
block_table: torch.Tensor | None = None
@triton.jit(do_not_specialize=["num_reqs", "num_actual_tokens", "num_tokens"])
def _kpool_tail_slot_mapping_kernel(
slot_mapping_ptr,
block_table_ptr,
block_table_stride,
query_start_loc_ptr,
positions_ptr,
out_ptr,
num_reqs,
num_actual_tokens,
num_tokens,
kpool,
BLOCK: tl.constexpr,
):
pid = tl.program_id(0)
if pid < num_reqs:
start = tl.load(query_start_loc_ptr + pid)
# Tokens past the last request's boundary (if any) also map to it.
end = tl.load(query_start_loc_ptr + pid + 1)
end = tl.where(pid == num_reqs - 1, num_actual_tokens, end)
own_block = tl.load(block_table_ptr + pid * block_table_stride).to(tl.int64)
# Every scheduled request owns a tail block; the runners give dummy and
# padding requests the null block, which must never be written.
has_block = own_block != 0
for i in range(start, end, BLOCK):
offs = i + tl.arange(0, BLOCK)
mask = offs < end
pos = tl.load(positions_ptr + offs, mask=mask, other=0).to(tl.int64)
slot = tl.where(has_block, own_block * kpool + pos % kpool, -1)
tl.store(out_ptr + offs, slot, mask=mask)
else:
offs = num_actual_tokens + (pid - num_reqs) * BLOCK + tl.arange(0, BLOCK)
mask = offs < num_tokens
tl.store(out_ptr + offs, tl.load(slot_mapping_ptr + offs, mask=mask), mask=mask)
def compute_kpool_tail_slot_mapping(
slot_mapping: torch.Tensor,
block_table: torch.Tensor,
query_start_loc: torch.Tensor,
positions: torch.Tensor,
num_actual_tokens: int,
num_reqs: int,
kpool: int,
out: torch.Tensor | None = None,
) -> torch.Tensor:
"""Map every token to its request's one circular tail block."""
if out is None:
out = torch.empty_like(slot_mapping)
else:
assert out.shape == slot_mapping.shape
if slot_mapping.is_cuda and slot_mapping.dim() == 1 and num_reqs > 0:
block = 256
num_tokens = slot_mapping.shape[0]
num_actual_tokens = min(num_actual_tokens, num_tokens)
grid = (num_reqs + triton.cdiv(num_tokens - num_actual_tokens, block),)
_kpool_tail_slot_mapping_kernel[grid](
slot_mapping,
block_table,
block_table.stride(0),
query_start_loc,
positions,
out,
num_reqs,
num_actual_tokens,
num_tokens,
kpool,
BLOCK=block,
num_warps=4,
)
return out
# Torch fallback: CPU tensors (the CPU unit tests), non-1D or empty inputs.
# Production always takes the Triton path above — spec-decode tokens arrive
# flattened token-major, so slot_mapping is 1D there too.
out.copy_(slot_mapping)
if num_actual_tokens == 0:
return out
tokens = torch.arange(num_actual_tokens, device=slot_mapping.device)
req = torch.searchsorted(query_start_loc, tokens, right=True) - 1
req = req.clamp_(min=0, max=num_reqs - 1)
own_block = block_table[:num_reqs, 0].index_select(0, req).to(torch.int64)
pos = positions[:num_actual_tokens].to(torch.int64)
slots = own_block * kpool + torch.remainder(pos, kpool)
out[:num_actual_tokens] = torch.where(own_block != 0, slots, -1)
return out
class KpoolTailMetadataBuilder(AttentionMetadataBuilder):
"""Build only the circular slot mapping needed by the storage-only tail."""
_cudagraph_support = AttentionCGSupport.ALWAYS
supports_update_block_table = False
supports_draft_decode_metadata_update = True
reorder_batch_threshold = None
def __init__(
self,
kv_cache_spec: AttentionSpec,
layer_names: list[str],
vllm_config: VllmConfig,
device: torch.device,
):
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
self.slot_mapping_buffer = torch.empty(
vllm_config.scheduler_config.max_num_batched_tokens,
dtype=torch.int64,
device=device,
)
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
) -> DeepseekV32IndexerMetadata:
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
split_decodes_and_prefills(common_attn_metadata)
)
slot_mapping = common_attn_metadata.slot_mapping
positions = common_attn_metadata.positions
if positions is not None:
slot_mapping_buffer = self.slot_mapping_buffer[
: slot_mapping.numel()
].view_as(slot_mapping)
slot_mapping = compute_kpool_tail_slot_mapping(
slot_mapping,
common_attn_metadata.block_table_tensor,
common_attn_metadata.query_start_loc,
positions,
common_attn_metadata.num_actual_tokens,
common_attn_metadata.num_reqs,
self.kv_cache_spec.block_size,
out=slot_mapping_buffer,
)
return DeepseekV32IndexerMetadata(
seq_lens=common_attn_metadata.seq_lens,
max_seq_len=common_attn_metadata.max_seq_len,
slot_mapping=slot_mapping,
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
num_prefills=num_prefills,
num_prefill_tokens=num_prefill_tokens,
positions=positions,
)
def update_draft_decode_metadata(
self,
metadata: DeepseekV32IndexerMetadata,
) -> None:
assert metadata.positions is not None
slot_mapping = metadata.slot_mapping[: metadata.num_decode_tokens]
slot_mapping.div_(self.kv_cache_spec.block_size, rounding_mode="floor")
slot_mapping.mul_(self.kv_cache_spec.block_size)
slot_mapping.add_(
metadata.positions[: metadata.num_decode_tokens].remainder(
self.kv_cache_spec.block_size
)
)
def get_max_prefill_buffer_size(vllm_config: VllmConfig):
max_model_len = vllm_config.model_config.max_model_len
factor = vllm_config.attention_config.sparse_mla_prefill_workspace_factor
assert factor is not None
# Each entry is 128 fp8 bytes and 4 scale bytes for a total of 132 bytes.
# The flashmla_sparse backend uses a workspace size of factor * max_model_len.
# The memory usage of the workspace there is 576 * 2 bytes; so we size this as
# (576 * 2 // 132) * factor (40 at factor 5) to maximize this workspace
# size while still fitting within the flashmla_sparse workspace.
# For DeepSeek-V3.2, the max_model_len is 163840.
# 40 * 163840 * 132 = 865075200 bytes = 825 MB
return max_model_len * (576 * 2 // 132) * factor
def _supports_varlen_paged_mqa_logits() -> bool:
return (
current_platform.is_cuda()
and current_platform.is_device_capability_family(100)
and has_deep_gemm()
)
def _supports_flattened_device_query_lens() -> bool:
return (
current_platform.is_cuda()
and current_platform.is_device_capability_family(90)
and has_deep_gemm()
)
def _rocm_supports_flattened_device_query_lens(vllm_config: VllmConfig) -> bool:
model_config = vllm_config.model_config
return (
current_platform.is_rocm()
and model_config is not None
and "DeepseekV4ForCausalLM" in model_config.architectures
)
def _supports_native_decode(next_n: int) -> bool:
"""Whether decode can pass `next_n` Q rows per request to the kernel
instead of flattening to one single-token row per query, which re-reads
the KV tile once per row.
"""
if not (current_platform.is_cuda() and has_deep_gemm()):
return next_n in (1, 2)
if current_platform.is_device_capability_family(100):
return True
if current_platform.is_device_capability_family(90):
return native_next_n_supported(next_n)
return next_n in (1, 2)
def _use_flattening(vllm_config: VllmConfig) -> bool:
speculative_config = vllm_config.speculative_config
next_n = 1 + vllm_config.num_speculative_tokens
return not _supports_native_decode(next_n) or (
speculative_config is not None
and speculative_config.enable_adaptive_verification
and (
_supports_flattened_device_query_lens()
or _rocm_supports_flattened_device_query_lens(vllm_config)
)
)
class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
# The indexer opts out of the shared reorder-threshold vote (see __init__),
# so this is None; its own split uses self.decode_threshold.
reorder_batch_threshold: int | None = None
requires_block_table_width = True
@classmethod
def get_cudagraph_support(
cls,
vllm_config: VllmConfig,
kv_cache_spec: KVCacheSpec,
) -> AttentionCGSupport:
if _supports_varlen_paged_mqa_logits() or _use_flattening(vllm_config):
return AttentionCGSupport.ALWAYS
return AttentionCGSupport.UNIFORM_BATCH
def __init__(self, *args, block_table_width: int, **kwargs) -> None:
super().__init__(*args, **kwargs)
scheduler_config = self.vllm_config.scheduler_config
parallel_config = self.vllm_config.parallel_config
self.dcp_world_size = parallel_config.decode_context_parallel_size
self.tp_size = parallel_config.tensor_parallel_size
self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
self.pcp_world_size = parallel_config.prefill_context_parallel_size
self.use_pcp = self.pcp_world_size > 1
self.pcp_rank = get_pcp_group().rank_in_group if self.use_pcp else 0
self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
# KV compression (DeepseekV4). Default to 1 for no compression.
self.compress_ratio = 1
if isinstance(self.kv_cache_spec, MLAAttentionSpec):
assert isinstance(self.kv_cache_spec.tokens_per_state, int)
self.compress_ratio = self.kv_cache_spec.tokens_per_state
# NOTE(Chen):an estimated max size of flattened_kv. Need to double check.
# Counted in compressed rows, like the chunker's seq_lens and the
# workspace.
self.max_prefill_buffer_size = (
get_max_prefill_buffer_size(self.vllm_config) // self.compress_ratio
)
self.num_speculative_tokens = (
self.vllm_config.speculative_config.num_speculative_tokens
if self.vllm_config.speculative_config
else 0
)
self.indexer_uses_fp4 = dsa_indexer_uses_fp4(self.vllm_config)
next_n = self.num_speculative_tokens + 1
self.decode_threshold = next_n
self.reorder_batch_threshold = None
self.use_flattening = _use_flattening(self.vllm_config)
self.supports_varlen = _supports_varlen_paged_mqa_logits()
logger.info_once(
"DSA indexer decode path: use_flattening=%s supports_varlen=%s "
"(next_n=%d, use_fp4_cache=%s)",
self.use_flattening,
self.supports_varlen,
next_n,
self.indexer_uses_fp4,
)
sm_count = num_compute_units(self.device.index)
self.num_sms = sm_count
self.offsets_buffer = torch.arange(
next_n, device=self.device, dtype=torch.int32
)
self.decode_lens_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
self.per_req_decode_lens_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
# Shared workspace for decode seq_lens. Native MTP views this as
# (B, max_decode_len) at runtime, keeping context_lens contiguous even
# when max_decode_len is smaller than next_n.
self.decode_seq_lens_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
self.global_decode_seq_lens_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
self.decode_indices_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
self.arange_buffer = torch.arange(
max(
scheduler_config.max_num_seqs * next_n,
scheduler_config.max_num_batched_tokens,
),
dtype=torch.int32,
device=self.device,
)
# Materialize the rank on device during builder initialization. Creating
# this scalar in build() would introduce a GPU<->CPU sync in the decode
# hot path.
self.dcp_rank_tensor = torch.tensor(
self.dcp_rank, dtype=torch.int32, device=self.device
)
self.expanded_block_table_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens, block_table_width),
dtype=torch.int32,
device=self.device,
)
# See: DeepGMM/csrc/apis/attention.hpp. Sized for one slot per SM;
# build() narrows it to whatever the kernel actually schedules.
self.scheduler_metadata_buffer = torch.empty(
(self.num_sms + 1, 2), dtype=torch.int32, device=self.device
)
# Each compressed state must live on a single rank, so the DCP
# interleave has to cover whole states.
if (
self.dcp_world_size > 1
and self.compress_ratio > 1
and (
(
isinstance(self.kv_cache_spec, MLAAttentionSpec)
and self.kv_cache_spec.model_version == "deepseek_v4"
)
or self.cp_kv_cache_interleave_size % self.compress_ratio != 0
)
):
raise NotImplementedError(
"DCP with sparse indexer KV compression "
f"(compress_ratio={self.compress_ratio}) requires "
"--cp-kv-cache-interleave-size to be a multiple of it; "
"DeepSeek-V4 is not supported."
)
# DCP interleave in units of compressed states.
self.compressed_cp_interleave_size = (
self.cp_kv_cache_interleave_size // self.compress_ratio
if self.dcp_world_size > 1 and self.compress_ratio > 1
else self.cp_kv_cache_interleave_size
)
# Pre-allocate buffers for CUDA graph compatibility when
if self.compress_ratio > 1:
# compress_ratio > 1 (DeepseekV4)
# Compressed slot mapping output buffer
self.compressed_slot_mapping_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int64,
device=self.device,
)
# Buffer for compressed seq_lens in decode path
self.expanded_seq_lens_buffer = torch.zeros(
(scheduler_config.max_num_batched_tokens,),
dtype=torch.int32,
device=self.device,
)
# DCP not supported yet
self.supports_draft_decode_metadata_update = self.dcp_world_size == 1
def _dcp_localize_decode_seq_lens(
self,
seq_lens: torch.Tensor,
num_decodes: int,
seq_lens_is_buffer_view: bool,
) -> torch.Tensor:
local_seq_lens = get_dcp_local_seq_lens(
seq_lens,
self.dcp_world_size,
self.dcp_rank_tensor,
self.cp_kv_cache_interleave_size,
)
if seq_lens_is_buffer_view:
seq_lens.copy_(local_seq_lens)
return seq_lens
out = self.decode_seq_lens_buffer[:num_decodes]
out.copy_(local_seq_lens)
return out
def _prepare_decode_tensors(
self,
seq_lens: torch.Tensor,
block_table: torch.Tensor,
decode_lens: torch.Tensor,
decode_lens_cpu: torch.Tensor,
query_start_loc: torch.Tensor,
num_decodes: int,
num_decode_tokens: int,
use_native: bool,
next_n: int,
max_decode_len: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, bool]:
"""Prepare native or per-token flattened decode tensors."""
spec_config = self.vllm_config.speculative_config
adaptive = bool(spec_config and spec_config.enable_adaptive_verification)
min_decode_len = int(decode_lens_cpu.min().item())
if not use_native:
assert self.decode_seq_lens_buffer.dim() == 1
if (
not self.supports_varlen
and (num_decodes == 1 or not adaptive)
and min_decode_len == max_decode_len
and num_decodes * max_decode_len == num_decode_tokens
):
# Uniform decode lengths with no cudagraph token padding.
_PREPARE_UNIFORM_DECODE_KERNEL(
seq_lens,
self.decode_seq_lens_buffer,
block_table,
self.expanded_block_table_buffer,
self.decode_lens_buffer,
num_decode_tokens,
max_decode_len,
)
self.decode_seq_lens_buffer[num_decode_tokens:] = 0
seq_lens = self.decode_seq_lens_buffer[:num_decode_tokens]
block_table = self.expanded_block_table_buffer[:num_decode_tokens]
decode_lens = self.decode_lens_buffer[:num_decode_tokens]
return seq_lens, block_table, decode_lens, num_decode_tokens, False
else:
# Variable decode lengths.
# Assume 4 requests with seq_lens [10, 7, 12, 0] (the final req is
# padding) and decode_lens [3, 1, 4, 0] in the below example comments.
# The context lengths are therefore
# [10-3, 7-1, 12-4, 0-0] = [7, 6, 8, 0].
# 3 + 1 + 4 + 0 = 8
actual_expanded = int(decode_lens_cpu.sum().item())
# Fuse expanded_base and expanded_starts into a single
# repeat_interleave:
# seq_len_i = (context_start[b] - query_start_loc[b]) + arange[i] + 1
# where context_start[b] = seq_lens[b] - decode_lens[b].
# Example: offsets = [7-0, 6-3, 8-4, 0-8] = [7, 3, 4, -8]
# expanded_offsets = [7, 7, 7, 3, 4, 4, 4, 4]
# result = [8, 9, 10, 7, 9, 10, 11, 12]
expanded_offsets = torch.repeat_interleave(
seq_lens - decode_lens - query_start_loc,
decode_lens,
output_size=actual_expanded,
)
# [8, 9, 10, 7, 9, 10, 11, 12, ...] where ... is unused buffer space
self.decode_seq_lens_buffer[:actual_expanded] = (
expanded_offsets + self.arange_buffer[:actual_expanded] + 1
)
self.decode_seq_lens_buffer[actual_expanded:] = 0
seq_lens = self.decode_seq_lens_buffer[:num_decode_tokens]
# Give each of the flattened entries the same block table row as the
# original request.
self.expanded_block_table_buffer[:actual_expanded] = (
torch.repeat_interleave(
block_table, decode_lens, dim=0, output_size=actual_expanded
)
)
if actual_expanded < num_decode_tokens:
self.expanded_block_table_buffer[
actual_expanded:num_decode_tokens, 0
] = 0
block_table = self.expanded_block_table_buffer[:num_decode_tokens]
# All reqs now have decode_len=1
self.decode_lens_buffer[:num_decode_tokens] = 1
decode_lens = self.decode_lens_buffer[:num_decode_tokens]
return seq_lens, block_table, decode_lens, num_decode_tokens, False
else:
# Native path: plain decode (next_n==1) or spec decode
# with 2D per-token context lengths (next_n > 1).
#
# When decode_lens are not truly uniform (e.g. some requests have
# decode_len < next_n due to padding or short prefills), the simple
# reshape in sparse_attn_indexer won't work. Use pack_seq_triton
# (requires_padding) instead.
requires_padding = min_decode_len != max_decode_len
if use_native and next_n > 1:
assert self.decode_seq_lens_buffer.dim() == 1
# (B, max_decode_len): token j attends to
# L - max_decode_len + j + 1 KV tokens.
seq_lens_buffer = self.decode_seq_lens_buffer[
: num_decodes * max_decode_len
].view(num_decodes, max_decode_len)
# Clamp at 0: padding requests have seq_len == 0, which would
# otherwise make token 0 negative (next_n=2 gives 0-2+1+0 = -1).
# Downstream kernels read these as uint32, turning -1 into ~4e9.
seq_lens_buffer[:] = (
seq_lens.unsqueeze(1)
- max_decode_len
+ 1
+ self.offsets_buffer[:max_decode_len]
).clamp_(min=0)
seq_lens = seq_lens_buffer
return seq_lens, block_table, decode_lens, num_decodes, requires_padding
def _prepare_global_decode_seq_lens(
self,
global_seq_lens: torch.Tensor | None,
decode_lens: torch.Tensor,
decode_lens_cpu: torch.Tensor,
query_start_loc: torch.Tensor,
num_decode_tokens: int,
use_native: bool,
max_decode_len: int,
) -> torch.Tensor | None:
if global_seq_lens is None:
return None
if use_native or max_decode_len <= 1:
return global_seq_lens
actual_expanded = int(decode_lens_cpu.sum().item())
if actual_expanded > 0:
expanded_offsets = torch.repeat_interleave(
global_seq_lens - decode_lens - query_start_loc,
decode_lens,
output_size=actual_expanded,
)
self.global_decode_seq_lens_buffer[:actual_expanded] = (
expanded_offsets + self.arange_buffer[:actual_expanded] + 1
)
self.global_decode_seq_lens_buffer[actual_expanded:num_decode_tokens] = 0
return self.global_decode_seq_lens_buffer[:num_decode_tokens]
def _split_pcp_dcp_prefill_chunks(
self,
row_req_idx: np.ndarray,
row_shard_rows: np.ndarray,
row_query_lens_cpu: torch.Tensor,
max_logits_bytes: int,
request_offset: int,
) -> list[tuple[slice, slice]]:
"""Chunk by request rather than by row, so a split prefill's two rows
charge their shared context once, then widen each chunk back to rows.
``row_shard_rows`` holds the whole request's largest DCP shard on
every row, so the plan is identical on every PCP rank.
"""
row_bounds = request_row_bounds(row_req_idx)
first_rows = row_bounds[:-1]
# Each request's context, padded to whole DCP shards.
seq_lens = row_shard_rows[first_rows] * self.dcp_world_size
# Every rank holds a full-size chunk of a split request (shorter ones
# are replicated), so rows x longest row is the same on every rank,
# whichever row holds the short tail.
row_query_lens = row_query_lens_cpu.numpy()
query_lens = np.diff(row_bounds) * np.maximum.reduceat(
row_query_lens, first_rows
)
chunk_specs = self._split_indexer_prefill_chunks(
torch.from_numpy(seq_lens.astype(np.int32)),
torch.from_numpy(query_lens.astype(np.int32)),
self.max_prefill_buffer_size,
max_logits_bytes,
)
return [
(
slice(
request_offset + int(row_bounds[request_slice.start]),
request_offset + int(row_bounds[request_slice.stop]),
),
query_slice,
)
for request_slice, query_slice in chunk_specs
]
def _prefill_split_seq_lens(self, seq_lens_cpu: torch.Tensor) -> torch.Tensor:
"""Per-request KV lengths the prefill chunker budgets logits with;
subclasses whose logits rows are wider than the context override."""
return seq_lens_cpu
@staticmethod
def _split_indexer_prefill_chunks(
compressed_seq_lens_cpu: torch.Tensor,
prefill_query_lens_cpu: torch.Tensor,
workspace_size: int,
max_logits_bytes: int,
request_offset: int = 0,
) -> list[tuple[slice, slice]]:
"""Split this step's prefill requests into chunks, respecting:
- N constraint: total_seq_lens <= workspace_size (existing O(N)
workspace)
- Logits constraint: M * N * 4 <= max_logits_bytes
When a single request-level chunk still exceeds the logits budget,
sub-chunks on the query dimension (M) to bound peak memory.
Returns list of (req_slice, query_slice) tuples.
"""
chunks: list[tuple[slice, slice]] = []
n = len(compressed_seq_lens_cpu)
max_logits_elems = max_logits_bytes // 4
end = 0
while end < n:
start, chunk_m, chunk_n = end, 0, 0
while end < n:
q, s = (
prefill_query_lens_cpu[end].item(),
compressed_seq_lens_cpu[end].item(),
)
new_m, new_n = chunk_m + q, chunk_n + s
if new_n <= workspace_size and new_m * new_n <= max_logits_elems:
chunk_m, chunk_n = new_m, new_n
end += 1
else:
break
# A single request can exceed the budget, requiring sub-chunking
# on the query dimension.
if end == start:
chunk_m, chunk_n = (
prefill_query_lens_cpu[end].item(),
compressed_seq_lens_cpu[end].item(),
)
end += 1
req_slice = slice(start + request_offset, end + request_offset)
max_q = (
max(1, max_logits_elems // chunk_n) if chunk_n > 0 else max(1, chunk_m)
)
for q_off in range(0, chunk_m, max_q):
sub_m = min(max_q, chunk_m - q_off)
chunks.append((req_slice, slice(q_off, q_off + sub_m)))
return chunks
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
) -> DeepseekV32IndexerMetadata:
num_reqs = common_attn_metadata.num_reqs
num_tokens = common_attn_metadata.num_actual_tokens
query_start_loc = common_attn_metadata.query_start_loc
query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu
seq_lens = common_attn_metadata.seq_lens
slot_mapping = common_attn_metadata.slot_mapping
block_table = common_attn_metadata.block_table_tensor
dcp_local_seq_lens = common_attn_metadata.dcp_local_seq_lens
compressed_slot_mapping = slot_mapping
if self.compress_ratio > 1:
padded_num_tokens = num_tokens
local_slot_mapping = slot_mapping
if self.use_pcp:
# The gathered layout holds each rank's local tokens, padded, in
# rank order, so this rank's segment lines up with query_start_loc.
padded_num_tokens = slot_mapping.shape[0] // self.pcp_world_size
local_slot_mapping = slot_mapping[
self.pcp_rank * padded_num_tokens : (self.pcp_rank + 1)
* padded_num_tokens
]
compressed_slot_mapping = get_compressed_slot_mapping(
num_tokens,
local_slot_mapping,
query_start_loc,
seq_lens,
block_table,
self.kv_cache_spec.num_states,
self.compress_ratio,
out=self.compressed_slot_mapping_buffer,
dcp_world_size=self.dcp_world_size,
cp_interleave=self.cp_kv_cache_interleave_size,
)
if self.pcp_world_size > 1:
compressed_slot_mapping = get_pcp_group().all_gather(
self.compressed_slot_mapping_buffer[:padded_num_tokens],
dim=0,
)
# PCP decode sharding keeps a zero-token placeholder on ranks that own
# no request in a step so collectives retain a uniform rank shape. Do
# not turn that placeholder into a zero-length indexer decode request.
if num_tokens == 0:
return DeepseekV32IndexerMetadata(
seq_lens=seq_lens,
max_seq_len=common_attn_metadata.max_seq_len,
slot_mapping=compressed_slot_mapping,
num_decodes=0,
num_decode_tokens=0,
num_prefills=0,
num_prefill_tokens=0,
prefill=None,
decode=None,
)
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
split_decodes_and_prefills(
common_attn_metadata,
decode_threshold=self.decode_threshold,
require_uniform=not (self.use_flattening or self.supports_varlen),
treat_short_extends_as_decodes=not self.use_pcp,
)
)
assert num_decodes + num_prefills == num_reqs
assert num_decode_tokens + num_prefill_tokens == num_tokens
prefill_metadata = None
if num_prefills > 0:
compressed_seq_lens = (
seq_lens // self.compress_ratio if self.compress_ratio > 1 else seq_lens
)
# This CPU value is an upper bound for async-spec extend rows. It
# is safe for chunking/allocation because CUDA metadata below is
# built from exact device seq_lens and gather ignores the tail.
assert common_attn_metadata.seq_lens_cpu_upper_bound is not None
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
compressed_seq_lens_cpu = (
seq_lens_cpu // self.compress_ratio
if self.compress_ratio > 1
else seq_lens_cpu
)
prefill_query_lens_cpu = torch.diff(
query_start_loc_cpu[num_decodes : num_decodes + num_prefills + 1]
)
max_logits_bytes = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
# Upper bound is exact for prefill rows (the `[num_decodes:]`
# slice below).
assert common_attn_metadata.seq_lens_cpu_upper_bound is not None
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
req_idx = None
shard_rows = None
if self.use_pcp and self.dcp_world_size > 1:
# The gathered KV must be packed identically on every PCP rank:
# chunk by request from its DCP shard rows, which every rank
# holds. A dummy batch bypasses the PCP manager and has one
# row per request, so its own extent is the request's.
req_idx = common_attn_metadata.req_idx
if req_idx is None:
req_idx = np.arange(num_reqs)
shard_rows_cpu = common_attn_metadata.dcp_local_seq_lens_cpu_upper_bound
if shard_rows_cpu is None:
shard_rows_cpu = get_dcp_local_seq_lens(
seq_lens_cpu,
self.dcp_world_size,
0,
self.cp_kv_cache_interleave_size,
)
shard_rows = shard_rows_cpu.numpy()
chunk_specs = self._split_pcp_dcp_prefill_chunks(
req_idx[num_decodes:],
shard_rows[num_decodes:],
prefill_query_lens_cpu,
max_logits_bytes,
request_offset=num_decodes,
)
else:
chunk_specs = self._split_indexer_prefill_chunks(
self._prefill_split_seq_lens(compressed_seq_lens_cpu[num_decodes:]),
prefill_query_lens_cpu,
self.max_prefill_buffer_size,
max_logits_bytes,
request_offset=num_decodes,
)
chunks = []
for req_slice, query_slice in chunk_specs:
pcp_plan = None
if req_idx is not None:
assert shard_rows is not None
pcp_plan = build_pcp_global_chunk_plan(
req_idx[req_slice],
shard_rows[req_slice],
self.dcp_world_size,
self.device,
self.cp_kv_cache_interleave_size,
)
metadata = build_prefill_chunk_metadata(
req_slice.start,
req_slice.stop,
query_start_loc,
query_start_loc_cpu,
seq_lens,
compressed_seq_lens,
compressed_seq_lens_cpu,
block_table,
self.compress_ratio,
query_slice=query_slice,
skip_kv_gather=query_slice.start > 0,
dcp_rank=self.dcp_rank,
dcp_world_size=self.dcp_world_size,
cp_kv_cache_interleave_size=self.compressed_cp_interleave_size,
pcp_plan=pcp_plan,
)
# Skip when total_seq_lens is 0 (i.e., no compressed token).
if metadata is not None:
chunks.append(metadata)
row_shard_sizes = None
if tp_prefill_row_sharding_supported(
self.vllm_config, self.dcp_world_size, self.use_pcp, self.tp_size
):
row_shard_sizes = balanced_prefill_row_shard(
seq_lens_cpu[num_decodes:],
prefill_query_lens_cpu,
self.compress_ratio,
self.tp_size,
)
prefill_metadata = DeepseekV32IndexerPrefillMetadata(
chunks,
max_prefill_seq_len=(
int(seq_lens_cpu[num_decodes:].max().item())
if num_prefills > 0
else 0
),
row_shard_sizes=row_shard_sizes,
)
decode_metadata = None
if num_decodes > 0:
if not self.supports_varlen:
torch.diff(
common_attn_metadata.query_start_loc[: num_decodes + 1],
out=self.decode_lens_buffer[:num_decodes],
)
self.per_req_decode_lens_buffer[:num_decodes].copy_(
self.decode_lens_buffer[:num_decodes]
)
decode_lens = self.decode_lens_buffer[:num_decodes]
decode_lens_cpu = torch.diff(
common_attn_metadata.query_start_loc_cpu[: num_decodes + 1]
)
# Under DCP the per-token decode bounds must be localized AFTER the
# per-token expansion below, not before. Expanding from a
# request-level localized length subtracts decode offsets in local
# space and yields too-short bounds (e.g. world=2, rank=1, global
# per-token bounds [8, 9, 10] -> [3, 4, 5] instead of [4, 4, 5]), so
# the first decode token would run top-k against too short a local KV
# range and miss valid tokens. Keep the global seq_lens here and
# localize the expanded bounds further down.
global_seq_lens_for_decode: torch.Tensor | None = None
if dcp_local_seq_lens is not None:
global_seq_lens_for_decode = common_attn_metadata.seq_lens[:num_decodes]
seq_lens = common_attn_metadata.seq_lens[:num_decodes]
block_table = common_attn_metadata.block_table_tensor[:num_decodes, ...]
max_decode_len = int(decode_lens_cpu.max().item())
min_decode_len = int(decode_lens_cpu.min().item())
write_is_uniform = min_decode_len == max_decode_len
next_n = 1 + self.num_speculative_tokens
# The kernel sees max_decode_len Q rows, not the configured next_n,
# so legality is per-step: on SM90 a uniformly 3-deep batch has no
# native kernel. max_decode_len <= 1 always has one.
step_next_n_ok = max_decode_len <= 1 or _supports_native_decode(
max_decode_len
)
use_native = (
not (self.use_flattening or self.supports_varlen)
and max_decode_len <= next_n
and step_next_n_ok
)
if not self.supports_varlen:
global_seq_lens_for_decode = self._prepare_global_decode_seq_lens(
global_seq_lens=global_seq_lens_for_decode,
decode_lens=decode_lens,
decode_lens_cpu=decode_lens_cpu,
query_start_loc=common_attn_metadata.query_start_loc[:num_decodes],
num_decode_tokens=num_decode_tokens,
use_native=use_native,
max_decode_len=max_decode_len,
)
decode_indices = None
if self.supports_varlen:
from vllm.v1.attention.ops.metadata import (
_indexer_decode_metadata_kernel,
)
capacity = self.decode_seq_lens_buffer.numel()
grid = max(
num_decodes,
num_decode_tokens + triton.cdiv(capacity - num_decode_tokens, 256),
)
_indexer_decode_metadata_kernel[(grid,)](
query_start_loc,
seq_lens,
block_table,
self.decode_seq_lens_buffer,
self.expanded_block_table_buffer,
self.decode_lens_buffer,
self.decode_indices_buffer,
self.per_req_decode_lens_buffer,
num_decodes,
num_decode_tokens,
capacity,
block_table.stride(0),
self.expanded_block_table_buffer.stride(0),
BLOCK_COLS=block_table.shape[1],
num_warps=4,
)
seq_lens = self.decode_seq_lens_buffer[:num_decode_tokens]
block_table = self.expanded_block_table_buffer[:num_decode_tokens]
decode_lens = self.decode_lens_buffer[:num_decode_tokens]
decode_indices = self.decode_indices_buffer[:num_decode_tokens]
requires_padding = False
if global_seq_lens_for_decode is not None and max_decode_len > 1:
self.global_decode_seq_lens_buffer[:num_decode_tokens].copy_(
seq_lens
)
global_seq_lens_for_decode = self.global_decode_seq_lens_buffer[
:num_decode_tokens
]
else:
seq_lens, block_table, decode_lens, batch_size, requires_padding = (
self._prepare_decode_tensors(
seq_lens=seq_lens,
block_table=block_table,
decode_lens=decode_lens,
decode_lens_cpu=decode_lens_cpu,
query_start_loc=common_attn_metadata.query_start_loc[
:num_decodes
],
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
use_native=use_native,
next_n=next_n,
max_decode_len=max_decode_len,
)
)
# Flattening always returns a buffer view, including single-token
# batches. Keep its address stable across varlen graph replays.
seq_lens_is_buffer_view = not use_native or next_n > 1
# DCP: localize the now-expanded per-token global bounds to this
# rank's owned KV. Done here (after expansion) so each token's global
# causal length is localized individually; see the comment above.
if dcp_local_seq_lens is not None:
seq_lens = self._dcp_localize_decode_seq_lens(
seq_lens, num_decodes, seq_lens_is_buffer_view
)
# For DeepseekV4 (compress_ratio > 1), the indexer KV cache stores
# compressed tokens. Convert uncompressed seq_lens to compressed.
if self.compress_ratio > 1:
if seq_lens_is_buffer_view:
seq_lens //= self.compress_ratio
else:
# Copy to avoid mutating shared state; keeps CG address stable.
self.expanded_seq_lens_buffer[:num_decodes] = (
seq_lens // self.compress_ratio
)
self.expanded_seq_lens_buffer[num_decodes:num_decode_tokens] = 0
seq_lens = self.expanded_seq_lens_buffer[:num_decode_tokens]
# Non-MTP: deep_gemm paged MQA logits requires 2D context_lens
# (csrc/apis/attention.hpp). Unsqueeze to (B, 1) so downstream
# kernels see the same (B, next_n) layout as the MTP path.
if seq_lens.dim() == 1:
seq_lens = seq_lens.unsqueeze(-1)
# DeepGEMM is required for the paged MQA logits on CUDA devices
schedule_metadata = self.scheduler_metadata_buffer
if current_platform.is_cuda() and has_deep_gemm():
metadata = get_paged_mqa_logits_metadata(
seq_lens,
self.kv_cache_spec.num_states,
self.num_sms,
indices=decode_indices,
)
schedule_metadata = self.scheduler_metadata_buffer[: metadata.shape[0]]
schedule_metadata[:] = metadata
decode_metadata = DeepSeekV32IndexerDecodeMetadata(
block_table=block_table,
seq_lens=seq_lens,
decode_lens=decode_lens,
requires_padding=requires_padding,
schedule_metadata=schedule_metadata,
indices=decode_indices,
global_seq_lens=global_seq_lens_for_decode,
per_req_decode_lens=self.per_req_decode_lens_buffer[:num_decodes],
decode_is_uniform=write_is_uniform,
write_max_decode_len=max_decode_len,
)
attn_metadata = DeepseekV32IndexerMetadata(
seq_lens=common_attn_metadata.seq_lens,
max_seq_len=common_attn_metadata.max_seq_len,
slot_mapping=compressed_slot_mapping,
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
num_prefills=num_prefills,
num_prefill_tokens=num_prefill_tokens,
prefill=prefill_metadata,
decode=decode_metadata,
block_table=common_attn_metadata.block_table_tensor,
)
return attn_metadata
def update_draft_decode_metadata(
self,
metadata: DeepseekV32IndexerMetadata,
) -> None:
decode = metadata.decode
if decode is None or metadata.num_decode_tokens == 0:
return
assert metadata.num_prefills == 0
assert metadata.num_decodes == metadata.num_decode_tokens
assert decode.seq_lens.numel() == metadata.num_decode_tokens
assert metadata.block_table is not None
assert self.dcp_world_size == 1
# One query token per request, so the per-token decode block table is
# the request block table (at the indexer kernel's page size). build()
# may copy it into a builder buffer, so re-derive it for this batch.
decode.block_table.copy_(metadata.block_table[: metadata.num_decode_tokens])
if decode.indices is not None:
decode.indices.copy_(self.arange_buffer[: metadata.num_decode_tokens])
if decode.per_req_decode_lens is not None:
decode.per_req_decode_lens.fill_(1)
if self.compress_ratio > 1:
get_compressed_slot_mapping(
metadata.num_decode_tokens,
self.arange_buffer[: metadata.num_decode_tokens],
self.arange_buffer[: metadata.num_decode_tokens + 1],
metadata.seq_lens,
decode.block_table,
self.kv_cache_spec.num_states,
self.compress_ratio,
out=metadata.slot_mapping,
)
torch.div(
metadata.seq_lens,
self.compress_ratio,
rounding_mode="floor",
out=decode.seq_lens.view(-1),
)
else:
decode.seq_lens.view(-1).copy_(metadata.seq_lens)
decode.decode_lens.fill_(1)
if current_platform.is_cuda() and has_deep_gemm():
schedule_metadata = get_paged_mqa_logits_metadata(
decode.seq_lens,
self.kv_cache_spec.num_states,
self.num_sms,
indices=decode.indices,
)
assert schedule_metadata.shape == decode.schedule_metadata.shape
decode.schedule_metadata.copy_(schedule_metadata)
def build_prefill_chunk_metadata(
start_idx: int,
end_idx: int,
query_start_loc: torch.Tensor,
query_start_loc_cpu: torch.Tensor,
uncompressed_seq_lens: torch.Tensor,
compressed_seq_lens: torch.Tensor,
compressed_seq_lens_cpu: torch.Tensor,
block_table: torch.Tensor,
compress_ratio: int,
query_slice: slice | None = None,
skip_kv_gather: bool = False,
dcp_rank: int = 0,
dcp_world_size: int = 1,
cp_kv_cache_interleave_size: int = 1,
pcp_plan: PCPGlobalChunkPlan | None = None,
) -> DeepseekV32IndexerPrefillChunkMetadata | None:
if pcp_plan is not None:
total_seq_lens = pcp_plan.total
else:
total_seq_lens = compressed_seq_lens_cpu[start_idx:end_idx].sum().item()
if total_seq_lens == 0:
return None
num_reqs = end_idx - start_idx
device = block_table.device
token_to_seq = torch.empty(total_seq_lens, dtype=torch.int32, device=device)
if pcp_plan is not None:
cu_seq_lens = pcp_plan.global_cu
else:
cu_seq_lens = torch.empty(num_reqs + 1, dtype=torch.int32, device=device)
# Assigning to slice avoids cpu sync.
cu_seq_lens[:1] = 0
torch.cumsum(compressed_seq_lens[start_idx:end_idx], dim=0, out=cu_seq_lens[1:])
local_cu_seq_lens = cu_seq_lens
local_total_seq_lens = total_seq_lens
max_local_total_seq_lens = total_seq_lens
if pcp_plan is not None:
local_cu_seq_lens = pcp_plan.padded_local_cu
local_total_seq_lens = pcp_plan.padded_local_total
max_local_total_seq_lens = pcp_plan.padded_local_total
elif dcp_world_size > 1:
# Per-rank local KV length under interleave-aware DCP sharding, shape
# [num_reqs, dcp_world_size]. Reuse the canonical CP helper so the
# sharding matches the rest of the DCP pipeline (decode/prefill).
local_seq_lens = get_dcp_local_seq_lens(
compressed_seq_lens[start_idx:end_idx],
dcp_world_size,
None,
cp_kv_cache_interleave_size,
)
this_rank_counts = local_seq_lens[:, dcp_rank].to(torch.int32)
local_cu_seq_lens = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device)
torch.cumsum(this_rank_counts, dim=0, out=local_cu_seq_lens[1:])
local_total_seq_lens = int(local_cu_seq_lens[-1].item())
max_local_total_seq_lens = int(local_seq_lens.sum(dim=0).max().item())
query_start_loc = (
query_start_loc[start_idx : end_idx + 1] - query_start_loc[start_idx]
)
total_query_len = int(
(query_start_loc_cpu[end_idx] - query_start_loc_cpu[start_idx]).item()
)
if query_slice is not None:
qs_start = min(query_slice.start, total_query_len)
qs_stop = min(query_slice.stop, total_query_len)
else:
qs_start = 0
qs_stop = total_query_len
output_query_len = qs_stop - qs_start
assert 0 <= output_query_len <= total_query_len - qs_start, (
f"query slice [{qs_start}, {qs_stop}) does not fit this rank's "
f"{total_query_len} tokens; the metadata tail would stay uninitialized"
)
cu_seq_len_ks = torch.empty(output_query_len, dtype=torch.int32, device=device)
cu_seq_len_ke = torch.empty(output_query_len, dtype=torch.int32, device=device)
if pcp_plan is not None:
row_start_cu = pcp_plan.row_start_cu
kernel_dcp_rank, kernel_dcp_world = 0, 1
else:
# Under DCP the kernel writes this rank's local row bounds into
# cu_seq_len_ks/ke; otherwise local_cu_seq_lens aliases cu_seq_lens.
row_start_cu = local_cu_seq_lens
kernel_dcp_rank, kernel_dcp_world = dcp_rank, dcp_world_size
_BUILD_PREFILL_CHUNK_METADATA_KERNEL(
query_start_loc,
uncompressed_seq_lens[start_idx:end_idx],
cu_seq_lens,
row_start_cu,
token_to_seq,
cu_seq_len_ks,
cu_seq_len_ke,
qs_start,
qs_stop,
kernel_dcp_rank,
kernel_dcp_world,
cp_kv_cache_interleave_size,
num_reqs=num_reqs,
COMPRESS_RATIO=compress_ratio,
)
pcp_deinterleave_idx = pcp_plan.deinterleave_idx if pcp_plan is not None else None
token_start = query_start_loc_cpu[start_idx].item()
if query_slice is not None:
token_end = token_start + qs_stop
token_start = token_start + qs_start
skip_kv_gather = skip_kv_gather or qs_start > 0
else:
token_end = query_start_loc_cpu[end_idx].item()
return DeepseekV32IndexerPrefillChunkMetadata(
pcp_deinterleave_idx=pcp_deinterleave_idx,
cu_seqlen_ks=cu_seq_len_ks,
cu_seqlen_ke=cu_seq_len_ke,
cu_seq_lens=cu_seq_lens,
token_to_seq=token_to_seq,
total_seq_lens=total_seq_lens,
max_seqlen_k=int(compressed_seq_lens_cpu[start_idx:end_idx].max()),
block_table=block_table[start_idx:end_idx],
token_start=token_start,
token_end=token_end,
num_reqs=num_reqs,
skip_kv_gather=skip_kv_gather,
local_cu_seq_lens=local_cu_seq_lens,
local_total_seq_lens=local_total_seq_lens,
max_local_total_seq_lens=max_local_total_seq_lens,
)
_PREPARE_UNIFORM_DECODE_KERNEL = PrepareUniformDecodeKernel()
_BUILD_PREFILL_CHUNK_METADATA_KERNEL = BuildPrefillChunkMetadataKernel()