[Spec Decode] Remove eager metadata rebuild during MTP fused multi-step decode (#58463)

Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
This commit is contained in:
Giancarlo Delfin
2026-10-06 12:17:30 -04:00
committed by GitHub
parent 4ea0c28bc4
commit fcf53d240c
8 changed files with 182 additions and 50 deletions
@@ -122,6 +122,7 @@ def test_indexer_draft_decode_metadata_update_is_capture_safe():
builder = object.__new__(DeepseekV32IndexerMetadataBuilder)
builder.dcp_world_size = 1
builder.compress_ratio = 16
builder.kernel_block_size = None
builder.num_sms = num_sms
builder.arange_buffer = torch.arange(num_reqs + 1, dtype=torch.int32, device=device)
builder.kv_cache_spec = SimpleNamespace(num_states=64)
@@ -140,6 +141,7 @@ def test_indexer_draft_decode_metadata_update_is_capture_safe():
requires_padding=False,
schedule_metadata=schedule_metadata,
),
block_table=block_table,
)
builder.update_draft_decode_metadata(metadata)
@@ -167,6 +169,65 @@ def test_indexer_draft_decode_metadata_update_is_capture_safe():
torch.testing.assert_close(metadata.slot_mapping, expected_slot_mapping)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
@pytest.mark.parametrize(
"block_size,kernel_block_size,compress_ratio", [(64, 64, 1), (256, 64, 4)]
)
def test_indexer_draft_decode_refresh_follows_new_batch(
monkeypatch, block_size, kernel_block_size, compress_ratio
):
"""Without a prior build(), the refresh must not keep the last batch's state."""
monkeypatch.setattr(indexer_module, "has_deep_gemm", lambda: False)
device = torch.device("cuda")
num_reqs, width = 3, 8
factor = block_size // kernel_block_size
builder = object.__new__(DeepseekV32IndexerMetadataBuilder)
builder.dcp_world_size = 1
builder.compress_ratio = compress_ratio
builder.kernel_block_size = kernel_block_size
builder.arange_buffer = torch.arange(num_reqs + 1, dtype=torch.int32, device=device)
builder.kv_cache_spec = SimpleNamespace(block_size=block_size, num_states=64)
def stale(*shape: int) -> torch.Tensor:
return torch.full(shape, -7, dtype=torch.int32, device=device)
decode = DeepSeekV32IndexerDecodeMetadata(
block_table=stale(num_reqs, width // factor),
seq_lens=stale(num_reqs, 1),
decode_lens=stale(num_reqs),
requires_padding=False,
schedule_metadata=stale(1),
indices=stale(num_reqs),
per_req_decode_lens=stale(num_reqs),
)
request_block_table = torch.randint(
0, 1000, (num_reqs, width), dtype=torch.int32, device=device
)
seq_lens = torch.tensor([17, 33, 65], dtype=torch.int32, device=device)
metadata = DeepseekV32IndexerMetadata(
seq_lens=seq_lens,
max_seq_len=128,
slot_mapping=torch.zeros(num_reqs, dtype=torch.int64, device=device),
num_decodes=num_reqs,
num_decode_tokens=num_reqs,
num_prefills=0,
num_prefill_tokens=0,
decode=decode,
block_table=request_block_table,
)
builder.update_draft_decode_metadata(metadata)
expected_block_table = request_block_table[:, ::factor] // factor
torch.testing.assert_close(decode.block_table, expected_block_table)
torch.testing.assert_close(decode.indices, builder.arange_buffer[:num_reqs])
assert torch.all(decode.per_req_decode_lens == 1)
assert torch.all(decode.decode_lens == 1)
expected_seq_lens = torch.div(seq_lens, compress_ratio, rounding_mode="floor")
torch.testing.assert_close(decode.seq_lens.flatten(), expected_seq_lens)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
@pytest.mark.parametrize(
"query_lens", [[1], [0, 257, 1, 0, 3], [6] * 64, [2, 6, 4, 0], [0, 0, 0]]
+13 -16
View File
@@ -506,19 +506,13 @@ class CommonAttentionMetadata:
# but not the right per-request boundaries. Padding requests have a query
# length of zero and are skipped by the device boundary search.
num_mapped_tokens = int(self.query_start_loc_cpu[-1])
from vllm.v1.attention.ops.metadata import _token_request_mapping_kernel
from vllm.v1.attention.ops.metadata import compute_token_to_req_indices
num_output_tokens = max(num_mapped_tokens, num_tokens)
assert buffer.shape[0] >= num_output_tokens
_token_request_mapping_kernel[((num_output_tokens + 255) // 256,)](
self.query_start_loc,
buffer,
self.query_start_loc.shape[0] - 1,
num_mapped_tokens,
num_output_tokens,
num_warps=4,
self._token_to_req_indices_cache = compute_token_to_req_indices(
self.query_start_loc, buffer, num_mapped_tokens, num_output_tokens
)
self._token_to_req_indices_cache = buffer[:num_output_tokens]
return self._token_to_req_indices_cache[:num_tokens]
# TODO(lucas): remove once we have FULL-CG spec-decode support
@@ -609,8 +603,11 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
supports_update_block_table: bool = False
# Whether the builder constructor requires the block-table width.
requires_block_table_width: ClassVar[bool] = False
# Whether all step-dependent draft decode metadata can be updated in place,
# allowing one metadata build to be reused across autoregressive draft steps.
# Whether update_draft_decode_metadata() can regenerate all decode metadata
# from persistent device buffers with capture-safe ops only. Lets a
# speculator record the refresh inside its draft decode CUDA graph, so no
# eager build() is needed between steps or between the batches that replay
# it, whether the graph holds one draft forward or a multi-step loop.
supports_draft_decode_metadata_update: bool = False
@abstractmethod
@@ -758,12 +755,12 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
)
def update_draft_decode_metadata(self, metadata: M) -> None:
"""Update step-dependent draft decode metadata in place.
"""Update draft decode metadata in place.
The fused draft loop may call this method during full CUDA graph
capture. CUDA graph replay does not run this Python method, so
implementations must emit capture-safe operations and keep replayed
tensor state in persistent storage.
Speculators can record this call inside their draft decode CUDA graphs,
so it runs once at capture and never again in Python. Implementations
must emit only capture-safe operations and write results into persistent
tensors that ``metadata`` already references.
"""
raise NotImplementedError
@@ -27,6 +27,7 @@ from vllm.v1.attention.backends.mla.sparse_utils import (
flat_kv_row_view,
triton_convert_req_index_to_global_index,
)
from vllm.v1.attention.ops.metadata import compute_token_to_req_indices
from vllm.v1.kv_cache_interface import AttentionSpec
from vllm.vllm_flash_attn.flash_attn_interface import flash_attn_varlen_func
@@ -132,8 +133,18 @@ class FlashAttnMLASparseMetadataBuilder(
self._init_reorder_batch_threshold(threshold, supports_spec_as_decode=True)
self.supports_draft_decode_metadata_update = self.dcp_world_size == 1
def update_draft_decode_metadata(self, _metadata: SparseMLACommonMetadata) -> None:
pass
def update_draft_decode_metadata(self, metadata: SparseMLACommonMetadata) -> None:
num_tokens = metadata.num_decode_tokens
if num_tokens == 0:
return
# Everything else is a view of runner buffers; the per-token request
# map is a builder buffer that build() filled for the captured batch.
compute_token_to_req_indices(
metadata.query_start_loc,
metadata.req_id_per_token,
num_tokens,
num_tokens,
)
class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
+22
View File
@@ -678,6 +678,7 @@ class DeepseekV32IndexerMetadata:
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"])
@@ -1713,6 +1714,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
num_prefill_tokens=num_prefill_tokens,
prefill=prefill_metadata,
decode=decode_metadata,
block_table=common_attn_metadata.block_table_tensor,
)
return attn_metadata
@@ -1728,8 +1730,28 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
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.
block_table = metadata.block_table[: metadata.num_decode_tokens]
kernel_block_size = self.kernel_block_size
if (
self.compress_ratio > 1
and kernel_block_size is not None
and self.kv_cache_spec.block_size != kernel_block_size
and self.kv_cache_spec.block_size % kernel_block_size == 0
):
factor = self.kv_cache_spec.block_size // kernel_block_size
block_table = block_table[:, ::factor] // factor
decode.block_table.copy_(block_table)
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,
+14 -1
View File
@@ -33,6 +33,7 @@ from vllm.v1.attention.backends.mla.compressor_utils import (
)
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
from vllm.v1.attention.ops.flashmla import FlashMLASchedMeta, get_mla_metadata
from vllm.v1.attention.ops.metadata import compute_token_to_req_indices
from vllm.v1.kv_cache_interface import (
KVCacheSpec,
MLAAttentionSpec,
@@ -829,7 +830,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
self,
metadata: DeepseekSparseSWAMetadata,
) -> None:
if metadata.num_decode_tokens == 0:
num_tokens = metadata.num_decode_tokens
if num_tokens == 0:
return
assert metadata.query_start_loc is not None
assert metadata.seq_lens is not None
@@ -839,6 +841,17 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
assert metadata.decode_swa_lens is not None
assert metadata.replay_start is not None
# Recompute the validity mask and token->request map from the device
# buffers. Their padding differs from the dummy batch the graph was
# captured with.
torch.ge(metadata.slot_mapping, 0, out=metadata.is_valid_token)
compute_token_to_req_indices(
metadata.query_start_loc,
metadata.token_to_req_indices,
num_tokens,
num_tokens,
)
_COMPUTE_SWA_INDICES_AND_LENS_KERNEL(
metadata.decode_swa_indices,
metadata.decode_swa_lens,
+24
View File
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Device-side request mapping and sparse indexer metadata."""
import torch
from vllm.triton_utils import tl, triton
@@ -27,6 +29,28 @@ def _token_request_mapping_kernel(
tl.store(output + token, tl.where(token < num_mapped, req, 0), token < num_tokens)
def compute_token_to_req_indices(
query_start_loc: torch.Tensor,
out: torch.Tensor,
num_mapped_tokens: int,
num_tokens: int,
) -> torch.Tensor:
"""Map each of the first ``num_tokens`` tokens to its request index.
Reads only the device ``query_start_loc``, so it is safe to record in a
CUDA graph. Tokens at or past ``num_mapped_tokens`` are mapped to 0.
"""
_token_request_mapping_kernel[((num_tokens + 255) // 256,)](
query_start_loc,
out,
query_start_loc.shape[0] - 1,
num_mapped_tokens,
num_tokens,
num_warps=4,
)
return out[:num_tokens]
@triton.jit(
do_not_specialize=[
"num_reqs",
@@ -99,10 +99,6 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
self.use_fused_multi_step_decode = False
return
if not self.advance_draft_positions:
self.use_fused_multi_step_decode = True
return
unsupported_backends = sorted(
{
attn_group.backend.get_name()
@@ -574,6 +570,11 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
query_start_loc = self.input_buffers.query_start_loc[: num_reqs + 1]
idx_mapping = self.idx_mapping[:num_reqs]
if batch_desc.cg_mode == CUDAGraphMode.FULL:
assert self.decode_cudagraph_manager is not None
self.decode_cudagraph_manager.run_fullgraph(batch_desc)
return
attn_metadata = None
slot_mappings_by_layer = None
if not skip_attn:
@@ -583,10 +584,9 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
positions,
batch_desc.num_tokens,
)
if batch_desc.cg_mode != CUDAGraphMode.FULL:
slot_mappings_by_layer = build_slot_mappings_by_layer(
slot_mappings, self.kv_cache_config
)
slot_mappings_by_layer = build_slot_mappings_by_layer(
slot_mappings, self.kv_cache_config
)
attn_metadata = self._build_uniform_attn_metadata(
num_reqs=num_reqs,
batch_desc=batch_desc,
@@ -595,11 +595,6 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
step=1,
)
if batch_desc.cg_mode == CUDAGraphMode.FULL:
assert self.decode_cudagraph_manager is not None
self.decode_cudagraph_manager.run_fullgraph(batch_desc)
return
self._generate_fused_drafts(
num_reqs,
batch_desc.num_tokens,
@@ -623,13 +618,19 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
idx_mapping = self.idx_mapping[:num_reqs]
positions = self.input_buffers.positions[:num_reqs]
query_start_loc = self.input_buffers.query_start_loc[: num_reqs + 1]
attn_groups = (
[group for groups in self.attn_groups for group in groups]
if attn_metadata is not None
else []
)
for step in range(1, num_speculative_steps):
if attn_metadata is not None and (
self.advance_draft_positions or step == 1
):
self.block_tables.compute_slot_mappings(
idx_mapping,
query_start_loc,
positions,
num_tokens_padded,
)
self._update_draft_decode_metadata(attn_metadata, num_reqs)
self.current_draft_step.fill_(step)
self._generate_draft(
num_reqs,
@@ -640,19 +641,6 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
cudagraph_runtime_mode,
num_speculative_steps,
)
if (
step < num_speculative_steps - 1
and attn_metadata is not None
and self.advance_draft_positions
):
self.block_tables.compute_slot_mappings(
idx_mapping,
query_start_loc,
positions,
num_tokens_padded,
)
for attn_group in attn_groups:
attn_group.update_draft_decode_metadata(attn_metadata)
def _generate_draft(
self,
@@ -562,3 +562,19 @@ class DraftModelSpeculator(BaseSpeculator):
causal=causal,
dcp_local_seq_lens=dcp_local_seq_lens,
)
def _update_draft_decode_metadata(
self, attn_metadata: dict[str, Any], num_reqs: int
) -> None:
if self.block_tables.cp_size > 1:
prepare_dcp_local_seq_lens(
self.input_buffers.dcp_local_seq_lens,
self.input_buffers.seq_lens,
num_reqs,
self.block_tables.cp_size,
self.block_tables.cp_rank,
self.block_tables.cp_interleave,
)
for groups in self.attn_groups:
for group in groups:
group.update_draft_decode_metadata(attn_metadata)