Files
vLLM/vllm/v1/attention/backends/ple_recoverssm.py
T
Jürgen SchmiedandClaude 03cae7837f [Spec Decode][Qwen4Exp] RecoverSSM for Qwen GDN and the PLE short conv
With --use-replayssm and speculative decoding, Qwen4Exp's Gated DeltaNet layers verify the
draft window from a read-only checkpoint and keep a per-token replay record
[correction(V), normalized key(K), gate(1)] in fp32. After sampling, one commit replays the
accepted tokens and writes the recurrent state once (plus the block-boundary state in align
mode), instead of snapshotting the state after every draft position. The PLE dilated short
conv follows the same protocol (accepted-suffix compaction). num_speculative_blocks = 0,
so no per-draft Mamba blocks are reserved.

- GDN verify/commit kernels: model_executor/layers/mamba/gdn/recoverssm_gdn.py
- metadata/backends: v1/attention/backends/gdn_recoverssm.py, ple_recoverssm.py
- the verify reads strided q/k/v views of the conv output and writes in place
- builders declare AttentionCGSupport.NEVER: FULL_AND_PIECEWISE falls back to PIECEWISE;
  the fallback now also counts breakable CUDA graphs (empty splitting_ops, with or
  without torch.compile) as piecewise
  attention instead of dropping to NONE
- the generic Mamba metadata builder skips the Mamba2 ReplaySSM ring-buffer workspaces
  when --use-replayssm selects RecoverSSM (the PLE short conv uses that builder)
- tests: GDN verify/commit vs the native kernels (fp32 and bf16 state, align boundaries,
  mixed and ragged batches, extreme gates), PLE vs token-by-token decode, config selection

Signed-off-by: Jürgen Schmied <juergenschmied70@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-27 01:17:24 +02:00

327 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""RecoverSSM protocol for the Qwen4Exp PLE short-conv layer.
When the GDN layers run RecoverSSM, the runner resets the shared num_accepted_tokens to
1 after each commit (align mode). The PLE dilated short conv keeps an extended window in
one state block and reads it at num_accepted - 1, so it must follow the same protocol:
every active decode row takes the spec path, the conv reads at offset 0, and after
sampling the accepted window is compacted to the front (the shared RecoverSSM compaction
kernel; in align mode it also writes the block-boundary window)."""
from dataclasses import dataclass, field, fields
from typing import Any
import torch
from vllm.logger import init_logger
from vllm.model_executor.layers.mamba.ops.recoverssm import (
compact_conv_state_kernel,
prepare_commit_plan_kernel,
)
from vllm.model_executor.layers.mamba.recoverssm_utils import recoverssm_require
from vllm.triton_utils import triton
from vllm.v1.attention.backend import AttentionCGSupport
from vllm.v1.attention.backends.recoverssm_metadata import (
RecoverSSMMetadata,
RecoverSSMPostprocessMetadata,
recoverssm_request_indices,
recoverssm_spec_rows,
)
from vllm.v1.attention.backends.short_conv_attn import (
PleShortConvAttentionBackend,
PleShortConvAttentionMetadata,
PleShortConvAttentionMetadataBuilder,
)
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
logger = init_logger(__name__)
def _require(cond: bool, msg: str) -> None:
recoverssm_require(cond, msg, "PLE RecoverSSM")
class _PleConvCommit:
def __init__(self, conv_states, spec_query_len: int, max_num_reqs: int):
from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
_require(len(conv_states) > 0, "PLE commit requires at least one layer")
if not is_conv_state_dim_first():
conv_states = [
s.transpose(-1, -2) for s in conv_states
] # -> [blocks, C, W]
self.conv_states = list(conv_states)
ref = self.conv_states[0]
_require(ref.ndim == 3, "PLE conv state must be [blocks, dim, window]")
dev = ref.device
for s in self.conv_states:
_require(
s.shape == ref.shape
and s.dtype == ref.dtype
and s.stride()[1:] == ref.stride()[1:]
and s.device == dev,
"PLE layers need matching conv states",
)
self.conv_dim, conv_len = ref.shape[1], ref.shape[2]
self.hist = conv_len - spec_query_len + 1
_require(self.hist > 0, "PLE conv state is shorter than its window")
self.spec_query_len = spec_query_len
t = lambda xs: torch.tensor(xs, dtype=torch.int64, device=dev)
self.base = t([s.data_ptr() for s in self.conv_states])
self.bstride = t([s.stride(0) for s in self.conv_states])
self.dstride = t([s.stride(1) for s in self.conv_states])
self.tstride = t([s.stride(2) for s in self.conv_states])
z = lambda: torch.empty(max_num_reqs, dtype=torch.int32, device=dev)
self.commit_lens, self.final_idx, self.bnd_idx, self.bnd_len = (
z(),
z(),
z(),
z(),
)
def commit(
self,
num_accepted_tokens,
state_indices,
query_start_loc,
request_indices=None,
block_table=None,
num_computed_tokens=None,
mamba_block_size=None,
):
batch = state_indices.shape[0]
if batch == 0:
return
_require(state_indices.ndim == 1, "state indices must be one-dimensional")
_require(
batch <= self.commit_lens.shape[0],
"PLE commit batch exceeds its plan capacity",
)
_require(
query_start_loc.ndim == 1 and query_start_loc.shape[0] == batch + 1,
"PLE commit metadata is incompatible",
)
_require(
request_indices is None or request_indices.shape[0] >= batch,
"PLE request mapping is too short",
)
_require(
num_accepted_tokens.ndim == 1
and (request_indices is not None or num_accepted_tokens.shape[0] >= batch),
"PLE accepted-token counts are too short",
)
_align = (block_table, num_computed_tokens, mamba_block_size)
_require(
all(x is None for x in _align) or all(x is not None for x in _align),
"PLE align metadata is incomplete",
)
_require(
mamba_block_size is None or mamba_block_size >= self.spec_query_len,
"PLE align block size must cover one speculative window",
)
_require(
block_table is None or block_table.ndim == 2,
"PLE block table must be two-dimensional",
)
_dev = self.conv_states[0].device
_require(
all(
t_ is None or t_.device == _dev
for t_ in (
num_accepted_tokens,
state_indices,
query_start_loc,
request_indices,
block_table,
num_computed_tokens,
)
),
"PLE commit inputs must be on the same device",
)
bt = (0, 0) if block_table is None else block_table.stride()
prepare_commit_plan_kernel[(batch,)](
num_accepted_tokens,
request_indices,
state_indices,
query_start_loc,
block_table,
num_computed_tokens,
self.commit_lens,
self.final_idx,
self.bnd_idx,
self.bnd_len,
NULL_BLOCK_ID,
mamba_block_size or 1,
block_table.shape[1] if block_table is not None else 1,
num_accepted_tokens.stride(0),
request_indices.stride(0) if request_indices is not None else 0,
state_indices.stride(0),
query_start_loc.stride(0),
bt[0],
bt[1],
0 if num_computed_tokens is None else num_computed_tokens.stride(0),
SPEC_QUERY_LEN=self.spec_query_len,
num_warps=1,
)
compact_conv_state_kernel[
(triton.cdiv(self.conv_dim, 256), batch, len(self.conv_states))
](
self.conv_states[0],
self.base,
self.bstride,
self.dstride,
self.tstride,
state_indices,
self.commit_lens,
self.final_idx,
self.bnd_idx,
self.bnd_len,
NULL_BLOCK_ID,
self.conv_dim,
self.hist,
state_indices.stride(0),
BLOCK_D=256,
BLOCK_HISTORY=triton.next_power_of_2(self.hist),
ALIGN_MODE=block_table is not None,
num_warps=4,
)
@dataclass
class PleRecoverSSMCommitMetadata:
state_indices: torch.Tensor # [n]
query_start_loc: torch.Tensor # [n + 1]
request_indices: torch.Tensor | None
block_table: torch.Tensor | None
num_computed_tokens: torch.Tensor | None
block_size: int | None
@dataclass
class PleRecoverSSMMetadata(PleShortConvAttentionMetadata, RecoverSSMMetadata):
recoverssm_commit: PleRecoverSSMCommitMetadata | None = None
recoverssm_context: Any = field(default=None, repr=False, compare=False)
def commit_recoverssm_state(self, num_accepted_tokens):
c = self.recoverssm_commit
if c is None:
return None
n = self.num_spec_decodes
self.recoverssm_context.commit(
num_accepted_tokens,
c.state_indices[:n],
c.query_start_loc[: n + 1],
request_indices=c.request_indices,
block_table=c.block_table,
num_computed_tokens=c.num_computed_tokens,
mamba_block_size=c.block_size,
)
if c.block_table is None:
return None
assert c.block_size is not None and c.num_computed_tokens is not None
return RecoverSSMPostprocessMetadata(
num_spec_decodes=n,
request_indices=c.request_indices,
block_table=c.block_table,
num_computed_tokens=c.num_computed_tokens,
block_size=c.block_size,
)
class PleRecoverSSMMetadataBuilder(PleShortConvAttentionMetadataBuilder):
# Same as the GDN RecoverSSM builder: PIECEWISE only for now.
_cudagraph_support = AttentionCGSupport.NEVER
def __init__(self, kv_cache_spec, layer_names, vllm_config, device) -> None:
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
self.use_full_cuda_graph = False
self._ones = torch.ones(
vllm_config.scheduler_config.max_num_seqs, dtype=torch.int32, device=device
)
self._ctx: _PleConvCommit | None = None
self._logged = False
def _get_ctx(self):
if self._ctx is None:
fc = self.vllm_config.compilation_config.static_forward_context
convs = [fc[name].kv_cache[0] for name in self.layer_names]
ctx = _PleConvCommit(
convs, 1 + self.num_spec, self.vllm_config.scheduler_config.max_num_seqs
)
self._ctx = ctx
logger.info(
"PLE RecoverSSM commit context: %d layers, history %d",
len(convs),
ctx.hist,
)
return self._ctx
def build(
self,
common_prefix_len,
common_attn_metadata,
fast_build=False,
*,
num_accepted_tokens=None,
num_decode_draft_tokens_cpu=None,
**kwargs,
): # type: ignore[override]
m = common_attn_metadata
rows = None
if self.use_spec_decode and num_decode_draft_tokens_cpu is not None:
if m.is_prefilling is None or m.is_prefilling.device.type != "cpu":
raise ValueError("PLE RecoverSSM needs the CPU is_prefilling mask")
# the same classification as the GDN builder, so both commits map the same
# requests
num_decode_draft_tokens_cpu, rows = recoverssm_spec_rows(
m.is_prefilling,
m.query_start_loc_cpu,
num_decode_draft_tokens_cpu,
m.num_reqs,
)
meta = super().build(
common_prefix_len,
m,
fast_build,
num_accepted_tokens=num_accepted_tokens,
num_decode_draft_tokens_cpu=num_decode_draft_tokens_cpu,
**kwargs,
)
if (
not isinstance(meta, PleShortConvAttentionMetadata)
or meta.num_spec_decodes == 0
):
return meta
base = {f.name: getattr(meta, f.name) for f in fields(meta)}
n = meta.num_spec_decodes
base["num_accepted_tokens"] = self._ones[:n]
if rows is None:
raise ValueError("PLE RecoverSSM: spec rows without draft counts")
req_idx = recoverssm_request_indices(rows, n, m.query_start_loc.device)
align = self.vllm_config.cache_config.mamba_cache_mode == "align"
commit = PleRecoverSSMCommitMetadata(
state_indices=meta.spec_state_indices_tensor,
query_start_loc=meta.spec_query_start_loc,
request_indices=req_idx,
block_table=m.block_table_tensor if align else None,
num_computed_tokens=m.compute_num_computed_tokens() if align else None,
block_size=self.kv_cache_spec.block_size if align else None,
)
if not self._logged:
self._logged = True
logger.info("PLE RecoverSSM path taken: %d spec rows, align=%s", n, align)
return PleRecoverSSMMetadata(
**base, recoverssm_commit=commit, recoverssm_context=self._get_ctx()
)
class PleRecoverSSMAttentionBackend(PleShortConvAttentionBackend):
@staticmethod
def get_name() -> str:
return "PLE_SHORT_CONV_RECOVERSSM"
@staticmethod
def get_builder_cls() -> type[PleRecoverSSMMetadataBuilder]:
return PleRecoverSSMMetadataBuilder