Files
vLLM-4x-RTX-3090/patches/0072-recoverssm-RecoverSSM-for-Qwen-GDN-and-the-PLE-short.patch
MOVZX 777e910dd0 Initial commit
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-15 01:01:52 +07:00

2716 lines
103 KiB
Diff

From f1902d20ba112511ed8ad1ad042f68e484e74d58 Mon Sep 17 00:00:00 2001
From: halt95 <halt95@users.noreply.github.com>
Date: Sun, 27 Sep 2026 00:07:04 +0200
Subject: [PATCH 072/103] recoverssm: RecoverSSM for Qwen GDN and the PLE short
conv (port of upstream #58863, 03cae7837f)
Port of the upstream vLLM change #58863 (Apache-2.0). With --use-replayssm and speculative decoding, Gated DeltaNet layers verify the
draft window from a read-only checkpoint and keep a per-token replay record; after sampling one commit replays
the accepted tokens and writes the recurrent state once. The PLE short conv gets the same treatment.
---
tests/config/test_recoverssm_config.py | 173 +++++
tests/kernels/mamba/test_recoverssm_gdn.py | 601 +++++++++++++++
tests/kernels/mamba/test_recoverssm_ple.py | 153 ++++
vllm/config/cache.py | 2 +-
vllm/config/compilation.py | 13 +-
vllm/config/vllm.py | 29 +-
.../layers/mamba/gdn/qwen_gdn_linear_attn.py | 132 +++-
.../layers/mamba/gdn/recoverssm_gdn.py | 695 ++++++++++++++++++
vllm/model_executor/models/interfaces.py | 1 +
vllm/models/qwen4_exp/nvidia/model.py | 59 +-
vllm/models/qwen4_exp/nvidia/mtp.py | 6 +-
vllm/models/qwen4_exp/nvidia/ple_layer.py | 8 +
vllm/v1/attention/backends/gdn_recoverssm.py | 181 +++++
vllm/v1/attention/backends/mamba_attn.py | 7 +-
vllm/v1/attention/backends/ple_recoverssm.py | 326 ++++++++
15 files changed, 2352 insertions(+), 34 deletions(-)
create mode 100644 tests/config/test_recoverssm_config.py
create mode 100644 tests/kernels/mamba/test_recoverssm_gdn.py
create mode 100644 tests/kernels/mamba/test_recoverssm_ple.py
create mode 100644 vllm/model_executor/layers/mamba/gdn/recoverssm_gdn.py
create mode 100644 vllm/v1/attention/backends/gdn_recoverssm.py
create mode 100644 vllm/v1/attention/backends/ple_recoverssm.py
diff --git a/tests/config/test_recoverssm_config.py b/tests/config/test_recoverssm_config.py
new file mode 100644
index 0000000..21791aa
--- /dev/null
+++ b/tests/config/test_recoverssm_config.py
@@ -0,0 +1,173 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Configuration-level regression tests for Qwen4Exp GDN/PLE RecoverSSM selection.
+
+``--use-replayssm`` with speculative decoding selects RecoverSSM for Qwen4Exp, as it
+does for Kimi-K3 KDA. The MTP drafter's derived config shares ``cache_config`` and must
+keep the choice. These tests run the real validator function against stand-in configs.
+"""
+
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+
+from vllm.config.mamba import MambaBackendEnum
+from vllm.config.vllm import VllmConfig
+from vllm.model_executor.layers.mamba.recoverssm_utils import uses_recoverssm
+from vllm.v1.attention.backend import AttentionCGSupport
+
+TARGET = "Qwen4ExpForConditionalGeneration"
+DRAFTER = "Qwen4ExpMTP"
+
+
+def _validator():
+ validators = VllmConfig.__pydantic_decorators__.model_validators # type: ignore[attr-defined]
+ return validators["validate_mamba_cached_kernel"].func
+
+
+def _config(
+ arch: str,
+ cache_config=None,
+ num_spec: int = 3,
+ use_replayssm: bool = True,
+ supports_replayssm: bool = True,
+ **overrides,
+):
+ cfg = SimpleNamespace(
+ num_speculative_tokens=num_spec,
+ model_config=SimpleNamespace(
+ architecture=arch, supports_replayssm=supports_replayssm
+ ),
+ cache_config=cache_config
+ or SimpleNamespace(
+ use_recoverssm=False,
+ use_replayssm=use_replayssm,
+ mamba_cache_mode="align",
+ ),
+ mamba_config=SimpleNamespace(
+ enable_stochastic_rounding=False, backend=MambaBackendEnum.TRITON
+ ),
+ use_v2_model_runner=True,
+ parallel_config=SimpleNamespace(pipeline_parallel_size=1),
+ kv_transfer_config=None,
+ )
+ for key, value in overrides.items():
+ target, _, attr = key.partition("__")
+ if attr:
+ setattr(getattr(cfg, target), attr, value)
+ else:
+ setattr(cfg, target, value)
+ return cfg
+
+
+def test_validator_is_registered():
+ assert "validate_mamba_cached_kernel" in (
+ VllmConfig.__pydantic_decorators__.model_validators # type: ignore[attr-defined]
+ )
+
+
+@pytest.mark.parametrize(
+ "arch", ["Qwen4ExpForConditionalGeneration", "Qwen4ExpForCausalLM"]
+)
+def test_replayssm_with_spec_selects_recoverssm(arch):
+ cfg = _config(arch)
+ _validator()(cfg)
+ assert cfg.cache_config.use_recoverssm is True
+ assert uses_recoverssm(cfg.cache_config, 3)
+
+
+def test_derived_drafter_keeps_the_choice_and_runs_the_checks():
+ target = _config(TARGET)
+ _validator()(target)
+ drafter = _config(DRAFTER, cache_config=target.cache_config)
+ _validator()(drafter)
+ assert target.cache_config.use_recoverssm is True
+ bad = _config(
+ DRAFTER,
+ cache_config=target.cache_config,
+ parallel_config__pipeline_parallel_size=2,
+ )
+ with pytest.raises(ValueError, match="pipeline_parallel_size"):
+ _validator()(bad)
+
+
+@pytest.mark.parametrize(
+ ("override", "value", "match"),
+ [
+ ("parallel_config__pipeline_parallel_size", 2, "pipeline_parallel_size"),
+ ("mamba_config__enable_stochastic_rounding", True, "stochastic"),
+ ("mamba_config__backend", "flashinfer", "mamba-backend triton"),
+ ("use_v2_model_runner", False, "V2_MODEL_RUNNER"),
+ ("cache_config__mamba_cache_mode", "all", "none and align"),
+ ],
+)
+def test_runtime_checks_reject(override, value, match):
+ cfg = _config(TARGET, **{override: value})
+ with pytest.raises(ValueError, match=match):
+ _validator()(cfg)
+
+
+def test_flag_unset_keeps_the_stock_path():
+ cfg = _config(TARGET, use_replayssm=False)
+ _validator()(cfg)
+ assert cfg.cache_config.use_recoverssm is False
+ assert not uses_recoverssm(cfg.cache_config, 3)
+
+
+def test_qwen4exp_replayssm_requires_speculative_decoding():
+ with pytest.raises(ValueError, match="requires speculative decoding"):
+ _validator()(_config(TARGET, num_spec=0))
+
+
+def test_architecture_without_replayssm_support_is_rejected():
+ cfg = _config("Qwen3NextForCausalLM", supports_replayssm=False)
+ with pytest.raises(ValueError, match="not supported for architecture"):
+ _validator()(cfg)
+
+
+def test_uses_recoverssm_predicate():
+ on = SimpleNamespace(use_recoverssm=True)
+ assert uses_recoverssm(on, 3)
+ assert not uses_recoverssm(on, 0)
+ assert not uses_recoverssm(SimpleNamespace(use_recoverssm=False), 3)
+ assert not uses_recoverssm(SimpleNamespace(), 3)
+
+
+def test_builders_fall_back_to_piecewise():
+ from vllm.v1.attention.backends.gdn_recoverssm import GDNRecoverSSMMetadataBuilder
+ from vllm.v1.attention.backends.ple_recoverssm import PleRecoverSSMMetadataBuilder
+
+ for builder in (GDNRecoverSSMMetadataBuilder, PleRecoverSSMMetadataBuilder):
+ assert (
+ builder.get_cudagraph_support(cast(Any, None), cast(Any, None))
+ == AttentionCGSupport.NEVER
+ )
+
+
+@pytest.mark.parametrize("compile_mode", ["VLLM_COMPILE", "NONE"])
+@pytest.mark.parametrize(("breakable", "expected"), [("1", "PIECEWISE"), ("0", "NONE")])
+def test_never_support_falls_back_to_piecewise_with_breakable_cudagraphs(
+ monkeypatch, compile_mode, breakable, expected
+):
+ """A backend without full-graph support must keep piecewise graphs when breakable
+ CUDA graphs split at attention (splitting_ops is empty then, and torch.compile may
+ be off)."""
+ from vllm.config.compilation import (
+ CompilationConfig,
+ CompilationMode,
+ CUDAGraphMode,
+ )
+
+ monkeypatch.setenv("VLLM_USE_BREAKABLE_CUDAGRAPH", breakable)
+ compilation_config = CompilationConfig(
+ mode=CompilationMode[compile_mode],
+ cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
+ splitting_ops=[],
+ )
+ resolved = compilation_config.resolve_cudagraph_mode_and_sizes(
+ AttentionCGSupport.NEVER,
+ "GDNRecoverSSMAttentionBackend",
+ uniform_decode_query_len=4,
+ )
+ assert resolved.name == expected
diff --git a/tests/kernels/mamba/test_recoverssm_gdn.py b/tests/kernels/mamba/test_recoverssm_gdn.py
new file mode 100644
index 0000000..1e0cbcf
--- /dev/null
+++ b/tests/kernels/mamba/test_recoverssm_gdn.py
@@ -0,0 +1,601 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""GDN RecoverSSM: verify against the native fused kernel, commit against the native
+per-token states, and the boundary checks (malformed metadata must raise ValueError
+before any kernel reads or writes out of bounds)."""
+
+from typing import Any, cast
+
+import pytest
+import torch
+
+from vllm.model_executor.layers.mamba.gdn.recoverssm_gdn import (
+ GDNRecoverSSMCommitContext,
+ gdn_recoverssm_verify,
+)
+from vllm.third_party.flash_linear_attention.ops.fused_sigmoid_gating import (
+ fused_sigmoid_gating_delta_rule_update,
+)
+
+pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
+H, HV, K, V, T = 16, 48, 128, 128, 4
+QLENS = (4, 4, 3)
+
+
+def _inputs(nb: int = 10) -> dict:
+ torch.manual_seed(0)
+ dev = "cuda"
+ tot = sum(QLENS)
+ qsl = torch.tensor([0, 4, 8, 11], dtype=torch.int32, device=dev)
+ return dict(
+ A_log=(torch.rand(HV, device=dev) * 2 - 1).float(),
+ a=torch.randn(tot, HV, device=dev, dtype=torch.bfloat16),
+ b=torch.randn(tot, HV, device=dev, dtype=torch.bfloat16),
+ dt_bias=(torch.randn(HV, device=dev) * 0.5).bfloat16(),
+ q=torch.randn(1, tot, H, K, device=dev, dtype=torch.bfloat16),
+ k=torch.randn(1, tot, H, K, device=dev, dtype=torch.bfloat16),
+ v=torch.randn(1, tot, HV, V, device=dev, dtype=torch.bfloat16),
+ checkpoint_state=(torch.randn(nb, HV, V, K, device=dev) * 0.05).float(),
+ replay_cache=torch.zeros(nb, HV, T, V + K + 1, device=dev),
+ query_start_loc=qsl,
+ state_indices=torch.tensor([2, 5, 7], dtype=torch.int32, device=dev),
+ spec_query_len=T,
+ )
+
+
+def _verify(inp: dict, **override):
+ d = {**inp, **override}
+ return gdn_recoverssm_verify(
+ d["A_log"],
+ d["a"],
+ d["b"],
+ d["dt_bias"],
+ d["q"],
+ d["k"],
+ d["v"],
+ checkpoint_state=d["checkpoint_state"],
+ replay_cache=d["replay_cache"],
+ query_start_loc=d["query_start_loc"],
+ state_indices=d["state_indices"],
+ spec_query_len=d["spec_query_len"],
+ out=d.get("out"),
+ )
+
+
+def _native(inp: dict):
+ return fused_sigmoid_gating_delta_rule_update(
+ A_log=inp["A_log"],
+ a=inp["a"],
+ b=inp["b"],
+ dt_bias=inp["dt_bias"],
+ q=inp["q"],
+ k=inp["k"],
+ v=inp["v"],
+ initial_state=inp["checkpoint_state"].clone(),
+ inplace_final_state=False,
+ cu_seqlens=inp["query_start_loc"].long(),
+ ssm_state_indices=inp["state_indices"].long(),
+ use_qk_l2norm_in_kernel=True,
+ )
+
+
+def _context(inp: dict, ckpt: torch.Tensor) -> GDNRecoverSSMCommitContext:
+ nb = ckpt.shape[0]
+ conv = [torch.zeros(nb, 8, 3 + T - 1, device="cuda", dtype=torch.bfloat16)]
+ return GDNRecoverSSMCommitContext.from_tensors(
+ conv, [ckpt], [inp["replay_cache"]], spec_query_len=T, max_num_reqs=8
+ )
+
+
+def test_verify_matches_native_and_keeps_the_checkpoint():
+ inp = _inputs()
+ before = inp["checkpoint_state"].clone()
+ out = _verify(inp)
+ ref, _ = _native(inp)
+ torch.testing.assert_close(
+ out.float(), ref.reshape(out.shape).float(), rtol=0, atol=0
+ )
+ assert torch.equal(inp["checkpoint_state"], before)
+
+
+@pytest.mark.parametrize("accepted", [1, 2, 3, 4])
+def test_commit_matches_native_per_token_state(accepted):
+ inp = _inputs()
+ _verify(inp)
+ _, states = _native(inp)
+ ckpt = inp["checkpoint_state"].clone()
+ ctx = _context(inp, ckpt)
+ acc = torch.tensor(
+ [min(accepted, q) for q in QLENS], dtype=torch.int32, device="cuda"
+ )
+ ctx.commit(acc, inp["state_indices"], inp["query_start_loc"])
+ for i, blk in enumerate(inp["state_indices"].tolist()):
+ tok = int(inp["query_start_loc"][i]) + int(acc[i]) - 1
+ torch.testing.assert_close(ckpt[blk], states[tok], rtol=1e-5, atol=1e-6)
+ others = [b for b in range(ckpt.shape[0]) if b not in inp["state_indices"].tolist()]
+ assert torch.equal(ckpt[others], inp["checkpoint_state"][others])
+
+
+@pytest.mark.parametrize(
+ "case", ["short_qsl", "capacity", "hv_lt_h", "a_log", "out", "replay", "device"]
+)
+def test_verify_rejects_malformed_inputs(case):
+ inp = _inputs()
+ over, match = {
+ "short_qsl": (
+ dict(query_start_loc=inp["query_start_loc"][:-1]),
+ "query metadata",
+ ),
+ "capacity": (
+ dict(
+ state_indices=inp["state_indices"][:2],
+ query_start_loc=inp["query_start_loc"][:3],
+ ),
+ "activation capacity",
+ ),
+ "hv_lt_h": (
+ dict(
+ v=inp["v"][:, :, :8].contiguous(),
+ a=inp["a"][:, :8].contiguous(),
+ b=inp["b"][:, :8].contiguous(),
+ ),
+ "positive multiple",
+ ),
+ "a_log": (dict(A_log=torch.zeros(HV + 1, device="cuda")), "A_log"),
+ "out": (
+ dict(
+ out=torch.empty(1, 11, HV, V + 1, device="cuda", dtype=torch.bfloat16)
+ ),
+ "output shape",
+ ),
+ "replay": (
+ dict(replay_cache=torch.zeros(10, HV, T, V + K, device="cuda")),
+ "replay buffer",
+ ),
+ "device": (dict(A_log=inp["A_log"].cpu()), "same device"),
+ }[case]
+ with pytest.raises(ValueError, match=match):
+ _verify(inp, **over)
+
+
+def test_commit_rejects_partial_or_short_metadata():
+ inp = _inputs()
+ ctx = _context(inp, inp["checkpoint_state"].clone())
+ acc = torch.ones(3, dtype=torch.int32, device="cuda")
+ bt = torch.zeros(3, 4, dtype=torch.int32, device="cuda")
+ with pytest.raises(ValueError, match="align metadata is incomplete"):
+ ctx.commit(
+ acc,
+ inp["state_indices"],
+ inp["query_start_loc"],
+ block_table=bt,
+ mamba_block_size=16,
+ )
+ with pytest.raises(ValueError, match="commit metadata"):
+ ctx.commit(acc, inp["state_indices"], inp["query_start_loc"][:-1])
+ with pytest.raises(ValueError, match="request mapping"):
+ ctx.commit(
+ acc,
+ inp["state_indices"],
+ inp["query_start_loc"],
+ request_indices=torch.zeros(2, dtype=torch.int32, device="cuda"),
+ )
+
+
+def test_context_rejects_inconsistent_layers():
+ inp = _inputs()
+ ck = inp["checkpoint_state"]
+ conv = torch.zeros(ck.shape[0], 8, 3 + T - 1, device="cuda", dtype=torch.bfloat16)
+ with pytest.raises(ValueError, match="at least one layer"):
+ GDNRecoverSSMCommitContext.from_tensors(
+ [], [], [], spec_query_len=T, max_num_reqs=8
+ )
+ with pytest.raises(ValueError, match="differ"):
+ GDNRecoverSSMCommitContext.from_tensors(
+ [conv], [ck, ck], [inp["replay_cache"]], spec_query_len=T, max_num_reqs=8
+ )
+ with pytest.raises(ValueError, match="block count"):
+ GDNRecoverSSMCommitContext.from_tensors(
+ [conv[:5]], [ck], [inp["replay_cache"]], spec_query_len=T, max_num_reqs=8
+ )
+
+
+# ------------------------------------------------------------------ more cases:
+# mapping, align, extreme gates
+from vllm.v1.attention.backends.recoverssm_metadata import ( # noqa: E402
+ recoverssm_request_indices,
+ recoverssm_spec_rows,
+)
+
+# (A_log range, a scale, b offset): drive g = -exp(A_log) * softplus(a + dt_bias)
+# and beta = sigmoid(b)
+GATES = {
+ "typical": (0.0, 1.0, 0.0),
+ "g_near_0": (-12.0, 1.0, 0.0),
+ "g_very_negative": (2.5, 4.0, 0.0),
+ "beta_near_0": (0.0, 1.0, -12.0),
+ "beta_near_1": (0.0, 1.0, 12.0),
+}
+
+
+def _make(qlens, nb=16, seed=0, gate="typical"):
+ g = torch.Generator(device="cuda").manual_seed(seed)
+ rnd = lambda *s: torch.randn(*s, device="cuda", generator=g) # noqa: E731
+ a_log_shift, ascale, boff = GATES[gate]
+ tot = sum(qlens)
+ qsl = torch.tensor(
+ [0, *torch.tensor(qlens).cumsum(0).tolist()], dtype=torch.int32, device="cuda"
+ )
+ return dict(
+ A_log=(
+ torch.rand(HV, device="cuda", generator=g) * 2 - 1 + a_log_shift
+ ).float(),
+ a=(rnd(tot, HV) * ascale).bfloat16(),
+ b=(rnd(tot, HV) + boff).bfloat16(),
+ dt_bias=(rnd(HV) * 0.5).bfloat16(),
+ q=rnd(1, tot, H, K).bfloat16(),
+ k=rnd(1, tot, H, K).bfloat16(),
+ v=rnd(1, tot, HV, V).bfloat16(),
+ checkpoint_state=(rnd(nb, HV, V, K) * 0.05).float(),
+ replay_cache=torch.zeros(nb, HV, T, V + K + 1, device="cuda"),
+ query_start_loc=qsl,
+ spec_query_len=T,
+ )
+
+
+def test_spec_rows_mixed_batch():
+ # rows: 0 spec K=3 | 1 prefill | 2 draft-less decode | 3 spec K=1 | 4 padded
+ # zero-query | 5,6 beyond num_reqs
+ is_prefilling = torch.tensor([False, True, False, False, False, False, False])
+ qsl = torch.tensor([0, 4, 104, 105, 107, 107], dtype=torch.int32)
+ drafts_in = torch.tensor(
+ [3, -1, -1, 1, -1, 2, 0], dtype=torch.int32
+ ) # 5, 6: garbage padding
+ drafts, rows = recoverssm_spec_rows(is_prefilling, qsl, drafts_in, num_reqs=5)
+ assert rows.tolist() == [0, 2, 3]
+ assert drafts.tolist() == [1, -1, 1, 1, -1, -1, -1]
+ idx = recoverssm_request_indices(rows, 3, torch.device("cuda"))
+ assert idx is not None and idx.tolist() == [0, 2, 3]
+ assert (
+ recoverssm_request_indices(torch.tensor([0, 1, 2]), 3, torch.device("cuda"))
+ is None
+ )
+ with pytest.raises(ValueError, match="spec rows classified"):
+ recoverssm_request_indices(rows, 2, torch.device("cuda"))
+
+
+def test_commit_maps_noncontiguous_requests_in_align_mode():
+ """Spec rows 0, 2, 3 of a 5-request batch; accepted counts, block table and
+ num_computed are indexed by request row. Every spec request must commit its own
+ state into its own block."""
+ qlens = [4, 1, 2] # the spec rows' windows (K=3, draft-less, K=1)
+ inp = _make(qlens, nb=40)
+ bs = 8
+ bt = torch.arange(1, 1 + 5 * 6, dtype=torch.int32, device="cuda").reshape(
+ 5, 6
+ ) # request row -> blocks
+ nc = torch.tensor(
+ [5, 0, 16, 9, 0], dtype=torch.int32, device="cuda"
+ ) # per request row
+ req_rows = torch.tensor([0, 2, 3], dtype=torch.int32, device="cuda")
+ src = torch.stack([bt[r, int(nc[r]) // bs] for r in req_rows.tolist()]).contiguous()
+ inp["state_indices"] = src
+ _verify(inp)
+ _, states = _native(inp)
+ ckpt = inp["checkpoint_state"].clone()
+ ctx = _context(inp, ckpt)
+ acc = torch.tensor(
+ [3, 9, 1, 2, 9], dtype=torch.int32, device="cuda"
+ ) # rows 1, 4 must be ignored
+ ctx.commit(
+ acc,
+ src,
+ inp["query_start_loc"],
+ request_indices=req_rows,
+ block_table=bt,
+ num_computed_tokens=nc,
+ mamba_block_size=bs,
+ )
+ for i, r in enumerate(req_rows.tolist()):
+ n = min(int(acc[r]), qlens[i])
+ c0 = int(nc[r])
+ final_blk = int(bt[r, (c0 + n) // bs])
+ tok = int(inp["query_start_loc"][i]) + n - 1
+ torch.testing.assert_close(ckpt[final_blk], states[tok], rtol=1e-5, atol=1e-6)
+
+
+# (num_computed, accepted, block size): boundary after 1 / after 2 / ending exactly on
+# it / crossing and continuing / accepted = 1 without crossing / the full window
+# crossing after 3
+ALIGN_CASES = [
+ (3, 2, 4),
+ (2, 3, 4),
+ (0, 4, 4),
+ (6, 4, 4),
+ (1, 1, 4),
+ (5, 4, 8),
+ (7, 4, 8),
+ (4, 4, 8),
+]
+
+
+@pytest.mark.parametrize("nc,accepted,bs", ALIGN_CASES)
+def test_align_boundary_and_final_state_match_native(nc, accepted, bs):
+ inp = _make([T], nb=12)
+ bt = torch.arange(2, 8, dtype=torch.int32, device="cuda").reshape(1, 6)
+ src = bt[0, nc // bs].reshape(1).contiguous()
+ inp["state_indices"] = src
+ _verify(inp)
+ _, states = _native(inp)
+ ckpt = inp["checkpoint_state"].clone()
+ ctx = _context(inp, ckpt)
+ ctx.commit(
+ torch.tensor([accepted], dtype=torch.int32, device="cuda"),
+ src,
+ inp["query_start_loc"],
+ block_table=bt,
+ num_computed_tokens=torch.tensor([nc], dtype=torch.int32, device="cuda"),
+ mamba_block_size=bs,
+ )
+ next_boundary = (nc // bs + 1) * bs
+ final_blk = int(bt[0, (nc + accepted) // bs])
+ if (
+ nc + accepted >= next_boundary
+ ): # crosses: the boundary state goes into the source block
+ torch.testing.assert_close(
+ ckpt[int(src)], states[next_boundary - nc - 1], rtol=1e-5, atol=1e-6
+ )
+ torch.testing.assert_close(
+ ckpt[final_blk], states[accepted - 1], rtol=1e-5, atol=1e-6
+ )
+
+
+@pytest.mark.parametrize("gate", list(GATES))
+@pytest.mark.parametrize("seed", [0, 1, 2])
+def test_commit_matches_native_under_extreme_gates(gate, seed):
+ inp = _make(list(QLENS), nb=10, seed=seed, gate=gate)
+ inp["state_indices"] = torch.tensor([2, 5, 7], dtype=torch.int32, device="cuda")
+ _verify(inp)
+ _, states = _native(inp)
+ for accepted in (1, 2, 3, 4):
+ ckpt = inp["checkpoint_state"].clone()
+ ctx = _context(inp, ckpt)
+ acc = torch.tensor(
+ [min(accepted, q) for q in QLENS], dtype=torch.int32, device="cuda"
+ )
+ ctx.commit(acc, inp["state_indices"], inp["query_start_loc"])
+ for i, blk in enumerate(inp["state_indices"].tolist()):
+ tok = int(inp["query_start_loc"][i]) + int(acc[i]) - 1
+ torch.testing.assert_close(ckpt[blk], states[tok], rtol=1e-5, atol=1e-6)
+
+
+@pytest.mark.parametrize("batch", [1, 2, 4])
+def test_verify_on_strided_views_writes_in_place(batch):
+ """The layer passes views of the packed conv output (token stride = the packed
+ width) and the layer's output buffer; result and replay record must equal the
+ contiguous-copy path."""
+ torch.manual_seed(batch)
+ H, HV, K, V, SQ, NB = 16, 48, 128, 128, 4, 6
+ T = batch * SQ
+ qd, vd = H * K, HV * V
+ packed = torch.randn(T, 2 * qd + 2 * vd, device="cuda", dtype=torch.bfloat16)
+ mixed = packed[:, : 2 * qd + vd]
+ a = torch.randn(T, HV, device="cuda", dtype=torch.bfloat16)
+ b = torch.randn(T, HV, device="cuda", dtype=torch.bfloat16)
+ A_log = torch.randn(HV, device="cuda")
+ dt_bias = torch.randn(HV, device="cuda")
+ checkpoint = torch.randn(NB, HV, V, K, device="cuda") * 0.05
+ qsl = torch.arange(0, T + 1, SQ, device="cuda", dtype=torch.int32)
+ si = torch.arange(1, batch + 1, device="cuda", dtype=torch.int32)
+
+ def run(q, k, v, out=None):
+ replay = torch.zeros(NB, HV, SQ, V + K + 1, device="cuda")
+ o = gdn_recoverssm_verify(
+ A_log,
+ a,
+ b,
+ dt_bias,
+ q,
+ k,
+ v,
+ checkpoint_state=checkpoint,
+ replay_cache=replay,
+ query_start_loc=qsl,
+ state_indices=si,
+ spec_query_len=SQ,
+ out=out,
+ )
+ return o, replay
+
+ q, k, v = (
+ x.contiguous().view(1, T, -1, d)
+ for x, d in zip(torch.split(mixed, [qd, qd, vd], dim=-1), (K, K, V))
+ )
+ want_out, want_replay = run(q, k, v)
+
+ core = torch.zeros(T + 3, HV, V, device="cuda", dtype=torch.bfloat16)
+ views = (
+ mixed[:, :qd].view(1, T, -1, K),
+ mixed[:, qd : 2 * qd].view(1, T, -1, K),
+ mixed[:, 2 * qd :].view(1, T, -1, V),
+ )
+ got_out, got_replay = run(*views, out=core[:T].unsqueeze(0))
+ assert got_out.data_ptr() == core.data_ptr()
+ assert torch.equal(core[:T].unsqueeze(0), want_out)
+ assert torch.equal(got_replay, want_replay)
+
+
+def test_verify_on_strided_views_with_variable_query_lengths():
+ """Token-strided views with ragged windows (query lengths 4, 1, 3), as MTP
+ scheduling produces them."""
+ torch.manual_seed(7)
+ H, HV, K, V, SQ, NB = 16, 48, 128, 128, 4, 6
+ qsl_list = [0, 4, 5, 8]
+ T = qsl_list[-1]
+ qd, vd = H * K, HV * V
+ packed = torch.randn(T, 2 * qd + 2 * vd, device="cuda", dtype=torch.bfloat16)
+ mixed = packed[:, : 2 * qd + vd]
+ a = torch.randn(T, HV, device="cuda", dtype=torch.bfloat16)
+ b = torch.randn(T, HV, device="cuda", dtype=torch.bfloat16)
+ A_log = torch.randn(HV, device="cuda")
+ dt_bias = torch.randn(HV, device="cuda")
+ checkpoint = torch.randn(NB, HV, V, K, device="cuda") * 0.05
+ qsl = torch.tensor(qsl_list, device="cuda", dtype=torch.int32)
+ si = torch.tensor([1, 3, 5], device="cuda", dtype=torch.int32)
+
+ def run(q, k, v, out=None):
+ replay = torch.zeros(NB, HV, SQ, V + K + 1, device="cuda")
+ o = gdn_recoverssm_verify(
+ A_log,
+ a,
+ b,
+ dt_bias,
+ q,
+ k,
+ v,
+ checkpoint_state=checkpoint,
+ replay_cache=replay,
+ query_start_loc=qsl,
+ state_indices=si,
+ spec_query_len=SQ,
+ out=out,
+ )
+ return o, replay
+
+ q, k, v = (
+ x.contiguous().view(1, T, -1, d)
+ for x, d in zip(torch.split(mixed, [qd, qd, vd], dim=-1), (K, K, V))
+ )
+ want_out, want_replay = run(q, k, v)
+ core = torch.zeros(T + 2, HV, V, device="cuda", dtype=torch.bfloat16)
+ views = (
+ mixed[:, :qd].view(1, T, -1, K),
+ mixed[:, qd : 2 * qd].view(1, T, -1, K),
+ mixed[:, 2 * qd :].view(1, T, -1, V),
+ )
+ _, got_replay = run(*views, out=core[:T].unsqueeze(0))
+ assert torch.equal(core[:T].unsqueeze(0), want_out)
+ assert torch.equal(got_replay, want_replay)
+
+
+def test_layer_verify_qkv_views_and_fallback():
+ """The layer's guard: views for a [tokens, q|k|v] conv output with a contiguous
+ last dimension, the contiguous-copy fallback for any other layout; same values
+ either way."""
+ from types import SimpleNamespace
+
+ from vllm.model_executor.layers.mamba.gdn.qwen_gdn_linear_attn import (
+ QwenGatedDeltaNetAttention as Layer,
+ )
+
+ H, HV, K, V, T = 4, 8, 16, 16, 5
+ layer = SimpleNamespace(
+ key_dim=H * K, value_dim=HV * V, tp_size=1, head_k_dim=K, head_v_dim=V
+ )
+ layer.rearrange_mixed_qkv = lambda x: Layer.rearrange_mixed_qkv(cast(Any, layer), x)
+ width = 2 * H * K + HV * V
+ packed = torch.randn(T, width + HV * V, dtype=torch.bfloat16) # q|k|v|z, like qkvz
+ strided = packed[:, :width]
+ q, k, v, is_view = Layer._recoverssm_verify_qkv(cast(Any, layer), strided)
+ assert is_view
+ assert q.data_ptr() == packed.data_ptr()
+ rq, rk, rv = Layer.rearrange_mixed_qkv(cast(Any, layer), strided)
+ assert torch.equal(q, rq) and torch.equal(k, rk) and torch.equal(v, rv)
+
+ # Column-major storage: last dimension not contiguous -> copies, same values.
+ col_major = strided.t().contiguous().t()
+ assert col_major.stride(-1) != 1
+ q2, k2, v2, is_view2 = Layer._recoverssm_verify_qkv(cast(Any, layer), col_major)
+ assert not is_view2
+ assert torch.equal(q2, rq) and torch.equal(k2, rk) and torch.equal(v2, rv)
+
+
+# ------------------------------------------------------------- BF16 recurrent state
+# RecoverSSM upcasts the BF16 checkpoint, carries the accepted window in FP32 and rounds
+# once when it writes the committed state. The reference is the FP32 trajectory from the
+# same (already BF16-rounded) checkpoint; the committed state must equal its BF16
+# rounding up to one BF16 ulp, and be no further from it than the native path is.
+BF16_ULP = 2**-7
+
+
+def _bf16_case(qlens, nb=12, seed=0):
+ inp = _make(qlens, nb=nb, seed=seed)
+ inp["checkpoint_state"] = inp["checkpoint_state"].bfloat16()
+ ref = dict(inp)
+ ref["checkpoint_state"] = inp["checkpoint_state"].float()
+ return inp, ref
+
+
+def test_verify_bf16_state_matches_native():
+ inp, _ = _bf16_case(list(QLENS))
+ inp["state_indices"] = torch.tensor([2, 5, 7], dtype=torch.int32, device="cuda")
+ before = inp["checkpoint_state"].clone()
+ out = _verify(inp)
+ ref, _ = _native(inp)
+ torch.testing.assert_close(
+ out.float(), ref.reshape(out.shape).float(), rtol=0, atol=0
+ )
+ assert torch.equal(inp["checkpoint_state"], before)
+
+
+@pytest.mark.parametrize("accepted", [1, 2, 3, 4])
+@pytest.mark.parametrize("seed", [0, 1])
+def test_commit_bf16_state_rounds_once(accepted, seed):
+ inp, ref_inp = _bf16_case(list(QLENS), seed=seed)
+ idx = torch.tensor([2, 5, 7], dtype=torch.int32, device="cuda")
+ inp["state_indices"] = ref_inp["state_indices"] = idx
+ _verify(inp)
+ _, native_states = _native(inp)
+ _, ref_states = _native(ref_inp)
+ ckpt = inp["checkpoint_state"].clone()
+ _context(inp, ckpt).commit(
+ torch.tensor(
+ [min(accepted, q) for q in QLENS], dtype=torch.int32, device="cuda"
+ ),
+ idx,
+ inp["query_start_loc"],
+ )
+ for i, blk in enumerate(idx.tolist()):
+ tok = int(inp["query_start_loc"][i]) + min(accepted, QLENS[i]) - 1
+ ref = ref_states[tok].float()
+ got = ckpt[blk].float()
+ torch.testing.assert_close(
+ got, ref.bfloat16().float(), rtol=BF16_ULP, atol=1e-5
+ )
+ err_rssm = (got - ref).abs().mean()
+ err_native = (native_states[tok].bfloat16().float() - ref).abs().mean()
+ assert err_rssm <= err_native * 1.05 + 1e-7, (err_rssm, err_native)
+
+
+@pytest.mark.parametrize("nc,accepted,bs", ALIGN_CASES)
+def test_align_boundary_bf16_state(nc, accepted, bs):
+ inp, ref_inp = _bf16_case([T])
+ bt = torch.arange(2, 8, dtype=torch.int32, device="cuda").reshape(1, 6)
+ src = bt[0, nc // bs].reshape(1).contiguous()
+ inp["state_indices"] = ref_inp["state_indices"] = src
+ _verify(inp)
+ _, ref_states = _native(ref_inp)
+ ckpt = inp["checkpoint_state"].clone()
+ _context(inp, ckpt).commit(
+ torch.tensor([accepted], dtype=torch.int32, device="cuda"),
+ src,
+ inp["query_start_loc"],
+ block_table=bt,
+ num_computed_tokens=torch.tensor([nc], dtype=torch.int32, device="cuda"),
+ mamba_block_size=bs,
+ )
+ next_boundary = (nc // bs + 1) * bs
+ final_blk = int(bt[0, (nc + accepted) // bs])
+ if nc + accepted >= next_boundary:
+ torch.testing.assert_close(
+ ckpt[int(src)].float(),
+ ref_states[next_boundary - nc - 1].float().bfloat16().float(),
+ rtol=BF16_ULP,
+ atol=1e-5,
+ )
+ torch.testing.assert_close(
+ ckpt[final_blk].float(),
+ ref_states[accepted - 1].float().bfloat16().float(),
+ rtol=BF16_ULP,
+ atol=1e-5,
+ )
diff --git a/tests/kernels/mamba/test_recoverssm_ple.py b/tests/kernels/mamba/test_recoverssm_ple.py
new file mode 100644
index 0000000..ec11ad3
--- /dev/null
+++ b/tests/kernels/mamba/test_recoverssm_ple.py
@@ -0,0 +1,153 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""PLE short-conv RecoverSSM against the sequential reference.
+
+Reference: the stock decode path fed the accepted tokens one at a time.
+RecoverSSM: one spec-mode conv over the whole window from the checkpoint
+(offset 0), then ``_PleConvCommit`` compacts the accepted suffix to the front.
+The conv history is pure data movement, so the states must be bit-identical,
+and the outputs of the accepted tokens must match as well.
+"""
+
+import pytest
+import torch
+
+from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
+from vllm.models.qwen4_exp.nvidia.ops.ple import ple_conv
+from vllm.v1.attention.backends.ple_recoverssm import _PleConvCommit
+
+C, KSIZE, DIL, SQ, NB = 512, 4, 3, 4, 8
+STATE_LEN = (KSIZE - 1) * DIL
+WIDTH = STATE_LEN + SQ - 1
+DEV = "cuda"
+
+
+def _state():
+ if is_conv_state_dim_first():
+ raw = torch.zeros(NB, C, WIDTH, dtype=torch.bfloat16, device=DEV)
+ return raw, raw
+ raw = torch.zeros(NB, WIDTH, C, dtype=torch.bfloat16, device=DEV)
+ return raw, raw.transpose(-1, -2)
+
+
+def _i32(xs):
+ return torch.tensor(xs, dtype=torch.int32, device=DEV)
+
+
+def _setup(seed, blocks):
+ g = torch.Generator(device=DEV).manual_seed(seed)
+ w = torch.randn(C, KSIZE, generator=g, device=DEV).to(torch.bfloat16)
+ x = torch.randn(len(blocks) * SQ, C, generator=g, device=DEV).to(torch.bfloat16)
+ outer = torch.randn_like(x)
+ raw, view = _state()
+ for b in blocks:
+ view[b, :, :STATE_LEN] = torch.randn(C, STATE_LEN, generator=g, device=DEV).to(
+ torch.bfloat16
+ )
+ return w, x, outer, raw, view
+
+
+def _sequential(w, x, outer, view, block, first, count):
+ """Stock decode, one token at a time, on a copy of the checkpoint block."""
+ ref = view.clone()
+ res = torch.zeros(count, C, dtype=torch.bfloat16, device=DEV)
+ for i in range(count):
+ t = first + i
+ ple_conv(
+ inputs=x[t : t + 1],
+ residual=res[i : i + 1],
+ conv_state=ref,
+ conv_weights=w,
+ state_indices=_i32([block]),
+ outer_residual=outer[t : t + 1],
+ mode="decode",
+ dilation=DIL,
+ )
+ return ref[block, :, :STATE_LEN].clone(), res
+
+
+def _spec(w, x, outer, view, blocks):
+ res = torch.zeros(len(blocks) * SQ, C, dtype=torch.bfloat16, device=DEV)
+ ple_conv(
+ inputs=x,
+ residual=res,
+ conv_state=view,
+ conv_weights=w,
+ state_indices=_i32(blocks),
+ outer_residual=outer,
+ mode="spec",
+ dilation=DIL,
+ query_start_loc=_i32(list(range(0, len(blocks) * SQ + 1, SQ))),
+ num_accepted_tokens=_i32([1] * len(blocks)),
+ spec_query_len=SQ,
+ )
+ return res
+
+
+@pytest.mark.parametrize("accepted", [1, 2, SQ])
+@pytest.mark.parametrize("seed", [0, 1])
+def test_ple_commit_matches_sequential(accepted, seed):
+ block = 3
+ w, x, outer, raw, view = _setup(seed, [block])
+ want_state, want_out = _sequential(w, x, outer, view, block, 0, accepted)
+ history = torch.cat([view[block, :, :STATE_LEN], x[:accepted].T], dim=1)
+
+ got_out = _spec(w, x, outer, view, [block])
+ ctx = _PleConvCommit([raw], spec_query_len=SQ, max_num_reqs=NB)
+ ctx.commit(_i32([accepted]), _i32([block]), _i32([0, SQ]))
+
+ got_state = view[block, :, :STATE_LEN]
+ assert torch.equal(got_state, want_state)
+ assert torch.equal(got_state, history[:, -STATE_LEN:])
+ assert torch.equal(got_out[:accepted], want_out)
+
+
+@pytest.mark.parametrize(
+ ("num_computed", "accepted", "crosses"),
+ [(6, 3, True), (6, 2, True), (1, 2, False), (7, SQ, True)],
+)
+def test_ple_commit_align_boundary(num_computed, accepted, crosses):
+ block_size, src, nxt = 8, 2, 5
+ w, x, outer, raw, view = _setup(7, [src])
+ boundary_len = block_size - num_computed
+ want_final, _ = _sequential(w, x, outer, view, src, 0, accepted)
+ want_boundary, _ = _sequential(w, x, outer, view, src, 0, min(boundary_len, SQ))
+
+ _spec(w, x, outer, view, [src])
+ ctx = _PleConvCommit([raw], spec_query_len=SQ, max_num_reqs=NB)
+ ctx.commit(
+ _i32([accepted]),
+ _i32([src]),
+ _i32([0, SQ]),
+ block_table=_i32([[src, nxt]]),
+ num_computed_tokens=_i32([num_computed]),
+ mamba_block_size=block_size,
+ )
+
+ final_block = nxt if num_computed + accepted >= block_size else src
+ assert torch.equal(view[final_block, :, :STATE_LEN], want_final)
+ if crosses:
+ assert torch.equal(view[src, :, :STATE_LEN], want_boundary)
+
+
+def test_ple_commit_non_contiguous_request_rows():
+ blocks, accepted = [1, 6], [3, 1]
+ w, x, outer, raw, view = _setup(11, blocks)
+ want = [
+ _sequential(w, x, outer, view, b, i * SQ, a)[0]
+ for i, (b, a) in enumerate(zip(blocks, accepted))
+ ]
+
+ _spec(w, x, outer, view, blocks)
+ # Spec rows 0 and 1 belong to requests 1 and 3 of a four-request batch.
+ counts = _i32([9, accepted[0], 9, accepted[1]])
+ ctx = _PleConvCommit([raw], spec_query_len=SQ, max_num_reqs=NB)
+ ctx.commit(
+ counts,
+ _i32(blocks),
+ _i32([0, SQ, 2 * SQ]),
+ request_indices=_i32([1, 3]),
+ )
+
+ for b, ref in zip(blocks, want):
+ assert torch.equal(view[b, :, :STATE_LEN], ref)
diff --git a/vllm/config/cache.py b/vllm/config/cache.py
index 6dca830..e276dda 100644
--- a/vllm/config/cache.py
+++ b/vllm/config/cache.py
@@ -202,7 +202,7 @@ class CacheConfig:
mode flushes are most efficient when mamba_block_size is a multiple of
replayssm_buffer_len, but this is not required."""
use_recoverssm: bool = field(default=False, init=False)
- """Whether Kimi-K3 KDA uses RecoverSSM speculative decode."""
+ """Whether RecoverSSM speculative decode is used (Kimi-K3 KDA; Qwen4Exp GDN and PLE)."""
# Will be set after profiling.
num_gpu_blocks: int | None = field(default=None, init=False)
diff --git a/vllm/config/compilation.py b/vllm/config/compilation.py
index f11bea4..791975b 100644
--- a/vllm/config/compilation.py
+++ b/vllm/config/compilation.py
@@ -1446,10 +1446,15 @@ class CompilationConfig:
f"with {min_cg_attn_backend} backend (support: "
f"{min_cg_support})"
)
- if self.mode == CompilationMode.VLLM_COMPILE and (
- self.splitting_ops_contain_attention()
- or self.use_inductor_graph_partition
- ):
+ if (
+ self.mode == CompilationMode.VLLM_COMPILE
+ and (
+ self.splitting_ops_contain_attention()
+ or self.use_inductor_graph_partition
+ )
+ ) or envs.VLLM_USE_BREAKABLE_CUDAGRAPH:
+ # Breakable CUDA graphs break at the attention kernels at runtime,
+ # with or without torch.compile.
msg += (
"; setting cudagraph_mode=PIECEWISE because "
"attention is compiled piecewise"
diff --git a/vllm/config/vllm.py b/vllm/config/vllm.py
index 67ba137..f215ab3 100644
--- a/vllm/config/vllm.py
+++ b/vllm/config/vllm.py
@@ -68,6 +68,13 @@ else:
logger = init_logger(__name__)
+# Architectures whose GDN (and PLE) layers run RecoverSSM under --use-replayssm.
+_QWEN4EXP_RECOVERSSM_ARCHS = (
+ "Qwen4ExpForCausalLM",
+ "Qwen4ExpForConditionalGeneration",
+ "Qwen4ExpMTP",
+)
+
# TODO(rocm): These models are either unsupported by MRV2 or slower with
# MRV2 on AMD GPUs.
ROCM_DEFAULT_MRV1_ARCHITECTURES = frozenset(
@@ -3218,21 +3225,35 @@ class VllmConfig:
@model_validator(mode="after")
def validate_mamba_cached_kernel(self) -> "VllmConfig":
if not self.cache_config.use_replayssm:
- self.cache_config.use_kda_recoverssm = False
+ self.cache_config.use_recoverssm = False
return self
- self.cache_config.use_kda_recoverssm = self.num_speculative_tokens > 0
+ self.cache_config.use_recoverssm = self.num_speculative_tokens > 0
if self.model_config is not None and not self.model_config.supports_replayssm:
raise ValueError(
"--use-replayssm is not supported for architecture "
f"{self.model_config.architecture!r}"
)
- if self.cache_config.use_kda_recoverssm:
+ if (
+ self.model_config is not None
+ and self.model_config.architecture in _QWEN4EXP_RECOVERSSM_ARCHS
+ and not self.cache_config.use_recoverssm
+ ):
+ # Qwen4Exp's GDN and PLE layers have no plain ReplaySSM path; with them the
+ # flag selects RecoverSSM, which needs speculative decoding.
+ raise ValueError(
+ "--use-replayssm on Qwen4Exp selects GDN RecoverSSM and requires "
+ "speculative decoding (num_speculative_tokens > 0)"
+ )
+ if self.cache_config.use_recoverssm:
if self.model_config is not None and self.model_config.architecture not in (
"KimiLinearForCausalLM",
"KimiK3ForConditionalGeneration",
+ *_QWEN4EXP_RECOVERSSM_ARCHS,
):
- raise ValueError("RecoverSSM is only supported for Kimi-K3 KDA")
+ raise ValueError(
+ "RecoverSSM is only supported for Kimi-K3 KDA and Qwen4Exp GDN"
+ )
if self.mamba_config.enable_stochastic_rounding:
raise ValueError(
"RecoverSSM supports bfloat16/float32 "
diff --git a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
index bbbb1c8..2d6b675 100644
--- a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+++ b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
@@ -368,10 +368,15 @@ class ChunkGatedDeltaRule(CustomOp):
@PluggableLayer.register("qwen_gated_delta_net_attention")
class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
- def get_state_shape(
- self,
- ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
- return MambaStateShapeCalculator.gated_delta_net_state_shape(
+ def _uses_gdn_recoverssm(self) -> bool:
+ """One predicate for the replay state's shape, dtype and the attention
+ backend."""
+ from vllm.model_executor.layers.mamba.recoverssm_utils import uses_recoverssm
+
+ return uses_recoverssm(self.cache_config, self.num_spec)
+
+ def get_state_shape(self) -> tuple[tuple[int, ...], ...]:
+ shapes = MambaStateShapeCalculator.gated_delta_net_state_shape(
self.tp_size,
self.num_k_heads,
self.num_v_heads,
@@ -380,6 +385,32 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
self.conv_kernel_size,
self.num_spec,
)
+ if self._uses_gdn_recoverssm():
+ # per-token replay record [HV, spec_query_len, V + K + 1] fp32
+ shapes = (
+ *shapes,
+ (
+ self.num_v_heads // self.tp_size,
+ self.num_spec + 1,
+ self.head_v_dim + self.head_k_dim + 1,
+ ),
+ )
+ return shapes
+
+ def get_state_dtype(self) -> tuple[torch.dtype, ...]:
+ dtypes = super().get_state_dtype()
+ if self._uses_gdn_recoverssm():
+ dtypes = (*dtypes, torch.float32)
+ return dtypes
+
+ def get_attn_backend(self):
+ if self._uses_gdn_recoverssm():
+ from vllm.v1.attention.backends.gdn_recoverssm import (
+ GDNRecoverSSMAttentionBackend,
+ )
+
+ return GDNRecoverSSMAttentionBackend
+ return super().get_attn_backend()
def __init__(
self,
@@ -525,6 +556,17 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
self.gdn_decode_kernel = "CPU"
self.enable_fused_gdn_decode = self.gdn_decode_kernel == "cuda"
+ # RecoverSSM verify replaces the per-draft snapshot path (Triton decode
+ # only)
+ self.use_gdn_recoverssm = self._uses_gdn_recoverssm()
+ if self.use_gdn_recoverssm:
+ self.gdn_decode_kernel = "triton"
+ self.enable_fused_gdn_decode = False
+ self.enable_packed_recurrent_decode = False
+ logger.info_once(
+ "GDN RecoverSSM speculative verify active (spec_query_len %d)",
+ self.num_spec + 1,
+ )
logger.info_once("GDN decode kernel: %s", self.gdn_decode_kernel)
compilation_config = get_current_vllm_config().compilation_config
@@ -535,7 +577,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
def _fused_gdn_decode_unsupported_reason(
self, vllm_config: VllmConfig
) -> str | None:
- conv_state_dtype, recurrent_state_dtype = self.get_state_dtype()
+ conv_state_dtype, recurrent_state_dtype = self.get_state_dtype()[:2]
if (
self.gqa_interleaved_layout
or self.head_k_dim != 128
@@ -798,6 +840,25 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
return mixed_qkv_out, z_out, b_out, a_out
+ def _recoverssm_verify_qkv(self, mixed_qkv):
+ """Q/K/V for the RecoverSSM verify.
+
+ The verify kernel reads token-strided heads, so for a 2-D [tokens, q|k|v] conv
+ output with a contiguous last dimension it gets views instead of copies. Any
+ other layout falls back to the contiguous copies of rearrange_mixed_qkv().
+ Returns (query, key, value, is_view)."""
+ if mixed_qkv.ndim != 2 or mixed_qkv.stride(-1) != 1:
+ return (*self.rearrange_mixed_qkv(mixed_qkv), False)
+ q_dim = self.key_dim // self.tp_size
+ v_dim = self.value_dim // self.tp_size
+ num_tokens = mixed_qkv.shape[0]
+ query = mixed_qkv[:, :q_dim].view(1, num_tokens, -1, self.head_k_dim)
+ key = mixed_qkv[:, q_dim : 2 * q_dim].view(1, num_tokens, -1, self.head_k_dim)
+ value = mixed_qkv[:, 2 * q_dim : 2 * q_dim + v_dim].view(
+ 1, num_tokens, -1, self.head_v_dim
+ )
+ return query, key, value, True
+
def rearrange_mixed_qkv(self, mixed_qkv):
"""Split packed qkv into contiguous (1, seq, heads, dim) tensors.
@@ -1105,7 +1166,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
dtype = qkv_or_qkvz.dtype
num_k_heads = self.num_k_heads // self.tp_size
num_v_heads = self.num_v_heads // self.tp_size
- _, state_dtype = self.get_state_dtype()
+ state_dtype = self.get_state_dtype()[1] # 3 dtypes with the replay record
# All kernels use BT = chunk_size, so a single pass with T = chunk_size
# is sufficient to populate every autotuner cache. Mirror the real
@@ -1365,7 +1426,11 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
],
num_accepted_tokens=num_accepted_tokens,
query_start_loc=spec_query_start_loc,
- max_query_len=spec_state_indices_tensor.size(-1),
+ max_query_len=(
+ self.num_spec + 1
+ if getattr(self, "use_gdn_recoverssm", False)
+ else spec_state_indices_tensor.size(-1)
+ ),
validate_data=False,
)
@@ -1402,7 +1467,17 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
else:
mixed_qkv_non_spec = None
- query_spec, key_spec, value_spec = self.rearrange_mixed_qkv(mixed_qkv_spec)
+ if (
+ spec_sequence_masks is not None
+ and mixed_qkv_spec is not None
+ and self.use_gdn_recoverssm
+ ):
+ query_spec, key_spec, value_spec, verify_views = (
+ self._recoverssm_verify_qkv(mixed_qkv_spec)
+ )
+ else:
+ verify_views = False
+ query_spec, key_spec, value_spec = self.rearrange_mixed_qkv(mixed_qkv_spec)
# Split mixed non-spec-decode+prefill to process independently
split_non_spec = (
@@ -1465,7 +1540,43 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
# 2. Recurrent attention
# 2.1: Process the multi-query part
- if spec_sequence_masks is not None:
+ if spec_sequence_masks is not None and getattr(
+ self, "use_gdn_recoverssm", False
+ ):
+ # verify the window off the checkpoint; the commit after sampling
+ # writes the state once
+ from vllm.model_executor.layers.mamba.gdn.recoverssm_gdn import (
+ gdn_recoverssm_verify,
+ )
+
+ _n = attn_metadata.num_spec_decodes
+ assert spec_query_start_loc is not None
+ assert spec_state_indices_tensor is not None
+ core_attn_out_spec = gdn_recoverssm_verify(
+ self.A_log,
+ a_spec,
+ b_spec,
+ self.dt_bias,
+ query_spec,
+ key_spec,
+ value_spec,
+ checkpoint_state=ssm_state,
+ replay_cache=self_kv_cache[2],
+ query_start_loc=spec_query_start_loc[: _n + 1],
+ state_indices=spec_state_indices_tensor[:_n, 0],
+ spec_query_len=self.num_spec + 1,
+ use_qk_l2norm_in_kernel=True,
+ # spec-only batch: write straight into the layer output
+ out=(
+ core_attn_out[:num_actual_tokens].unsqueeze(0)
+ if verify_views
+ and mixed_qkv_non_spec is None
+ and query_spec.shape[1] == num_actual_tokens
+ else None
+ ),
+ )
+ last_recurrent_state = None
+ elif spec_sequence_masks is not None:
core_attn_out_spec, last_recurrent_state = (
fused_sigmoid_gating_delta_rule_update(
A_log=self.A_log,
@@ -1584,7 +1695,8 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
merged_out.index_copy_(1, non_spec_token_indx, core_attn_out_non_spec)
core_attn_out[:num_actual_tokens] = merged_out.squeeze(0)
elif spec_sequence_masks is not None:
- core_attn_out[:num_actual_tokens] = core_attn_out_spec.squeeze(0)
+ if core_attn_out_spec.data_ptr() != core_attn_out.data_ptr():
+ core_attn_out[:num_actual_tokens] = core_attn_out_spec.squeeze(0)
else:
core_attn_out[:num_actual_tokens] = core_attn_out_non_spec.squeeze(0)
diff --git a/vllm/model_executor/layers/mamba/gdn/recoverssm_gdn.py b/vllm/model_executor/layers/mamba/gdn/recoverssm_gdn.py
new file mode 100644
index 0000000..d347bef
--- /dev/null
+++ b/vllm/model_executor/layers/mamba/gdn/recoverssm_gdn.py
@@ -0,0 +1,695 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""RecoverSSM for Gated DeltaNet (Qwen-style GDN): speculative verify from one
+checkpoint without per-draft state snapshots, and accepted-state recovery after
+sampling.
+
+GDN port of the Kimi-K3 KDA RecoverSSM path
+(vllm/models/kimi_k3/nvidia/ops/recoverssm.py). Differences from KDA: the gate is a
+scalar per value head (g = -exp(A_log) * softplus(a + dt_bias)), q/k heads are grouped
+over value heads (HV // H), and q/k are L2-normalised inside the kernel when requested.
+The verify step caches, per token, the delta-rule correction c_t = beta_t * (v_t - S'_t
+k_t) (V floats), the normalised key (K floats) and the log-decay g_t (1 float) in one
+fp32 "replay" record. Recovery folds the accepted prefix: S_n = exp(sum_t g_t) S_0 +
+sum_t exp(sum_{s>t} g_s) c_t k_t^T and writes the checkpoint once (plus the
+block-boundary state in align mode)."""
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Any
+
+import torch
+
+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 tl, triton
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
+
+
+def _require(cond: bool, msg: str) -> None:
+ recoverssm_require(cond, msg, "GDN RecoverSSM")
+
+
+@triton.jit
+def _gdn_recoverssm_verify_kernel(
+ q_ptr,
+ k_ptr,
+ v_ptr,
+ a_ptr,
+ b_ptr,
+ A_log_ptr,
+ dt_bias_ptr,
+ state_ptr,
+ replay_ptr,
+ out_ptr,
+ query_start_loc_ptr,
+ state_indices_ptr,
+ scale,
+ softplus_beta,
+ softplus_threshold,
+ null_block_id,
+ stride_q_token,
+ stride_k_token,
+ stride_v_token,
+ stride_a_token,
+ stride_b_token,
+ stride_state_block,
+ stride_state_head,
+ stride_state_v,
+ stride_state_k,
+ stride_replay_block,
+ stride_replay_head,
+ stride_replay_pos,
+ stride_replay_dim,
+ stride_out_token,
+ stride_qsl,
+ stride_si,
+ H: tl.constexpr,
+ HV: tl.constexpr,
+ K: tl.constexpr,
+ V: tl.constexpr,
+ BK: tl.constexpr,
+ BV: tl.constexpr,
+ SPEC_QUERY_LEN: tl.constexpr,
+ USE_QK_L2NORM: tl.constexpr,
+):
+ pid_v = tl.program_id(0)
+ pid_b = tl.program_id(1)
+ pid_hv = tl.program_id(2)
+ pid_h = pid_hv // (HV // H)
+ bos = tl.load(query_start_loc_ptr + pid_b * stride_qsl).to(tl.int64)
+ eos = tl.load(query_start_loc_ptr + (pid_b + 1) * stride_qsl).to(tl.int64)
+ query_len = eos - bos
+ state_idx = tl.load(state_indices_ptr + pid_b * stride_si).to(tl.int64)
+ offs_k = tl.arange(0, BK)
+ offs_v = pid_v * BV + tl.arange(0, BV)
+ mask_k = offs_k < K
+ mask_v = offs_v < V
+ mask_state = mask_v[:, None] & mask_k[None, :]
+ if state_idx <= null_block_id:
+ for t in tl.static_range(SPEC_QUERY_LEN):
+ tl.store(
+ out_ptr + (bos + t) * stride_out_token + pid_hv * V + offs_v,
+ tl.zeros([BV], dtype=tl.float32),
+ mask=(t < query_len) & mask_v,
+ )
+ return
+ state = tl.load(
+ state_ptr
+ + state_idx * stride_state_block
+ + pid_hv * stride_state_head
+ + offs_v[:, None] * stride_state_v
+ + offs_k[None, :] * stride_state_k,
+ mask=mask_state,
+ other=0.0,
+ ).to(tl.float32)
+ neg_A = -tl.exp(tl.load(A_log_ptr + pid_hv).to(tl.float32))
+ dt_bias = tl.load(dt_bias_ptr + pid_hv).to(tl.float32)
+ rec_base = (
+ replay_ptr + state_idx * stride_replay_block + pid_hv * stride_replay_head
+ )
+ for t in tl.static_range(SPEC_QUERY_LEN):
+ valid = t < query_len
+ tok = bos + t
+ q = tl.load(
+ q_ptr + tok * stride_q_token + pid_h * K + offs_k,
+ mask=valid & mask_k,
+ other=0.0,
+ ).to(tl.float32)
+ k = tl.load(
+ k_ptr + tok * stride_k_token + pid_h * K + offs_k,
+ mask=valid & mask_k,
+ other=0.0,
+ ).to(tl.float32)
+ v = tl.load(
+ v_ptr + tok * stride_v_token + pid_hv * V + offs_v,
+ mask=valid & mask_v,
+ other=0.0,
+ ).to(tl.float32)
+ x = (
+ tl.load(a_ptr + tok * stride_a_token + pid_hv, mask=valid, other=0.0).to(
+ tl.float32
+ )
+ + dt_bias
+ )
+ sp = tl.where(
+ softplus_beta * x <= softplus_threshold,
+ (1 / softplus_beta) * tl.log(1 + tl.exp(softplus_beta * x)),
+ x,
+ )
+ g = neg_A * sp
+ beta = tl.sigmoid(
+ tl.load(b_ptr + tok * stride_b_token + pid_hv, mask=valid, other=0.0).to(
+ tl.float32
+ )
+ )
+ if USE_QK_L2NORM:
+ q = q * tl.rsqrt(tl.sum(q * q) + 1e-6)
+ k = k * tl.rsqrt(tl.sum(k * k) + 1e-6)
+ q = q * scale
+ new_state = state * tl.exp(g)
+ corr = (v - tl.sum(new_state * k[None, :], 1)) * beta
+ new_state = new_state + corr[:, None] * k[None, :]
+ state = tl.where(valid, new_state, state)
+ o = tl.sum(state * q[None, :], 1)
+ tl.store(
+ out_ptr + tok * stride_out_token + pid_hv * V + offs_v,
+ o.to(out_ptr.dtype.element_ty),
+ mask=valid & mask_v,
+ )
+ rec = rec_base + t * stride_replay_pos
+ tl.store(rec + offs_v * stride_replay_dim, corr, mask=valid & mask_v)
+ if pid_v == 0:
+ tl.store(rec + (V + offs_k) * stride_replay_dim, k, mask=valid & mask_k)
+ tl.store(rec + (V + K) * stride_replay_dim, g, mask=valid)
+
+
+def gdn_recoverssm_verify(
+ A_log: torch.Tensor,
+ a: torch.Tensor,
+ b: torch.Tensor,
+ dt_bias: torch.Tensor,
+ q: torch.Tensor,
+ k: torch.Tensor,
+ v: torch.Tensor,
+ *,
+ checkpoint_state: torch.Tensor,
+ replay_cache: torch.Tensor,
+ query_start_loc: torch.Tensor,
+ state_indices: torch.Tensor,
+ spec_query_len: int,
+ scale: float | None = None,
+ softplus_beta: float = 1.0,
+ softplus_threshold: float = 20.0,
+ use_qk_l2norm_in_kernel: bool = True,
+ out: torch.Tensor | None = None,
+) -> torch.Tensor:
+ """Verify a GDN speculative window against its checkpoint without modifying the
+ checkpoint. q, k: [1, T, H, K]; v: [1, T, HV, V]; a, b: [T, HV] (or [1, T, HV]);
+ checkpoint_state: [blocks, HV, V, K]; replay_cache: [blocks, HV, spec_query_len, V +
+ K + 1] fp32. Returns out [1, T, HV, V]."""
+ _require(
+ q.ndim == 4 and q.shape[0] == 1, "q must have shape [1, tokens, heads, dim]"
+ )
+ _, total, H, K = q.shape
+ _require(k.shape == q.shape, "q and k shapes differ")
+ _require(
+ v.ndim == 4 and tuple(v.shape[:2]) == (1, total),
+ "v must have shape [1, tokens, value heads, dim]",
+ )
+ HV, V = v.shape[-2], v.shape[-1]
+ _require(
+ H > 0 and HV >= H and HV % H == 0,
+ "value heads must be a positive multiple of the q/k heads",
+ )
+ _require(
+ a.numel() == total * HV and b.numel() == total * HV,
+ "gate or beta shape is incompatible",
+ )
+ a2 = a.reshape(total, HV)
+ b2 = b.reshape(total, HV)
+ _require(
+ a2.stride(1) == 1 and b2.stride(1) == 1,
+ "gate and beta heads must be contiguous",
+ )
+ _require(
+ tuple(A_log.shape) == (HV,) and tuple(dt_bias.shape) == (HV,),
+ "A_log or dt_bias shape is incompatible",
+ )
+ _require(
+ all(t_.stride(-1) == 1 and t_.stride(-2) == K for t_ in (q, k)),
+ "q and k heads must be contiguous",
+ )
+ _require(v.stride(-1) == 1 and v.stride(-2) == V, "v heads must be contiguous")
+ _require(checkpoint_state.ndim == 4, "checkpoint must be four-dimensional")
+ nb = checkpoint_state.shape[0]
+ _require(
+ tuple(checkpoint_state.shape[1:]) == (HV, V, K),
+ "checkpoint shape is incompatible",
+ )
+ _rec = (nb, HV, spec_query_len, V + K + 1)
+ _require(tuple(replay_cache.shape) == _rec, f"replay buffer needs shape {_rec}")
+ _require(replay_cache.dtype == torch.float32, "replay buffer must use float32")
+ _require(state_indices.ndim == 1, "state indices must be one-dimensional")
+ _require(
+ query_start_loc.ndim == 1
+ and query_start_loc.shape[0] == state_indices.shape[0] + 1,
+ "query metadata is incompatible",
+ )
+ _require(
+ total <= state_indices.shape[0] * spec_query_len,
+ "speculative decode input exceeds its activation capacity",
+ )
+ _dev = q.device
+ _require(
+ all(
+ t_.device == _dev
+ for t_ in (
+ k,
+ v,
+ a,
+ b,
+ A_log,
+ dt_bias,
+ checkpoint_state,
+ replay_cache,
+ query_start_loc,
+ state_indices,
+ )
+ if t_ is not None
+ )
+ and (out is None or out.device == _dev),
+ "inputs must be on the same device",
+ )
+ if scale is None:
+ scale = K**-0.5
+ if out is None:
+ out = torch.empty(1, total, HV, V, dtype=v.dtype, device=v.device)
+ _require(
+ tuple(out.shape) == (1, total, HV, V) and out.stride()[2:] == (V, 1),
+ "output shape or layout is incompatible",
+ )
+ batch = state_indices.shape[0]
+ if total == 0 or batch == 0:
+ return out
+ BK = triton.next_power_of_2(K)
+ BV = min(triton.next_power_of_2(V), 32)
+ grid = (triton.cdiv(V, BV), batch, HV)
+ _gdn_recoverssm_verify_kernel[grid](
+ q,
+ k,
+ v,
+ a2,
+ b2,
+ A_log,
+ dt_bias,
+ checkpoint_state,
+ replay_cache,
+ out,
+ query_start_loc,
+ state_indices,
+ scale,
+ softplus_beta,
+ softplus_threshold,
+ NULL_BLOCK_ID,
+ q.stride(1),
+ k.stride(1),
+ v.stride(1),
+ a2.stride(0),
+ b2.stride(0),
+ checkpoint_state.stride(0),
+ checkpoint_state.stride(1),
+ checkpoint_state.stride(2),
+ checkpoint_state.stride(3),
+ replay_cache.stride(0),
+ replay_cache.stride(1),
+ replay_cache.stride(2),
+ replay_cache.stride(3),
+ out.stride(1),
+ query_start_loc.stride(0),
+ state_indices.stride(0),
+ H=H,
+ HV=HV,
+ K=K,
+ V=V,
+ BK=BK,
+ BV=BV,
+ SPEC_QUERY_LEN=spec_query_len,
+ USE_QK_L2NORM=use_qk_l2norm_in_kernel,
+ num_warps=4,
+ num_stages=2,
+ )
+ return out
+
+
+@triton.jit
+def _commit_gdn_state_kernel(
+ state_ref_ptr,
+ state_base_addrs_ptr,
+ state_block_strides_ptr,
+ replay_ref_ptr,
+ replay_base_addrs_ptr,
+ replay_block_strides_ptr,
+ state_indices_ptr,
+ commit_lens_ptr,
+ final_state_indices_ptr,
+ boundary_state_indices_ptr,
+ boundary_recovery_lens_ptr,
+ null_block_id,
+ stride_state_head,
+ stride_state_v,
+ stride_state_k,
+ stride_replay_head,
+ stride_replay_pos,
+ stride_replay_dim,
+ stride_si,
+ K: tl.constexpr,
+ V: tl.constexpr,
+ BK: tl.constexpr,
+ BV: tl.constexpr,
+ NUM_HEADS: tl.constexpr,
+ ALIGN_MODE: tl.constexpr,
+):
+ pid_v = tl.program_id(0)
+ pid_b = tl.program_id(1)
+ pid_lh = tl.program_id(2)
+ pid_l = pid_lh // NUM_HEADS
+ pid_h = pid_lh % NUM_HEADS
+ src = tl.load(state_indices_ptr + pid_b * stride_si).to(tl.int64)
+ if src <= null_block_id:
+ return
+ n = tl.load(commit_lens_ptr + pid_b)
+ if n == 0:
+ return
+ final_idx = tl.load(final_state_indices_ptr + pid_b).to(tl.int64)
+ boundary_idx = tl.load(boundary_state_indices_ptr + pid_b).to(tl.int64)
+ boundary_len = tl.load(boundary_recovery_lens_ptr + pid_b)
+ if final_idx <= null_block_id:
+ return
+ state_ptr = tl.load(state_base_addrs_ptr + pid_l).to(
+ tl.pointer_type(state_ref_ptr.dtype.element_ty)
+ )
+ sbs = tl.load(state_block_strides_ptr + pid_l)
+ replay_ptr = tl.load(replay_base_addrs_ptr + pid_l).to(
+ tl.pointer_type(replay_ref_ptr.dtype.element_ty)
+ )
+ rbs = tl.load(replay_block_strides_ptr + pid_l)
+ rec_base = replay_ptr + src * rbs + pid_h * stride_replay_head
+ offs_k = tl.arange(0, BK)
+ offs_v = pid_v * BV + tl.arange(0, BV)
+ mask_k = offs_k < K
+ mask_v = offs_v < V
+ mask_state = mask_v[:, None] & mask_k[None, :]
+ sp = offs_v[:, None] * stride_state_v + offs_k[None, :] * stride_state_k
+ s0 = tl.load(
+ state_ptr + src * sbs + pid_h * stride_state_head + sp,
+ mask=mask_state,
+ other=0.0,
+ ).to(tl.float32)
+ fin_decay = 1.0
+ fin_corr = tl.zeros([BV, BK], tl.float32)
+ bnd_decay = 1.0
+ bnd_corr = tl.zeros([BV, BK], tl.float32)
+ for r in range(n):
+ t = n - r - 1
+ rec = rec_base + t * stride_replay_pos
+ c = tl.load(rec + offs_v * stride_replay_dim, mask=mask_v, other=0.0)
+ kk = tl.load(rec + (V + offs_k) * stride_replay_dim, mask=mask_k, other=0.0)
+ g = tl.load(rec + (V + K) * stride_replay_dim)
+ upd = c[:, None] * kk[None, :]
+ dec = tl.exp(g)
+ fin_corr += upd * fin_decay
+ fin_decay *= dec
+ if ALIGN_MODE:
+ before = t < boundary_len
+ bnd_corr += tl.where(before, upd * bnd_decay, 0.0)
+ bnd_decay *= tl.where(before, dec, 1.0)
+ if ALIGN_MODE:
+ bs = s0 * bnd_decay + bnd_corr
+ tl.store(
+ state_ptr + boundary_idx * sbs + pid_h * stride_state_head + sp,
+ bs.to(state_ref_ptr.dtype.element_ty),
+ mask=mask_state & (boundary_idx > null_block_id),
+ )
+ fs = s0 * fin_decay + fin_corr
+ tl.store(
+ state_ptr + final_idx * sbs + pid_h * stride_state_head + sp,
+ fs.to(state_ref_ptr.dtype.element_ty),
+ mask=mask_state,
+ )
+
+
+@dataclass
+class GDNRecoverSSMCommitContext:
+ conv_states: tuple[torch.Tensor, ...]
+ conv_state_base_addrs: torch.Tensor
+ conv_state_block_strides: torch.Tensor
+ conv_state_dim_strides: torch.Tensor
+ conv_state_token_strides: torch.Tensor
+ conv_history_len: int
+ checkpoints: tuple[torch.Tensor, ...]
+ state_base_addrs: torch.Tensor
+ state_block_strides: torch.Tensor
+ replays: tuple[torch.Tensor, ...]
+ replay_base_addrs: torch.Tensor
+ replay_block_strides: torch.Tensor
+ commit_lens: torch.Tensor
+ final_state_indices: torch.Tensor
+ boundary_state_indices: torch.Tensor
+ boundary_recovery_lens: torch.Tensor
+ spec_query_len: int
+
+ @classmethod
+ def from_tensors(
+ cls,
+ conv_states: Sequence[torch.Tensor],
+ checkpoints: Sequence[torch.Tensor],
+ replays: Sequence[torch.Tensor],
+ *,
+ spec_query_len: int,
+ max_num_reqs: int,
+ conv_dim_first: bool = True,
+ ) -> "GDNRecoverSSMCommitContext":
+ _require(len(checkpoints) > 0, "commit requires at least one layer")
+ _require(
+ len(conv_states) == len(checkpoints) == len(replays),
+ "conv, state and replay lists differ",
+ )
+ if not conv_dim_first:
+ conv_states = [s.transpose(-1, -2) for s in conv_states]
+ ref = checkpoints[0]
+ _require(ref.ndim == 4, "checkpoint must be four-dimensional")
+ nb, HV, V, K = ref.shape
+ _dev = ref.device
+ for s in checkpoints:
+ _require(
+ s.shape == ref.shape
+ and s.dtype == ref.dtype
+ and s.stride()[1:] == ref.stride()[1:]
+ and s.device == _dev,
+ "layers need matching checkpoints",
+ )
+ _rec = (nb, HV, spec_query_len, V + K + 1)
+ for r in replays:
+ _require(
+ tuple(r.shape) == _rec
+ and r.dtype == torch.float32
+ and r.stride()[1:] == replays[0].stride()[1:]
+ and r.device == _dev,
+ f"layers need matching float32 replay buffers of shape {_rec}",
+ )
+ conv_ref = conv_states[0]
+ _require(
+ conv_ref.ndim == 3 and conv_ref.shape[0] == nb,
+ "conv state must be [blocks, dim, window] with the checkpoint's "
+ "block count",
+ )
+ for c in conv_states:
+ _require(
+ c.shape == conv_ref.shape
+ and c.dtype == conv_ref.dtype
+ and c.stride()[1:] == conv_ref.stride()[1:]
+ and c.device == _dev,
+ "layers need matching conv states",
+ )
+ conv_dim, conv_len = conv_ref.shape[1:]
+ hist = conv_len - spec_query_len + 1
+ _require(hist > 0, "conv state is shorter than its window")
+ dev = ref.device
+ addr = lambda ts: torch.tensor(
+ [t.data_ptr() for t in ts], dtype=torch.int64, device=dev
+ )
+ bstr = lambda ts: torch.tensor(
+ [t.stride(0) for t in ts], dtype=torch.int64, device=dev
+ )
+ z = lambda: torch.empty(max_num_reqs, dtype=torch.int32, device=dev)
+ return cls(
+ tuple(conv_states),
+ addr(conv_states),
+ bstr(conv_states),
+ torch.tensor(
+ [s.stride(1) for s in conv_states], dtype=torch.int64, device=dev
+ ),
+ torch.tensor(
+ [s.stride(2) for s in conv_states], dtype=torch.int64, device=dev
+ ),
+ hist,
+ tuple(checkpoints),
+ addr(checkpoints),
+ bstr(checkpoints),
+ tuple(replays),
+ addr(replays),
+ bstr(replays),
+ z(),
+ z(),
+ z(),
+ z(),
+ spec_query_len,
+ )
+
+ @classmethod
+ def create(
+ cls, layers: Sequence[Any], *, spec_query_len: int, max_num_reqs: int
+ ) -> "GDNRecoverSSMCommitContext":
+ from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
+
+ if any(len(layer.kv_cache) != 3 for layer in layers):
+ raise ValueError("GDN RecoverSSM pages must contain conv, state and replay")
+ return cls.from_tensors(
+ [layer.kv_cache[0] for layer in layers],
+ [layer.kv_cache[1] for layer in layers],
+ [layer.kv_cache[2] for layer in layers],
+ spec_query_len=spec_query_len,
+ max_num_reqs=max_num_reqs,
+ conv_dim_first=is_conv_state_dim_first(),
+ )
+
+ def commit(
+ self,
+ num_accepted_tokens: torch.Tensor,
+ state_indices: torch.Tensor,
+ query_start_loc: torch.Tensor,
+ request_indices: torch.Tensor | None = None,
+ block_table: torch.Tensor | None = None,
+ num_computed_tokens: torch.Tensor | None = None,
+ mamba_block_size: int | None = None,
+ commit_conv: bool = True,
+ ) -> None:
+ """Fold accepted GDN and convolution inputs into every layer's checkpoint."""
+ 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], "commit batch exceeds its plan capacity"
+ )
+ _require(
+ query_start_loc.ndim == 1 and query_start_loc.shape[0] == batch + 1,
+ "commit metadata is incompatible",
+ )
+ _require(
+ request_indices is None or request_indices.shape[0] >= batch,
+ "request mapping is too short",
+ )
+ _require(
+ num_accepted_tokens.ndim == 1
+ and (request_indices is not None or num_accepted_tokens.shape[0] >= batch),
+ "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),
+ "align metadata is incomplete",
+ )
+ _require(
+ mamba_block_size is None or mamba_block_size >= self.spec_query_len,
+ "align block size must cover one speculative window",
+ )
+ _require(
+ block_table is None or block_table.ndim == 2,
+ "block table must be two-dimensional",
+ )
+ _dev = self.checkpoints[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,
+ )
+ ),
+ "commit inputs must be on the same device",
+ )
+ bt_stride = (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_state_indices,
+ self.boundary_state_indices,
+ self.boundary_recovery_lens,
+ 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_stride[0],
+ bt_stride[1],
+ 0 if num_computed_tokens is None else num_computed_tokens.stride(0),
+ SPEC_QUERY_LEN=self.spec_query_len,
+ num_warps=1,
+ )
+ num_layers = len(self.checkpoints)
+ if commit_conv:
+ conv_ref = self.conv_states[0]
+ conv_dim = conv_ref.shape[1]
+ compact_conv_state_kernel[(triton.cdiv(conv_dim, 256), batch, num_layers)](
+ conv_ref,
+ self.conv_state_base_addrs,
+ self.conv_state_block_strides,
+ self.conv_state_dim_strides,
+ self.conv_state_token_strides,
+ state_indices,
+ self.commit_lens,
+ self.final_state_indices,
+ self.boundary_state_indices,
+ self.boundary_recovery_lens,
+ NULL_BLOCK_ID,
+ conv_dim,
+ self.conv_history_len,
+ state_indices.stride(0),
+ BLOCK_D=256,
+ BLOCK_HISTORY=triton.next_power_of_2(self.conv_history_len),
+ ALIGN_MODE=block_table is not None,
+ num_warps=4,
+ )
+ ref = self.checkpoints[0]
+ _, HV, V, K = ref.shape
+ BK = triton.next_power_of_2(K)
+ BV = min(triton.next_power_of_2(V), 32)
+ rr = self.replays[0]
+ _commit_gdn_state_kernel[(triton.cdiv(V, BV), batch, num_layers * HV)](
+ ref,
+ self.state_base_addrs,
+ self.state_block_strides,
+ rr,
+ self.replay_base_addrs,
+ self.replay_block_strides,
+ state_indices,
+ self.commit_lens,
+ self.final_state_indices,
+ self.boundary_state_indices,
+ self.boundary_recovery_lens,
+ NULL_BLOCK_ID,
+ ref.stride(1),
+ ref.stride(2),
+ ref.stride(3),
+ rr.stride(1),
+ rr.stride(2),
+ rr.stride(3),
+ state_indices.stride(0),
+ K=K,
+ V=V,
+ BK=BK,
+ BV=BV,
+ NUM_HEADS=HV,
+ ALIGN_MODE=block_table is not None,
+ num_warps=4,
+ num_stages=2,
+ )
+
+
+__all__ = ["GDNRecoverSSMCommitContext", "gdn_recoverssm_verify"]
diff --git a/vllm/model_executor/models/interfaces.py b/vllm/model_executor/models/interfaces.py
index 2e79001..90f7714 100644
--- a/vllm/model_executor/models/interfaces.py
+++ b/vllm/model_executor/models/interfaces.py
@@ -85,6 +85,7 @@ MambaStateShapes: TypeAlias = (
| tuple[tuple[int, int, int]]
| tuple[tuple[int, int], tuple[int, int]]
| tuple[tuple[int, int], tuple[int, int, int]]
+ | tuple[tuple[int, int], tuple[int, int, int], tuple[int, int, int]]
| tuple[
tuple[int, int],
tuple[int, int, int],
diff --git a/vllm/models/qwen4_exp/nvidia/model.py b/vllm/models/qwen4_exp/nvidia/model.py
index 2b99063..fe416d4 100644
--- a/vllm/models/qwen4_exp/nvidia/model.py
+++ b/vllm/models/qwen4_exp/nvidia/model.py
@@ -37,11 +37,13 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
from vllm.model_executor.models.interfaces import (
HasInnerState,
IsHybrid,
+ MambaStateShapes,
MixtureOfExperts,
MultiModalEmbeddings,
SupportsLoRA,
SupportsMRoPE,
SupportsPP,
+ SupportsReplaySSM,
_require_is_multimodal,
)
from vllm.model_executor.models.qwen3_5 import (
@@ -791,6 +793,7 @@ class Qwen4ExpForCausalLM(
SupportsPP,
Qwen4ExpMixtureOfExperts,
IsHybrid,
+ SupportsReplaySSM,
):
packed_modules_mapping = {
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
@@ -911,20 +914,38 @@ class Qwen4ExpForCausalLM(
num_spec=num_spec,
)
+ @classmethod
+ def _uses_gdn_recoverssm(cls, vllm_config: VllmConfig) -> bool:
+ """One predicate for the replay record's shape AND dtype (they must
+ agree for the MambaSpec)."""
+ num_spec = (
+ vllm_config.speculative_config.num_speculative_tokens
+ if vllm_config.speculative_config
+ else 0
+ )
+ from vllm.model_executor.layers.mamba.recoverssm_utils import uses_recoverssm
+
+ return uses_recoverssm(vllm_config.cache_config, num_spec or 0)
+
@classmethod
def get_gdn_mamba_state_dtype_from_config(
cls, vllm_config: VllmConfig
- ) -> tuple[torch.dtype, torch.dtype]:
- return MambaStateDtypeCalculator.gated_delta_net_state_dtype(
- vllm_config.model_config.dtype,
- vllm_config.cache_config.mamba_cache_dtype,
- vllm_config.cache_config.mamba_ssm_cache_dtype,
+ ) -> tuple[torch.dtype, ...]:
+ dtypes: tuple[torch.dtype, ...] = (
+ MambaStateDtypeCalculator.gated_delta_net_state_dtype(
+ vllm_config.model_config.dtype,
+ vllm_config.cache_config.mamba_cache_dtype,
+ vllm_config.cache_config.mamba_ssm_cache_dtype,
+ )
)
+ if cls._uses_gdn_recoverssm(vllm_config): # replay record, fp32
+ dtypes = (*dtypes, torch.float32)
+ return dtypes
@classmethod
def get_gdn_mamba_state_shape_from_config(
cls, vllm_config: VllmConfig
- ) -> tuple[tuple[int, int], tuple[int, int]]:
+ ) -> MambaStateShapes:
parallel_config = vllm_config.parallel_config
hf_config = vllm_config.model_config.hf_text_config
tp_size = parallel_config.tensor_parallel_size
@@ -933,7 +954,7 @@ class Qwen4ExpForCausalLM(
if vllm_config.speculative_config
else 0
)
- return MambaStateShapeCalculator.gated_delta_net_state_shape(
+ shapes = MambaStateShapeCalculator.gated_delta_net_state_shape(
tp_size,
hf_config.linear_num_key_heads,
hf_config.linear_num_value_heads,
@@ -942,18 +963,30 @@ class Qwen4ExpForCausalLM(
hf_config.linear_conv_kernel_dim,
num_spec,
)
+ if cls._uses_gdn_recoverssm(
+ vllm_config
+ ): # replay record [HV, spec_query_len, V + K + 1]
+ shapes = (
+ *shapes,
+ (
+ hf_config.linear_num_value_heads // tp_size,
+ num_spec + 1,
+ hf_config.linear_value_head_dim + hf_config.linear_key_head_dim + 1,
+ ),
+ )
+ return shapes
@classmethod
def get_mamba_state_dtype_from_config(
cls,
vllm_config: VllmConfig,
- ) -> tuple[torch.dtype, torch.dtype]:
+ ) -> tuple[torch.dtype, ...]:
return cls.get_gdn_mamba_state_dtype_from_config(vllm_config)
@classmethod
def get_mamba_state_shape_from_config(
cls, vllm_config: VllmConfig
- ) -> tuple[tuple[int, int], tuple[int, int]]:
+ ) -> MambaStateShapes:
return cls.get_gdn_mamba_state_shape_from_config(vllm_config)
@classmethod
@@ -1043,6 +1076,7 @@ class Qwen4ExpForConditionalGeneration(
Qwen3_5ForConditionalGeneration,
HasInnerState,
Qwen4ExpMixtureOfExperts,
+ SupportsReplaySSM,
):
"""Qwen3-VL vision tower backed by the Qwen4Exp language model."""
@@ -1252,14 +1286,15 @@ class Qwen4ExpForConditionalGeneration(
def get_mamba_state_dtype_from_config(
cls,
vllm_config: VllmConfig,
- ) -> tuple[torch.dtype, torch.dtype]:
+ ) -> tuple[torch.dtype, ...]:
return Qwen4ExpForCausalLM.get_mamba_state_dtype_from_config(vllm_config)
@classmethod
- def get_mamba_state_shape_from_config(
+ def get_mamba_state_shape_from_config( # type: ignore[override]
cls,
vllm_config: VllmConfig,
- ) -> tuple[tuple[int, int], tuple[int, int]]:
+ ) -> MambaStateShapes:
+ # GDN RecoverSSM adds a third state (the replay record) to Qwen3.5's two.
return Qwen4ExpForCausalLM.get_mamba_state_shape_from_config(vllm_config)
@classmethod
diff --git a/vllm/models/qwen4_exp/nvidia/mtp.py b/vllm/models/qwen4_exp/nvidia/mtp.py
index 5640474..76c0d7c 100644
--- a/vllm/models/qwen4_exp/nvidia/mtp.py
+++ b/vllm/models/qwen4_exp/nvidia/mtp.py
@@ -35,7 +35,7 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
should_use_host_embed_table,
)
from vllm.model_executor.model_loader.utils import configure_quant_config
-from vllm.model_executor.models.interfaces import SupportsPP
+from vllm.model_executor.models.interfaces import SupportsPP, SupportsReplaySSM
from vllm.model_executor.models.qwen3_5 import Qwen3_5Model
from vllm.model_executor.models.utils import (
AutoWeightsLoader,
@@ -408,7 +408,9 @@ class Qwen4ExpMultiTokenPredictor(nn.Module):
"hidden_states": 0,
}
)
-class Qwen4ExpMTP(nn.Module, SupportsPP, Qwen4ExpMixtureOfExperts):
+class Qwen4ExpMTP(
+ nn.Module, SupportsPP, Qwen4ExpMixtureOfExperts, SupportsReplaySSM
+):
packed_modules_mapping = {
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
"gate_up_proj": ["gate_proj", "up_proj"],
diff --git a/vllm/models/qwen4_exp/nvidia/ple_layer.py b/vllm/models/qwen4_exp/nvidia/ple_layer.py
index 4925e03..af8f528 100644
--- a/vllm/models/qwen4_exp/nvidia/ple_layer.py
+++ b/vllm/models/qwen4_exp/nvidia/ple_layer.py
@@ -203,6 +203,14 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
return True
def get_attn_backend(self) -> type[PleShortConvAttentionBackend]:
+ from vllm.model_executor.layers.mamba.recoverssm_utils import uses_recoverssm
+
+ if uses_recoverssm(self.cache_config, self.num_spec_tokens):
+ from vllm.v1.attention.backends.ple_recoverssm import (
+ PleRecoverSSMAttentionBackend,
+ )
+
+ return PleRecoverSSMAttentionBackend
return PleShortConvAttentionBackend
def get_state_dtype(self) -> tuple[torch.dtype, ...]:
diff --git a/vllm/v1/attention/backends/gdn_recoverssm.py b/vllm/v1/attention/backends/gdn_recoverssm.py
new file mode 100644
index 0000000..a45142a
--- /dev/null
+++ b/vllm/v1/attention/backends/gdn_recoverssm.py
@@ -0,0 +1,181 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""GDN attention metadata for RecoverSSM speculative decode (Qwen-style Gated DeltaNet).
+
+Wraps the generic GDN builder: every active decode row runs through the spec path (a
+draft-less step is a T=1 window), the spec state indices keep only the checkpoint
+column, num_accepted_tokens is always 1 (the checkpoint and the compacted conv window
+already hold the accepted state), and the metadata carries what the post-sampling commit
+needs. Mirrors the Kimi-K3 KDA builder's RecoverSSM handling."""
+
+from dataclasses import dataclass, field, fields
+from typing import Any
+
+import torch
+
+from vllm.logger import init_logger
+from vllm.v1.attention.backend import AttentionCGSupport, CommonAttentionMetadata
+from vllm.v1.attention.backends.gdn_attn import (
+ GDNAttentionBackend,
+ GDNAttentionMetadata,
+ GDNAttentionMetadataBuilder,
+)
+from vllm.v1.attention.backends.recoverssm_metadata import (
+ RecoverSSMMetadata,
+ RecoverSSMPostprocessMetadata,
+ recoverssm_request_indices,
+ recoverssm_spec_rows,
+)
+
+logger = init_logger(__name__)
+
+
+@dataclass
+class GDNRecoverSSMCommitMetadata:
+ state_indices: torch.Tensor # [num_spec_decodes, 1]
+ query_start_loc: torch.Tensor # [num_spec_decodes + 1]
+ request_indices: (
+ torch.Tensor | None
+ ) # batch rows of the spec requests (None: rows 0..n-1)
+ block_table: torch.Tensor | None # align mode
+ num_computed_tokens: torch.Tensor | None
+ block_size: int | None
+
+
+@dataclass
+class GDNRecoverSSMMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
+ recoverssm_commit: GDNRecoverSSMCommitMetadata | None = None
+ recoverssm_context: Any = field(default=None, repr=False, compare=False)
+
+ def commit_recoverssm_state(
+ self, num_accepted_tokens: torch.Tensor
+ ) -> RecoverSSMPostprocessMetadata | None:
+ 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, 0],
+ 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 GDNRecoverSSMMetadataBuilder(GDNAttentionMetadataBuilder):
+ # The verify/commit metadata is not staged for FULL graph replay yet; declaring
+ # NEVER makes the runner fall back to PIECEWISE (attention compiled piecewise).
+ _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)
+ # The builder may be created before the runner resolves the CUDA graph mode.
+ self.use_full_cuda_graph = False
+ max_reqs = vllm_config.scheduler_config.max_num_seqs
+ self._ones = torch.ones(max_reqs, dtype=torch.int32, device=device)
+ self._context: Any = None
+ self._logged = False
+
+ def _get_context(self):
+ if self._context is None:
+ from vllm.model_executor.layers.mamba.gdn.recoverssm_gdn import (
+ GDNRecoverSSMCommitContext,
+ )
+
+ fc = self.vllm_config.compilation_config.static_forward_context
+ layers = [fc[name] for name in self.layer_names]
+ self._context = GDNRecoverSSMCommitContext.create(
+ layers,
+ spec_query_len=1 + self.num_spec,
+ max_num_reqs=self.vllm_config.scheduler_config.max_num_seqs,
+ )
+ logger.info(
+ "GDN RecoverSSM commit context: %d layers, spec_query_len %d",
+ len(layers),
+ 1 + self.num_spec,
+ )
+ return self._context
+
+ def build( # type: ignore[override]
+ self,
+ common_prefix_len: int,
+ common_attn_metadata: CommonAttentionMetadata,
+ num_accepted_tokens: torch.Tensor | None = None,
+ num_decode_draft_tokens_cpu: torch.Tensor | None = None,
+ fast_build: bool = False,
+ ) -> "GDNRecoverSSMMetadata":
+ 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("GDN RecoverSSM needs the CPU is_prefilling mask")
+ # Positive counts only defeat the base builder's "no drafts -> regular
+ # decode" shortcut: RecoverSSM keeps every post-prefill row on the spec path
+ # (extended conv window).
+ 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,
+ num_accepted_tokens=num_accepted_tokens,
+ num_decode_draft_tokens_cpu=num_decode_draft_tokens_cpu,
+ fast_build=fast_build,
+ )
+ base = {f.name: getattr(meta, f.name) for f in fields(meta)}
+ commit = None
+ if meta.num_spec_decodes > 0:
+ n = meta.num_spec_decodes
+ assert meta.spec_state_indices_tensor is not None
+ base["spec_state_indices_tensor"] = meta.spec_state_indices_tensor[:, :1]
+ base["num_accepted_tokens"] = self._ones[:n]
+ if rows is None:
+ raise ValueError("GDN RecoverSSM: spec rows without draft counts")
+ request_indices = recoverssm_request_indices(
+ rows, n, m.query_start_loc.device
+ )
+ align = self.vllm_config.cache_config.mamba_cache_mode == "align"
+ commit = GDNRecoverSSMCommitMetadata(
+ state_indices=base["spec_state_indices_tensor"],
+ query_start_loc=meta.spec_query_start_loc,
+ request_indices=request_indices,
+ 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(
+ "GDN RecoverSSM path taken: %d spec rows, align=%s", n, align
+ )
+ return GDNRecoverSSMMetadata(
+ **base,
+ recoverssm_commit=commit,
+ recoverssm_context=self._get_context() if commit is not None else None,
+ )
+
+
+class GDNRecoverSSMAttentionBackend(GDNAttentionBackend):
+ @staticmethod
+ def get_name() -> str:
+ return "GDN_RECOVERSSM"
+
+ @staticmethod
+ def get_builder_cls() -> type[GDNRecoverSSMMetadataBuilder]:
+ return GDNRecoverSSMMetadataBuilder
diff --git a/vllm/v1/attention/backends/mamba_attn.py b/vllm/v1/attention/backends/mamba_attn.py
index 817773a..29352b3 100644
--- a/vllm/v1/attention/backends/mamba_attn.py
+++ b/vllm/v1/attention/backends/mamba_attn.py
@@ -110,7 +110,12 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
self.compilation_config = vllm_config.compilation_config
self.num_spec_tokens: int = vllm_config.num_speculative_tokens
self.use_spec_decode = self.num_spec_tokens > 0
- self.use_replayssm = vllm_config.cache_config.use_replayssm
+ # RecoverSSM also rides on --use-replayssm but keeps its own metadata; the
+ # Mamba2 ReplaySSM ring-buffer workspaces below are not used then.
+ self.use_replayssm = (
+ vllm_config.cache_config.use_replayssm
+ and not vllm_config.cache_config.use_recoverssm
+ )
self.replayssm_buffer_len = vllm_config.cache_config.replayssm_buffer_len
self.use_flashinfer_replayssm = (
self.use_replayssm
diff --git a/vllm/v1/attention/backends/ple_recoverssm.py b/vllm/v1/attention/backends/ple_recoverssm.py
new file mode 100644
index 0000000..7402001
--- /dev/null
+++ b/vllm/v1/attention/backends/ple_recoverssm.py
@@ -0,0 +1,326 @@
+# 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
--
2.43.0