[Kernel] ReplaySSM: cache SSM inputs for faster Mamba2 standard decode (#48018)

Signed-off-by: Johnny-Liou <a897111@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: tomeras91 <57313761+tomeras91@users.noreply.github.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>

Johnny-Liou committed Jul 24, 2026 at 12:39 UTC 866fea2b9900bf49d552c205d2eaac4716fb63ac
23 files changed +2954 -31
benchmarks/replayssm/e2e_decode_speedup.py new
+267
@@ -0,0 +1,267 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""End-to-end autoregressive decode benchmark: ReplaySSM vs the standard SSM kernel.
4 +
5 +Loads a hybrid Mamba2 model, replicates one prompt across the batch, and times a
6 +long greedy decode (CUDA graphs on) once with the standard kernel and once with
7 +ReplaySSM, then reports the per-step / throughput speedup. The two modes run in
8 +separate subprocesses so each gets a clean CUDA context.
9 +
10 +The FlashInfer FP4-MoE autotuner is disabled by default (it is unstable under
11 +CUDA-graph capture on the pre-release Blackwell FP4 path); pass
12 +--no-disable-flashinfer-autotune for non-FP4 models.
13 +
14 +Examples:
15 + python e2e_decode_speedup.py --model-id nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
16 + python e2e_decode_speedup.py --dtype auto --buffer-len 16 \
17 + --model-id nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 # B300 NVFP4
18 +"""
19 +
20 +import argparse
21 +import json
22 +import os
23 +import subprocess
24 +import sys
25 +import time
26 +
27 +DEFAULT_PROMPT = "My cat wrote all this CUDA code for a new language model and"
28 +
29 +MODE_LABEL = {"standard": "standard", "replayssm": "ReplaySSM"}
30 +
31 +
32 +def parse_args():
33 + p = argparse.ArgumentParser(
34 + description="E2E decode speedup: ReplaySSM vs the standard SSM kernel."
35 + )
36 + p.add_argument("--model-id", default="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
37 + p.add_argument("--prompt", default=DEFAULT_PROMPT)
38 + p.add_argument("--batch-size", type=int, default=256)
39 + p.add_argument("--num-steps", type=int, default=1000)
40 + p.add_argument("--warmup-steps", type=int, default=128)
41 + p.add_argument("--repeats", type=int, default=1)
42 + p.add_argument(
43 + "--buffer-len", type=int, default=16, help="ReplaySSM input-buffer length."
44 + )
45 + p.add_argument(
46 + "--dtype",
47 + default="bfloat16",
48 + choices=["bfloat16", "float16", "float32", "auto"],
49 + )
50 + p.add_argument("--gpu-memory-utilization", type=float, default=0.9)
51 + p.add_argument("--max-model-len", type=int, default=None)
52 + p.add_argument(
53 + "--disable-flashinfer-autotune",
54 + action=argparse.BooleanOptionalAction,
55 + default=True,
56 + help="Disable the FlashInfer FP4-MoE autotuner (default: on). "
57 + "It is unstable under CUDA-graph capture on the "
58 + "pre-release Blackwell FP4 path; pass "
59 + "--no-disable-flashinfer-autotune for non-FP4 models.",
60 + )
61 + p.add_argument(
62 + "--mamba-ssm-cache-dtype",
63 + default="auto",
64 + choices=["auto", "float32", "float16", "bfloat16"],
65 + help="SSM state dtype (both modes). 'auto' = config-driven; "
66 + "'float32' = fp32 state, 'bfloat16' = s16 state.",
67 + )
68 + p.add_argument(
69 + "--baseline-ssm-config",
70 + default="",
71 + help="Pin the STANDARD baseline's SSM launch config as "
72 + "'bsm,nw' via override_ssm_config (forces the in-process "
73 + "engine so the override reaches the kernel). Empty = off.",
74 + )
75 + p.add_argument(
76 + "--worker",
77 + choices=["standard", "replayssm"],
78 + default=None,
79 + help=argparse.SUPPRESS,
80 + )
81 + return p.parse_args()
82 +
83 +
84 +def resolve_max_model_len(args) -> int:
85 + if args.max_model_len is not None:
86 + return args.max_model_len
87 + return args.num_steps + 256
88 +
89 +
90 +def run_worker(args):
91 + # override_ssm_config is a module global; it only reaches the model if the
92 + # engine runs in-process (default V1 spawns a separate EngineCore). Force it.
93 + if args.worker == "standard" and args.baseline_ssm_config:
94 + os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
95 +
96 + import torch
97 +
98 + from vllm import LLM, SamplingParams
99 +
100 + mode = args.worker
101 + max_model_len = resolve_max_model_len(args)
102 +
103 + llm_kwargs = dict(
104 + model=args.model_id,
105 + tensor_parallel_size=1,
106 + dtype=args.dtype,
107 + max_model_len=max_model_len,
108 + trust_remote_code=True,
109 + enable_prefix_caching=False,
110 + enable_chunked_prefill=False,
111 + max_num_seqs=args.batch_size,
112 + max_num_batched_tokens=max(max_model_len, args.batch_size * 64),
113 + enforce_eager=False,
114 + disable_log_stats=True,
115 + gpu_memory_utilization=args.gpu_memory_utilization,
116 + # SSM state dtype (applies to both standard and ReplaySSM).
117 + mamba_ssm_cache_dtype=args.mamba_ssm_cache_dtype,
118 + )
119 + if args.disable_flashinfer_autotune:
120 + # FP4-MoE autotuner is unstable under CUDA-graph capture on Blackwell;
121 + # re-enable (--no-disable-flashinfer-autotune) only for non-FP4 models.
122 + llm_kwargs["kernel_config"] = {"enable_flashinfer_autotune": False}
123 + if mode == "replayssm":
124 + llm_kwargs.update(use_replayssm=True, replayssm_buffer_len=args.buffer_len)
125 +
126 + _ssm_cm = None
127 + if mode == "standard" and args.baseline_ssm_config:
128 + from vllm.model_executor.layers.mamba.ops.mamba_ssm import override_ssm_config
129 +
130 + _bsm, _nw = (int(x) for x in args.baseline_ssm_config.split(","))
131 + _ssm_cm = override_ssm_config((_bsm, _nw))
132 + _ssm_cm.__enter__() # active through LLM() graph capture + decode
133 + print(
134 + f"[{mode}] override_ssm_config -> (BLOCK_SIZE_M={_bsm}, num_warps={_nw})",
135 + flush=True,
136 + )
137 +
138 + llm = LLM(**llm_kwargs)
139 + prompts = [args.prompt] * args.batch_size
140 +
141 + def timed_generate(n_tokens):
142 + sp = SamplingParams(
143 + n=1,
144 + temperature=0.0,
145 + ignore_eos=True,
146 + min_tokens=n_tokens,
147 + max_tokens=n_tokens,
148 + )
149 + if torch.accelerator.is_available():
150 + torch.accelerator.synchronize()
151 + t0 = time.perf_counter()
152 + outs = llm.generate(prompts, sp, use_tqdm=False)
153 + if torch.accelerator.is_available():
154 + torch.accelerator.synchronize()
155 + elapsed = time.perf_counter() - t0
156 + produced = min(len(o.outputs[0].token_ids) for o in outs)
157 + assert produced == n_tokens, f"expected {n_tokens} tokens, got {produced}"
158 + return elapsed
159 +
160 + timed_generate(args.warmup_steps)
161 +
162 + best = None
163 + for _ in range(args.repeats):
164 + elapsed = timed_generate(args.num_steps)
165 + tok_s = args.batch_size * args.num_steps / elapsed
166 + per_step_ms = elapsed / args.num_steps * 1e3
167 + print(
168 + f"[{mode}] {elapsed:.3f}s {tok_s:,.0f} tok/s {per_step_ms:.3f} ms/step",
169 + flush=True,
170 + )
171 + if best is None or elapsed < best["elapsed_s"]:
172 + best = {
173 + "mode": mode,
174 + "elapsed_s": elapsed,
175 + "tok_s": tok_s,
176 + "per_step_ms": per_step_ms,
177 + }
178 +
179 + print("RESULT_JSON " + json.dumps(best), flush=True)
180 + if _ssm_cm is not None:
181 + _ssm_cm.__exit__(None, None, None)
182 +
183 +
184 +def run_one_mode(args, mode) -> dict:
185 + cmd = [
186 + sys.executable,
187 + __file__,
188 + "--worker",
189 + mode,
190 + "--model-id",
191 + args.model_id,
192 + "--prompt",
193 + args.prompt,
194 + "--batch-size",
195 + str(args.batch_size),
196 + "--num-steps",
197 + str(args.num_steps),
198 + "--warmup-steps",
199 + str(args.warmup_steps),
200 + "--repeats",
201 + str(args.repeats),
202 + "--buffer-len",
203 + str(args.buffer_len),
204 + "--dtype",
205 + args.dtype,
206 + "--gpu-memory-utilization",
207 + str(args.gpu_memory_utilization),
208 + "--mamba-ssm-cache-dtype",
209 + args.mamba_ssm_cache_dtype,
210 + "--baseline-ssm-config",
211 + args.baseline_ssm_config,
212 + ]
213 + cmd.append(
214 + "--disable-flashinfer-autotune"
215 + if args.disable_flashinfer_autotune
216 + else "--no-disable-flashinfer-autotune"
217 + )
218 + if args.max_model_len is not None:
219 + cmd += ["--max-model-len", str(args.max_model_len)]
220 +
221 + result = None
222 + proc = subprocess.Popen(
223 + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
224 + )
225 + for line in proc.stdout:
226 + sys.stdout.write(line)
227 + sys.stdout.flush()
228 + if line.startswith("RESULT_JSON "):
229 + result = json.loads(line[len("RESULT_JSON ") :])
230 + proc.wait()
231 + if proc.returncode != 0:
232 + raise RuntimeError(f"mode '{mode}' worker exited with {proc.returncode}")
233 + if result is None:
234 + raise RuntimeError(f"mode '{mode}' produced no RESULT_JSON line")
235 + return result
236 +
237 +
238 +def main():
239 + args = parse_args()
240 + if args.worker is not None:
241 + run_worker(args)
242 + return
243 +
244 + print(
245 + f"model={args.model_id} batch_size={args.batch_size} "
246 + f"steps={args.num_steps} buffer_len={args.buffer_len} dtype={args.dtype}"
247 + )
248 +
249 + std = run_one_mode(args, "standard")
250 + fla = run_one_mode(args, "replayssm")
251 + speedup = std["per_step_ms"] / fla["per_step_ms"]
252 +
253 + print()
254 + header = f"{'mode':<10}{'ms/step':>12}{'tok/s':>16}{'wall (s)':>12}"
255 + print(header)
256 + print("-" * len(header))
257 + for r in (std, fla):
258 + print(
259 + f"{MODE_LABEL[r['mode']]:<10}{r['per_step_ms']:>12.3f}"
260 + f"{r['tok_s']:>16,.0f}{r['elapsed_s']:>12.3f}"
261 + )
262 + print("-" * len(header))
263 + print(f"speedup (standard / ReplaySSM, per step): {speedup:.3f}x")
264 +
265 +
266 +if __name__ == "__main__":
267 + main()
tests/kernels/mamba/test_replayssm_prefill_decode_equivalence_mamba2.py new
+244
@@ -0,0 +1,244 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Prefill == decode equivalence for the Mamba2 ReplaySSM kernels.
4 +
5 +The SSM recurrence is path-independent: vLLM's chunked prefill (the SSD kernel
6 +``mamba_chunk_scan_combined_varlen``) and step-by-step decode must produce the
7 +same per-position outputs and final state over a sequence. This file feeds one
8 +set of inputs through the production dt flow (raw dt + softplus + a per-head
9 +dt_bias, applied inside each kernel) to:
10 +
11 + * the exact fp32 step recurrence (``selective_state_update_ref``) -- the
12 + ground truth,
13 + * the chunked prefill kernel,
14 + * the baseline decode kernel,
15 + * the ReplaySSM output_only decode kernel,
16 +
17 +and checks all of them agree. Prefill (a chunked scan) and decode (a step
18 +recurrence) are different code paths, so they differ numerically: the chunked
19 +scan carries ~2e-2 (fp32) / ~4e-2 (bf16) vs the exact recurrence, far above the
20 +near-exact decode. We therefore anchor every path on the exact recurrence at
21 +SSD-level tolerances (the same regime as ``test_mamba_ssm_ssd.py``), which the
22 +chunked scan sets, keyed off the activation dtype.
23 +
24 +State and activation/buffer precision are swept independently, including the
25 +fp32-state + bf16-activation production config.
26 +"""
27 +
28 +import pytest
29 +import torch
30 +
31 +from tests.kernels.mamba.utils import selective_state_update_ref
32 +from vllm.model_executor.layers.mamba.ops.mamba_ssm import selective_state_update
33 +from vllm.model_executor.layers.mamba.ops.selective_state_update_replayssm_output_only import ( # noqa: E501
34 + selective_state_update_replayssm_output_only,
35 +)
36 +from vllm.model_executor.layers.mamba.ops.ssd_combined import (
37 + mamba_chunk_scan_combined_varlen,
38 +)
39 +from vllm.utils.torch_utils import set_random_seed
40 +from vllm.v1.attention.backends.mamba2_attn import compute_varlen_chunk_metadata
41 +
42 +
43 +def _prefill_tolerances(act_dtype: torch.dtype) -> tuple[float, float]:
44 + # The chunked prefill scan, not the decode, sets these: it carries ~2e-2
45 + # (fp32) / ~6e-2 (bf16) vs the exact recurrence, while ReplaySSM decode is
46 + # near-exact (~2e-6 fp32). Keyed off the activation dtype (the outputs are
47 + # in act_dtype). Same regime as test_mamba_ssm_ssd.py.
48 + if act_dtype == torch.float32:
49 + return 1e-2, 3e-2
50 + return 6e-2, 1e-1
51 +
52 +
53 +def _run_prefill_decode_equivalence(
54 + *,
55 + state_dtype: torch.dtype,
56 + act_dtype: torch.dtype,
57 + nheads: int,
58 + headdim: int,
59 + ngroups: int,
60 + dstate: int,
61 + seqlen: int,
62 + chunk_size: int,
63 + max_cache_len: int,
64 + seed: int = 0,
65 +) -> None:
66 + """Prefill the whole sequence and decode it step by step; check both match
67 + the exact fp32 recurrence (and each other). All paths use the production dt
68 + flow (raw dt + softplus + a per-head dt_bias), so this also checks prefill
69 + and decode apply the softplus/bias preprocessing consistently. ``state_dtype``
70 + is the recurrent-state precision; ``act_dtype`` the activation/buffer one."""
71 + device = "cuda"
72 + rtol, atol = _prefill_tolerances(act_dtype)
73 + set_random_seed(seed)
74 +
75 + # Production dt flow: raw dt + a per-head dt_bias (~-4 keeps softplus(dt +
76 + # bias) small and well-conditioned). dt_bias is (nheads,) for prefill and
77 + # (nheads, headdim) for the decode kernels/reference.
78 + A = -torch.exp(torch.rand(nheads, device=device, dtype=act_dtype))
79 + dt = torch.randn(seqlen, nheads, device=device, dtype=act_dtype)
80 + dt_bias = torch.rand(nheads, device=device, dtype=act_dtype) - 4
81 + dt_bias_hd = dt_bias.view(nheads, 1).expand(nheads, headdim)
82 + X = torch.randn(seqlen, nheads, headdim, device=device, dtype=act_dtype)
83 + B = torch.randn(seqlen, ngroups, dstate, device=device, dtype=act_dtype)
84 + C = torch.randn(seqlen, ngroups, dstate, device=device, dtype=act_dtype)
85 + A_bcast = A.view(nheads, 1, 1).expand(nheads, headdim, dstate)
86 +
87 + # Chunked prefill over the whole sequence (implicit batch=1, varlen). The
88 + # kernel always returns the final state in fp32, so no state_dtype plumbing.
89 + cu_seqlens = torch.tensor((0, seqlen), device=device).cumsum(0).to(torch.int32)
90 + cu_chunk_seqlens, last_chunk_indices, seq_idx = compute_varlen_chunk_metadata(
91 + cu_seqlens, chunk_size
92 + )
93 + y_prefill = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
94 + final_state_prefill = mamba_chunk_scan_combined_varlen(
95 + X,
96 + dt,
97 + A,
98 + B,
99 + C,
100 + chunk_size,
101 + cu_seqlens=cu_seqlens,
102 + cu_chunk_seqlens=cu_chunk_seqlens,
103 + last_chunk_indices=last_chunk_indices,
104 + seq_idx=seq_idx,
105 + out=y_prefill,
106 + D=None,
107 + dt_bias=dt_bias,
108 + dt_softplus=True,
109 + )
110 +
111 + # Step paths: exact fp32 recurrence (ground truth), baseline, ReplaySSM.
112 + # State follows state_dtype; caches follow act_dtype (dt_cache is fp32).
113 + state_ref = torch.zeros(
114 + 1, nheads, headdim, dstate, device=device, dtype=torch.float32
115 + )
116 + state_base = torch.zeros(
117 + 1, nheads, headdim, dstate, device=device, dtype=state_dtype
118 + )
119 + state_dec = torch.zeros(
120 + 1, nheads, headdim, dstate, device=device, dtype=state_dtype
121 + )
122 + x_cache = torch.zeros(
123 + 1, nheads, max_cache_len, headdim, device=device, dtype=act_dtype
124 + )
125 + dt_cache = torch.zeros(1, nheads, max_cache_len, device=device, dtype=torch.float32)
126 + B_cache = torch.zeros(
127 + 1, ngroups, max_cache_len, dstate, device=device, dtype=act_dtype
128 + )
129 + bc_pre = torch.empty(1, ngroups, max_cache_len, device=device, dtype=torch.float32)
130 + write_pos = torch.zeros(1, dtype=torch.int32, device=device)
131 + # No skip connection (D=0) on any path here; the D!=0 path is covered by the
132 + # standard-decode suite. The baseline kernel needs a D tensor, not None.
133 + D_zero = torch.zeros(nheads, headdim, device=device)
134 +
135 + y_ref = torch.empty(seqlen, nheads, headdim, device=device, dtype=torch.float32)
136 + y_base = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
137 + y_dec = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
138 + for t in range(seqlen):
139 + dt_t = dt[t].view(1, nheads, 1).expand(1, nheads, headdim)
140 + is_flush = write_pos == max_cache_len - 1
141 +
142 + y_ref[t] = selective_state_update_ref(
143 + state_ref,
144 + X[t : t + 1].float(),
145 + dt_t.float(),
146 + A_bcast.float(),
147 + B[t : t + 1].float(),
148 + C[t : t + 1].float(),
149 + dt_bias=dt_bias_hd.float(),
150 + dt_softplus=True,
151 + )[0]
152 +
153 + out_b = torch.empty(1, nheads, headdim, device=device, dtype=act_dtype)
154 + selective_state_update(
155 + state_base,
156 + X[t : t + 1],
157 + dt_t,
158 + A_bcast,
159 + B[t : t + 1],
160 + C[t : t + 1],
161 + D=D_zero,
162 + dt_bias=dt_bias_hd,
163 + dt_softplus=True,
164 + out=out_b,
165 + )
166 + y_base[t] = out_b[0]
167 +
168 + out_d = torch.empty(1, nheads, headdim, device=device, dtype=act_dtype)
169 + common = dict(
170 + dt_bias=dt_bias_hd,
171 + dt_softplus=True,
172 + x_cache=x_cache,
173 + dt_cache=dt_cache,
174 + B_cache=B_cache,
175 + write_pos=write_pos,
176 + is_flush=is_flush,
177 + max_cache_len=max_cache_len,
178 + out=out_d,
179 + )
180 + selective_state_update_replayssm_output_only(
181 + state_dec,
182 + X[t : t + 1],
183 + dt_t,
184 + A_bcast,
185 + B[t : t + 1],
186 + C[t : t + 1],
187 + bc_pre=bc_pre,
188 + **common,
189 + )
190 + y_dec[t] = out_d[0]
191 +
192 + write_pos = torch.where(is_flush, torch.zeros_like(write_pos), write_pos + 1)
193 +
194 + # Every path computes the same recurrence; anchor each on the fp32 truth.
195 + torch.testing.assert_close(y_prefill.float(), y_ref, rtol=rtol, atol=atol)
196 + torch.testing.assert_close(y_base.float(), y_ref, rtol=rtol, atol=atol)
197 + torch.testing.assert_close(y_dec.float(), y_ref, rtol=rtol, atol=atol)
198 + # Headline: ReplaySSM decode matches the chunked prefill directly.
199 + torch.testing.assert_close(y_dec.float(), y_prefill.float(), rtol=rtol, atol=atol)
200 + # Final state too (the recurrence ends in state_ref after the loop).
201 + torch.testing.assert_close(
202 + final_state_prefill[0].float(), state_ref[0], rtol=rtol, atol=atol
203 + )
204 +
205 +
206 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
207 +@pytest.mark.parametrize(
208 + "precision",
209 + # fp32 state is the default; bf16/fp16 are reduced-footprint configs. fp16
210 + # appears as an activation dtype (s32_afp16, sfp16_afp16) and as a finer-
211 + # mantissa state under bf16 activations (sfp16_a16).
212 + [
213 + pytest.param((torch.float32, torch.float32), id="s32_a32"),
214 + pytest.param((torch.float32, torch.bfloat16), id="s32_a16"),
215 + pytest.param((torch.bfloat16, torch.bfloat16), id="s16_a16"),
216 + pytest.param((torch.float32, torch.float16), id="s32_afp16"),
217 + pytest.param((torch.float16, torch.float16), id="sfp16_afp16"),
218 + pytest.param((torch.float16, torch.bfloat16), id="sfp16_a16"),
219 + ],
220 +)
221 +@pytest.mark.parametrize(
222 + "geometry", # (nheads, headdim, dstate, ngroups)
223 + [
224 + pytest.param((8, 64, 64, 2), id="small"),
225 + pytest.param((96, 80, 128, 8), id="nano4b"),
226 + ],
227 +)
228 +def test_replayssm_prefill_decode_equivalence(
229 + precision: tuple[torch.dtype, torch.dtype],
230 + geometry: tuple[int, int, int, int],
231 +):
232 + state_dtype, act_dtype = precision
233 + nheads, headdim, dstate, ngroups = geometry
234 + _run_prefill_decode_equivalence(
235 + state_dtype=state_dtype,
236 + act_dtype=act_dtype,
237 + nheads=nheads,
238 + headdim=headdim,
239 + ngroups=ngroups,
240 + dstate=dstate,
241 + seqlen=16,
242 + chunk_size=8,
243 + max_cache_len=4,
244 + )
tests/kernels/mamba/test_replayssm_standard_decode_mamba2.py new
+673
@@ -0,0 +1,673 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Standard (autoregressive) decode correctness for the Mamba2 ReplaySSM kernels.
4 +
5 +ReplaySSM caches the recent SSM inputs ``(x, dt, B)`` in a small ring buffer and
6 +reconstructs / reads out the recurrent state on the fly, writing the full state
7 +back to HBM only when the buffer flushes. This file checks that, over a
8 +multi-step decode, the ReplaySSM output_only kernel reproduces the exact SSM
9 +recurrence.
10 +
11 +For every step we assert, against trusted oracles driven one token at a time:
12 +
13 + * the ReplaySSM output matches its pure-PyTorch reference, which models the
14 + kernel's exact arithmetic (including its bf16 reconstruction), at every
15 + precision,
16 + * when the state is fp32, the output also matches the baseline decode kernel
17 + (``selective_state_update``); at bf16 state the baseline legitimately
18 + differs -- it downcasts the fp32 state to bf16 every step, while ReplaySSM
19 + accumulates a whole buffer in fp32 and is the more accurate path,
20 + * when state and activations are both fp32, the output also matches the exact
21 + elementwise ``selective_state_update_ref``,
22 + * the cached inputs match the reference cache management,
23 + * the checkpoint state matches the reference (and the baseline at fp32 state).
24 +
25 +State and activation/buffer precision are swept independently. Nemotron-3
26 +defaults to fp32 SSM state (``mamba_ssm_cache_dtype=float32``), but bf16 state is
27 +also supported; the buffer dtype follows the activation dtype and ``dt_cache``
28 +is always fp32.
29 +"""
30 +
31 +import pytest
32 +import torch
33 +
34 +from tests.kernels.mamba.utils import (
35 + allocate_update_caches,
36 + selective_state_update_ref,
37 + selective_state_update_replayssm_output_only_ref,
38 +)
39 +from vllm.model_executor.layers.mamba.ops.mamba_ssm import selective_state_update
40 +from vllm.model_executor.layers.mamba.ops.selective_state_update_replayssm_output_only import ( # noqa: E501
41 + selective_state_update_replayssm_output_only,
42 +)
43 +from vllm.utils.torch_utils import set_random_seed
44 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
45 +
46 +
47 +def _tolerances(dtype: torch.dtype) -> tuple[float, float]:
48 + # fp32 demands true fp32 parity. A correct fp32 reconstruction (tl.dot with
49 + # input_precision="tf32x3"/"ieee") matches the elementwise baseline to
50 + # ~1e-5, while a TF32 reconstruction drifts to ~1e-2. atol=1e-3 sits between,
51 + # so it flags TF32 degradation yet passes a correct fp32 kernel. bf16 stays
52 + # loose (bf16 rounding dominates and is unaffected by the matmul precision).
53 + if dtype == torch.float32:
54 + return 1e-4, 1e-3
55 + return 6e-2, 2e-1
56 +
57 +
58 +def _tied_A(nheads: int, headdim: int, dstate: int, device: str) -> torch.Tensor:
59 + A = -torch.rand(nheads, device=device) - 1.0
60 + return A.view(nheads, 1, 1).expand(nheads, headdim, dstate)
61 +
62 +
63 +def _tied_dt(
64 + batch: int,
65 + nheads: int,
66 + headdim: int,
67 + device: str,
68 + dtype: torch.dtype,
69 +) -> torch.Tensor:
70 + dt = torch.randn(batch, nheads, device=device, dtype=dtype)
71 + return dt.unsqueeze(-1).expand(batch, nheads, headdim)
72 +
73 +
74 +def _tied_dt_bias(nheads: int, headdim: int, device: str) -> torch.Tensor:
75 + dt_bias = torch.rand(nheads, device=device) - 4.0
76 + return dt_bias.view(nheads, 1).expand(nheads, headdim)
77 +
78 +
79 +def _run_standard_decode(
80 + *,
81 + state_dtype: torch.dtype,
82 + act_dtype: torch.dtype,
83 + batch: int,
84 + nheads: int,
85 + headdim: int,
86 + ngroups: int,
87 + dstate: int,
88 + max_cache_len: int,
89 + num_steps: int,
90 + has_z: bool,
91 + dt_softplus: bool,
92 + use_dt_bias: bool,
93 + desync_write_pos: bool = False,
94 + seed: int = 0,
95 +) -> None:
96 + """Drive ``num_steps`` decode steps and check every path against the
97 + trusted recurrence. ``state_dtype`` is the recurrent-state precision;
98 + ``act_dtype`` is the activation/buffer precision."""
99 + device = "cuda"
100 + both_fp32 = state_dtype == torch.float32 and act_dtype == torch.float32
101 + rtol, atol = _tolerances(torch.float32 if both_fp32 else torch.bfloat16)
102 + set_random_seed(seed)
103 +
104 + # One state copy per path; all start identical at the same precision.
105 + state0 = torch.randn(
106 + batch, nheads, headdim, dstate, dtype=state_dtype, device=device
107 + )
108 + state_anchor = state0.clone()
109 + state_baseline = state0.clone()
110 + state_cached = state0.clone()
111 + state_ref = state0.clone()
112 +
113 + A = _tied_A(nheads, headdim, dstate, device)
114 + dt_bias = _tied_dt_bias(nheads, headdim, device) if use_dt_bias else None
115 + D = torch.randn(nheads, headdim, device=device)
116 +
117 + # Caches follow the activation dtype (dt_cache is forced to fp32 inside).
118 + x_cache, dt_cache, B_cache, _ = allocate_update_caches(
119 + batch,
120 + nheads,
121 + ngroups,
122 + headdim,
123 + dstate,
124 + max_cache_len,
125 + device,
126 + act_dtype,
127 + act_dtype,
128 + )
129 + x_cache_ref, dt_cache_ref, B_cache_ref, _ = allocate_update_caches(
130 + batch,
131 + nheads,
132 + ngroups,
133 + headdim,
134 + dstate,
135 + max_cache_len,
136 + device,
137 + act_dtype,
138 + act_dtype,
139 + )
140 + bc_pre = torch.empty(
141 + batch, ngroups, max_cache_len, device=device, dtype=torch.float32
142 + )
143 +
144 + if desync_write_pos:
145 + # Rows start at different ring positions so they flush on different
146 + # steps, exercising per-row write-position handling.
147 + write_pos = (
148 + torch.arange(batch, device=device, dtype=torch.int32) % max_cache_len
149 + )
150 + else:
151 + write_pos = torch.zeros(batch, dtype=torch.int32, device=device)
152 +
153 + for _ in range(num_steps):
154 + x = torch.randn(batch, nheads, headdim, device=device, dtype=act_dtype)
155 + dt = _tied_dt(batch, nheads, headdim, device, act_dtype)
156 + B = torch.randn(batch, ngroups, dstate, device=device, dtype=act_dtype)
157 + C = torch.randn(batch, ngroups, dstate, device=device, dtype=act_dtype)
158 + z = torch.randn_like(x) if has_z else None
159 + is_flush = write_pos == max_cache_len - 1
160 +
161 + # Trusted recurrence (mutates state_anchor in place, returns output).
162 + out_anchor = selective_state_update_ref(
163 + state_anchor,
164 + x,
165 + dt,
166 + A,
167 + B,
168 + C,
169 + D=D,
170 + z=z,
171 + dt_bias=dt_bias,
172 + dt_softplus=dt_softplus,
173 + )
174 +
175 + # Upstream baseline decode kernel.
176 + out_baseline = torch.empty_like(x)
177 + selective_state_update(
178 + state_baseline,
179 + x,
180 + dt,
181 + A,
182 + B,
183 + C,
184 + D=D,
185 + z=z,
186 + dt_bias=dt_bias,
187 + dt_softplus=dt_softplus,
188 + out=out_baseline,
189 + )
190 +
191 + # ReplaySSM kernel under test + its pure-PyTorch reference.
192 + out_cached = torch.empty_like(x)
193 + common = dict(
194 + D=D,
195 + z=z,
196 + dt_bias=dt_bias,
197 + dt_softplus=dt_softplus,
198 + x_cache=x_cache,
199 + dt_cache=dt_cache,
200 + B_cache=B_cache,
201 + write_pos=write_pos,
202 + is_flush=is_flush,
203 + max_cache_len=max_cache_len,
204 + out=out_cached,
205 + )
206 + selective_state_update_replayssm_output_only(
207 + state_cached, x, dt, A, B, C, bc_pre=bc_pre, **common
208 + )
209 + out_ref = selective_state_update_replayssm_output_only_ref(
210 + state_ref,
211 + x,
212 + dt,
213 + A,
214 + B,
215 + C,
216 + D=D,
217 + z=z,
218 + dt_bias=dt_bias,
219 + dt_softplus=dt_softplus,
220 + x_cache=x_cache_ref,
221 + dt_cache=dt_cache_ref,
222 + B_cache=B_cache_ref,
223 + write_pos=write_pos,
224 + max_cache_len=max_cache_len,
225 + )
226 +
227 + # The reference models the kernel's exact arithmetic (including its bf16
228 + # reconstruction), so the kernel must match it tightly at every
229 + # precision. At fp32 this also flags any TF32 reconstruction drift.
230 + torch.testing.assert_close(out_cached, out_ref, rtol=rtol, atol=atol)
231 + # When the STATE is fp32 the baseline decode kernel is a valid oracle (it
232 + # does not downcast the state per step), so ReplaySSM must match it. At
233 + # bf16 state the baseline legitimately differs: it downcasts the fp32
234 + # state to bf16 every step, while ReplaySSM accumulates a whole buffer in
235 + # fp32 and is the MORE accurate path -- so it is not a tight oracle there.
236 + if state_dtype == torch.float32:
237 + torch.testing.assert_close(out_cached, out_baseline, rtol=rtol, atol=atol)
238 + # The exact elementwise reference is valid only when state AND
239 + # activations are fp32; otherwise it downcasts the state (at readout for
240 + # bf16 activations, or per step for bf16 state).
241 + if both_fp32:
242 + torch.testing.assert_close(out_cached, out_anchor, rtol=rtol, atol=atol)
243 +
244 + # Cached inputs match the reference cache management.
245 + torch.testing.assert_close(x_cache, x_cache_ref, rtol=rtol, atol=atol)
246 + torch.testing.assert_close(dt_cache, dt_cache_ref, rtol=rtol, atol=atol)
247 + torch.testing.assert_close(B_cache, B_cache_ref, rtol=rtol, atol=atol)
248 +
249 + # Checkpoint state at flush matches the reference (and, when the state is
250 + # fp32, the baseline kernel).
251 + if bool(is_flush.any()):
252 + torch.testing.assert_close(
253 + state_cached[is_flush], state_ref[is_flush], rtol=rtol, atol=atol
254 + )
255 + if state_dtype == torch.float32:
256 + torch.testing.assert_close(
257 + state_cached[is_flush],
258 + state_baseline[is_flush],
259 + rtol=rtol,
260 + atol=atol,
261 + )
262 +
263 + write_pos = torch.where(is_flush, torch.zeros_like(write_pos), write_pos + 1)
264 +
265 +
266 +# State/activation precisions. fp32 state is the default; bf16/fp16 are the
267 +# reduced-footprint configs. fp16 appears both as an activation dtype (fully-fp16
268 +# model sfp16_afp16, or fp16 act over fp32 state s32_afp16) and as a state dtype
269 +# under bf16 activations (sfp16_a16): fp16 has a finer mantissa than bf16 at the
270 +# same 2 bytes, so it is a more accurate state at no extra footprint. We still
271 +# skip fp16 state under fp32 activations (the unused low-state/high-act mix).
272 +_PRECISIONS = [
273 + pytest.param((torch.float32, torch.float32), id="s32_a32"),
274 + pytest.param((torch.float32, torch.bfloat16), id="s32_a16"),
275 + pytest.param((torch.bfloat16, torch.bfloat16), id="s16_a16"),
276 + pytest.param((torch.float32, torch.float16), id="s32_afp16"),
277 + pytest.param((torch.float16, torch.float16), id="sfp16_afp16"),
278 + pytest.param((torch.float16, torch.bfloat16), id="sfp16_a16"),
279 +]
280 +# Small synthetic shapes for the full axis sweep (compile fast).
281 +_SMALL_GEOMETRIES = [
282 + pytest.param((8, 64, 64, 4), id="small"),
283 + pytest.param((4, 64, 16, 1), id="tiny"),
284 +]
285 +# Production Mamba2 shapes (nheads, headdim, dstate, ngroups), TP=1.
286 +_REAL_GEOMETRIES = [
287 + pytest.param((96, 80, 128, 8), id="nano4b"),
288 + pytest.param((128, 64, 128, 8), id="super120b"),
289 + pytest.param((256, 64, 128, 8), id="ultra550b"),
290 +]
291 +
292 +
293 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
294 +@pytest.mark.parametrize("precision", _PRECISIONS)
295 +@pytest.mark.parametrize("max_cache_len", [1, 4, 16])
296 +@pytest.mark.parametrize("geometry", _SMALL_GEOMETRIES)
297 +@pytest.mark.parametrize("has_z", [False, True])
298 +def test_replayssm_standard_decode_matches_reference(
299 + precision: tuple[torch.dtype, torch.dtype],
300 + max_cache_len: int,
301 + geometry: tuple[int, int, int, int],
302 + has_z: bool,
303 +):
304 + state_dtype, act_dtype = precision
305 + nheads, headdim, dstate, ngroups = geometry
306 + _run_standard_decode(
307 + state_dtype=state_dtype,
308 + act_dtype=act_dtype,
309 + batch=4,
310 + nheads=nheads,
311 + headdim=headdim,
312 + ngroups=ngroups,
313 + dstate=dstate,
314 + max_cache_len=max_cache_len,
315 + num_steps=2 * max_cache_len + 1,
316 + has_z=has_z,
317 + dt_softplus=True,
318 + use_dt_bias=True,
319 + )
320 +
321 +
322 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
323 +@pytest.mark.parametrize("precision", _PRECISIONS)
324 +@pytest.mark.parametrize("geometry", _REAL_GEOMETRIES)
325 +def test_replayssm_standard_decode_real_geometry(
326 + precision: tuple[torch.dtype, torch.dtype],
327 + geometry: tuple[int, int, int, int],
328 +):
329 + # Production Mamba2 shapes for the Nemotron-3 family (Nano-4B / Super-120B /
330 + # Ultra-550B), at the production buffer length (8). All three precisions,
331 + # including the bf16 state case.
332 + state_dtype, act_dtype = precision
333 + nheads, headdim, dstate, ngroups = geometry
334 + _run_standard_decode(
335 + state_dtype=state_dtype,
336 + act_dtype=act_dtype,
337 + batch=4,
338 + nheads=nheads,
339 + headdim=headdim,
340 + ngroups=ngroups,
341 + dstate=dstate,
342 + max_cache_len=8,
343 + num_steps=17,
344 + has_z=True,
345 + dt_softplus=True,
346 + use_dt_bias=True,
347 + )
348 +
349 +
350 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
351 +@pytest.mark.parametrize(
352 + "precision",
353 + [
354 + pytest.param((torch.float32, torch.float32), id="s32_a32"),
355 + pytest.param((torch.float32, torch.bfloat16), id="s32_a16"),
356 + pytest.param((torch.float32, torch.float16), id="s32_afp16"),
357 + ],
358 +)
359 +def test_replayssm_standard_decode_desync_write_pos(
360 + precision: tuple[torch.dtype, torch.dtype],
361 +):
362 + # Rows start at staggered ring positions, so they flush on different steps
363 + # and hold genuinely different cached histories.
364 + state_dtype, act_dtype = precision
365 + _run_standard_decode(
366 + state_dtype=state_dtype,
367 + act_dtype=act_dtype,
368 + batch=4,
369 + nheads=8,
370 + headdim=64,
371 + ngroups=4,
372 + dstate=64,
373 + max_cache_len=4,
374 + num_steps=12,
375 + has_z=True,
376 + dt_softplus=True,
377 + use_dt_bias=True,
378 + desync_write_pos=True,
379 + )
380 +
381 +
382 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
383 +@pytest.mark.parametrize("precision", _PRECISIONS)
384 +@pytest.mark.parametrize("with_padding", [False, True])
385 +def test_replayssm_standard_decode_with_batch_indices(
386 + precision: tuple[torch.dtype, torch.dtype],
387 + with_padding: bool,
388 +):
389 + # Sparse state allocation via state_batch_indices, with NULL_BLOCK_ID
390 + # padding rows. The pure-PyTorch references do not model the sparse
391 + # gather, so the anchor here is the upstream baseline decode kernel.
392 + state_dtype, act_dtype = precision
393 + device = "cuda"
394 + both_fp32 = state_dtype == torch.float32 and act_dtype == torch.float32
395 + rtol, atol = _tolerances(torch.float32 if both_fp32 else torch.bfloat16)
396 + set_random_seed(0)
397 +
398 + batch = 3
399 + padding = 2 if with_padding else 0
400 + padded_batch = batch + padding
401 + total_state_slots = 16
402 + nheads = 4
403 + ngroups = 2
404 + headdim = 64
405 + dstate = 16
406 + max_cache_len = 4
407 + num_steps = 2 * max_cache_len
408 +
409 + state = torch.randn(
410 + total_state_slots, nheads, headdim, dstate, dtype=state_dtype, device=device
411 + )
412 + state_baseline = state.clone()
413 + state_cached = state.clone()
414 + state_before = state.clone()
415 +
416 + state_indices = (
417 + torch.randperm(total_state_slots - 1, device=device)[:batch] + 1
418 + ).to(torch.int32)
419 + state_batch_indices = torch.cat(
420 + [
421 + state_indices,
422 + torch.full((padding,), NULL_BLOCK_ID, dtype=torch.int32, device=device),
423 + ]
424 + )
425 + unused_states = torch.ones(total_state_slots, dtype=torch.bool, device=device)
426 + unused_states[state_indices] = False
427 +
428 + A = _tied_A(nheads, headdim, dstate, device)
429 + dt_bias = _tied_dt_bias(nheads, headdim, device)
430 + D = torch.randn(nheads, headdim, device=device)
431 + x_cache = torch.zeros(
432 + total_state_slots,
433 + nheads,
434 + max_cache_len,
435 + headdim,
436 + device=device,
437 + dtype=act_dtype,
438 + )
439 + dt_cache = torch.zeros(
440 + total_state_slots, nheads, max_cache_len, device=device, dtype=torch.float32
441 + )
442 + B_cache = torch.zeros(
443 + total_state_slots,
444 + ngroups,
445 + max_cache_len,
446 + dstate,
447 + device=device,
448 + dtype=act_dtype,
449 + )
450 + bc_pre = torch.empty(
451 + padded_batch, ngroups, max_cache_len, device=device, dtype=torch.float32
452 + )
453 + write_pos = torch.zeros(padded_batch, dtype=torch.int32, device=device)
454 +
455 + for _ in range(num_steps):
456 + x = torch.randn(padded_batch, nheads, headdim, device=device, dtype=act_dtype)
457 + dt = _tied_dt(padded_batch, nheads, headdim, device, act_dtype)
458 + B = torch.randn(padded_batch, ngroups, dstate, device=device, dtype=act_dtype)
459 + C = torch.randn(padded_batch, ngroups, dstate, device=device, dtype=act_dtype)
460 + z = torch.randn_like(x)
461 + is_flush = write_pos == max_cache_len - 1
462 +
463 + out_baseline = torch.empty_like(x)
464 + selective_state_update(
465 + state_baseline,
466 + x,
467 + dt,
468 + A,
469 + B,
470 + C,
471 + D=D,
472 + z=z,
473 + dt_bias=dt_bias,
474 + dt_softplus=True,
475 + state_batch_indices=state_batch_indices,
476 + out=out_baseline,
477 + )
478 +
479 + out_cached = torch.full_like(x, 42)
480 + common = dict(
481 + D=D,
482 + z=z,
483 + dt_bias=dt_bias,
484 + dt_softplus=True,
485 + x_cache=x_cache,
486 + dt_cache=dt_cache,
487 + B_cache=B_cache,
488 + write_pos=write_pos,
489 + is_flush=is_flush,
490 + max_cache_len=max_cache_len,
491 + state_batch_indices=state_batch_indices,
492 + out=out_cached,
493 + )
494 + selective_state_update_replayssm_output_only(
495 + state_cached, x, dt, A, B, C, bc_pre=bc_pre, **common
496 + )
497 +
498 + torch.testing.assert_close(
499 + out_cached[:batch], out_baseline[:batch], rtol=rtol, atol=atol
500 + )
501 + if with_padding:
502 + assert torch.equal(
503 + out_cached[batch:], torch.full_like(out_cached[batch:], 42)
504 + )
505 +
506 + if bool(is_flush[:batch].all()):
507 + torch.testing.assert_close(
508 + state_cached[state_indices],
509 + state_baseline[state_indices],
510 + rtol=rtol,
511 + atol=atol,
512 + )
513 +
514 + write_pos = torch.where(is_flush, torch.zeros_like(write_pos), write_pos + 1)
515 +
516 + assert torch.equal(state_cached[unused_states], state_before[unused_states])
517 + assert torch.equal(state_baseline[unused_states], state_before[unused_states])
518 +
519 +
520 +# Geometries with (nheads, ngroups) both divisible by the tp below.
521 +_TP_GEOMETRIES = [
522 + pytest.param((8, 64, 64, 4), id="small"),
523 + pytest.param((96, 80, 128, 8), id="nano4b"),
524 +]
525 +
526 +
527 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="Need CUDA device")
528 +@pytest.mark.parametrize("precision", _PRECISIONS)
529 +@pytest.mark.parametrize("geometry", _TP_GEOMETRIES)
530 +@pytest.mark.parametrize("tp", [2])
531 +def test_replayssm_standard_decode_tp_head_shard_equivalence(
532 + precision: tuple[torch.dtype, torch.dtype],
533 + geometry: tuple[int, int, int, int],
534 + tp: int,
535 +):
536 + """Tensor-parallel correctness at the kernel boundary (single GPU).
537 +
538 + The kernel has no cross-rank communication, so head sharding must be exactly
539 + separable: one run over all heads must equal concatenating ``tp`` independent
540 + per-rank runs on ``nheads // tp`` heads and ``ngroups // tp`` groups. This
541 + guards the per-rank divisors and group->head mapping the state-shape wiring
542 + relies on. The real TP1==TP2 engine check lives in the v1/e2e suite."""
543 + state_dtype, act_dtype = precision
544 + nheads, headdim, dstate, ngroups = geometry
545 + assert nheads % tp == 0 and ngroups % tp == 0
546 + device = "cuda"
547 + both_fp32 = state_dtype == torch.float32 and act_dtype == torch.float32
548 + rtol, atol = _tolerances(torch.float32 if both_fp32 else torch.bfloat16)
549 + set_random_seed(0)
550 +
551 + batch = 4
552 + max_cache_len = 4
553 + num_steps = 2 * max_cache_len + 1
554 + nh_s = nheads // tp
555 + ng_s = ngroups // tp
556 +
557 + # Shards slice the tied params; never .contiguous() -- that would drop the
558 + # stride-0 broadcast the kernel's TIE_HDIM asserts require.
559 + A = _tied_A(nheads, headdim, dstate, device)
560 + dt_bias = _tied_dt_bias(nheads, headdim, device)
561 + D = torch.randn(nheads, headdim, device=device)
562 +
563 + state_full = torch.randn(
564 + batch, nheads, headdim, dstate, dtype=state_dtype, device=device
565 + )
566 + x_cache, dt_cache, B_cache, _ = allocate_update_caches(
567 + batch,
568 + nheads,
569 + ngroups,
570 + headdim,
571 + dstate,
572 + max_cache_len,
573 + device,
574 + act_dtype,
575 + act_dtype,
576 + )
577 + bc_pre = torch.empty(
578 + batch, ngroups, max_cache_len, device=device, dtype=torch.float32
579 + )
580 +
581 + # Per-rank shards, each seeded from the matching head slice so all start equal.
582 + shard_state = []
583 + shard_caches = []
584 + for r in range(tp):
585 + h0 = r * nh_s
586 + shard_state.append(state_full[:, h0 : h0 + nh_s].contiguous())
587 + xc, dtc, bc, _ = allocate_update_caches(
588 + batch,
589 + nh_s,
590 + ng_s,
591 + headdim,
592 + dstate,
593 + max_cache_len,
594 + device,
595 + act_dtype,
596 + act_dtype,
597 + )
598 + bcp = torch.empty(
599 + batch, ng_s, max_cache_len, device=device, dtype=torch.float32
600 + )
601 + shard_caches.append((xc, dtc, bc, bcp))
602 +
603 + write_pos = torch.zeros(batch, dtype=torch.int32, device=device)
604 + for _ in range(num_steps):
605 + x = torch.randn(batch, nheads, headdim, device=device, dtype=act_dtype)
606 + dt = _tied_dt(batch, nheads, headdim, device, act_dtype)
607 + B = torch.randn(batch, ngroups, dstate, device=device, dtype=act_dtype)
608 + C = torch.randn(batch, ngroups, dstate, device=device, dtype=act_dtype)
609 + z = torch.randn_like(x)
610 + is_flush = write_pos == max_cache_len - 1
611 +
612 + out_full = torch.empty_like(x)
613 + selective_state_update_replayssm_output_only(
614 + state_full,
615 + x,
616 + dt,
617 + A,
618 + B,
619 + C,
620 + D=D,
621 + z=z,
622 + dt_bias=dt_bias,
623 + dt_softplus=True,
624 + x_cache=x_cache,
625 + dt_cache=dt_cache,
626 + B_cache=B_cache,
627 + bc_pre=bc_pre,
628 + write_pos=write_pos,
629 + is_flush=is_flush,
630 + max_cache_len=max_cache_len,
631 + out=out_full,
632 + )
633 +
634 + for r in range(tp):
635 + h0 = r * nh_s
636 + g0 = r * ng_s
637 + xc, dtc, bc, bcp = shard_caches[r]
638 + out_shard = torch.empty(
639 + batch, nh_s, headdim, device=device, dtype=act_dtype
640 + )
641 + selective_state_update_replayssm_output_only(
642 + shard_state[r],
643 + x[:, h0 : h0 + nh_s].contiguous(),
644 + dt[:, h0 : h0 + nh_s],
645 + A[h0 : h0 + nh_s],
646 + B[:, g0 : g0 + ng_s].contiguous(),
647 + C[:, g0 : g0 + ng_s].contiguous(),
648 + D=D[h0 : h0 + nh_s].contiguous(),
649 + z=z[:, h0 : h0 + nh_s].contiguous(),
650 + dt_bias=dt_bias[h0 : h0 + nh_s],
651 + dt_softplus=True,
652 + x_cache=xc,
653 + dt_cache=dtc,
654 + B_cache=bc,
655 + bc_pre=bcp,
656 + write_pos=write_pos,
657 + is_flush=is_flush,
658 + max_cache_len=max_cache_len,
659 + out=out_shard,
660 + )
661 +
662 + torch.testing.assert_close(
663 + out_full[:, h0 : h0 + nh_s], out_shard, rtol=rtol, atol=atol
664 + )
665 + if bool(is_flush.any()):
666 + torch.testing.assert_close(
667 + state_full[:, h0 : h0 + nh_s][is_flush],
668 + shard_state[r][is_flush],
669 + rtol=rtol,
670 + atol=atol,
671 + )
672 +
673 + write_pos = torch.where(is_flush, torch.zeros_like(write_pos), write_pos + 1)
tests/kernels/mamba/utils.py
+160
@@ -76,3 +76,163 @@ def selective_state_update_ref(
76 if not has_heads:
77 out = out.squeeze(1)
78 return out
79 +
80 +
81 +def selective_state_update_replayssm_output_only_ref(
82 + state: torch.Tensor,
83 + x: torch.Tensor,
84 + dt: torch.Tensor,
85 + A: torch.Tensor,
86 + B: torch.Tensor,
87 + C: torch.Tensor,
88 + D: torch.Tensor | None = None,
89 + z: torch.Tensor | None = None,
90 + dt_bias: torch.Tensor | None = None,
91 + dt_softplus: bool = False,
92 + x_cache: torch.Tensor | None = None,
93 + dt_cache: torch.Tensor | None = None,
94 + B_cache: torch.Tensor | None = None,
95 + write_pos: torch.Tensor | None = None,
96 + max_cache_len: int = 16,
97 +) -> torch.Tensor:
98 + """Pure-PyTorch cached-bc reference for validation."""
99 + has_heads = state.dim() > 3
100 + if state.dim() == 3:
101 + state = state.unsqueeze(1)
102 + if x.dim() == 2:
103 + x = x.unsqueeze(1)
104 + if dt.dim() == 2:
105 + dt = dt.unsqueeze(1)
106 + if A.dim() == 2:
107 + A = A.unsqueeze(0)
108 + if B.dim() == 2:
109 + B = B.unsqueeze(1)
110 + if C.dim() == 2:
111 + C = C.unsqueeze(1)
112 + if D is not None and D.dim() == 1:
113 + D = D.unsqueeze(0)
114 + if z is not None and z.dim() == 2:
115 + z = z.unsqueeze(1)
116 + if dt_bias is not None and dt_bias.dim() == 1:
117 + dt_bias = dt_bias.unsqueeze(0)
118 +
119 + batch, nheads, dim, dstate = state.shape
120 + assert x.shape == (batch, nheads, dim)
121 + assert dt.shape == x.shape
122 + assert A.shape == (nheads, dim, dstate)
123 + ngroups = B.shape[1]
124 + assert nheads % ngroups == 0, "nheads must be divisible by ngroups"
125 + assert B.shape == (batch, ngroups, dstate)
126 + assert C.shape == B.shape
127 +
128 + ratio = nheads // ngroups
129 +
130 + dt_val = dt[:, :, 0].float()
131 + if dt_bias is not None:
132 + dt_val = dt_val + dt_bias[:, 0].float()
133 + if dt_softplus:
134 + dt_val = F.softplus(dt_val)
135 + A_val = A[:, 0, 0].float()
136 + C_heads = C.repeat_interleave(ratio, dim=1)
137 + out = torch.empty(batch, nheads, dim, device=x.device, dtype=torch.float32)
138 +
139 + assert x_cache is not None
140 + assert dt_cache is not None
141 + assert B_cache is not None
142 + assert write_pos is not None
143 +
144 + for b in range(batch):
145 + cache_len = int(write_pos[b].item())
146 + is_flush = cache_len == max_cache_len - 1
147 + n_steps = cache_len + 1
148 +
149 + dt_all = torch.zeros(nheads, n_steps, device=x.device, dtype=torch.float32)
150 + if cache_len > 0:
151 + dt_all[:, :cache_len] = dt_cache[b, :, :cache_len]
152 + dt_all[:, cache_len] = dt_val[b]
153 +
154 + cumsum = torch.cumsum(dt_all, dim=-1)
155 + total = cumsum[:, -1]
156 + dA_cumsum = A_val[:, None] * cumsum
157 + dA_total = A_val * total
158 + total_decay = torch.exp(dA_total)
159 + scale = dt_all * torch.exp(dA_total[:, None] - dA_cumsum)
160 +
161 + x_all = torch.zeros(nheads, dim, n_steps, device=x.device, dtype=x.dtype)
162 + if cache_len > 0:
163 + x_all[..., :cache_len] = x_cache[b, :, :cache_len, :].permute(0, 2, 1)
164 + x_all[..., cache_len] = x[b]
165 +
166 + B_all = torch.zeros(ngroups, n_steps, dstate, device=B.device, dtype=B.dtype)
167 + if cache_len > 0:
168 + B_all[:, :cache_len, :] = B_cache[b, :, :cache_len, :]
169 + B_all[:, cache_len, :] = B[b]
170 +
171 + B_heads = B_all.repeat_interleave(ratio, dim=0)
172 + C_heads_b = C_heads[b]
173 +
174 + if is_flush:
175 + B_scaled = (B_heads.float() * scale[:, :, None]).to(B_heads.dtype)
176 + delta = torch.einsum("hdk,hkn->hdn", x_all.float(), B_scaled.float())
177 + state_new = state[b].float() * total_decay[:, None, None] + delta
178 + state[b].copy_(state_new.to(state.dtype))
179 + out[b] = torch.einsum("hdn,hn->hd", state_new, C_heads_b.float())
180 + else:
181 + checkpoint_out = torch.einsum(
182 + "hdn,hn->hd", state[b].float(), C_heads_b.float()
183 + )
184 + checkpoint_out = checkpoint_out * total_decay[:, None]
185 + BC = torch.einsum("hkn,hn->hk", B_heads.float(), C_heads_b.float())
186 + cache_out = torch.einsum("hdk,hk->hd", x_all.float(), scale * BC)
187 + out[b] = checkpoint_out + cache_out
188 + x_cache[b, :, cache_len, :] = x[b]
189 + dt_cache[b, :, cache_len] = dt_val[b]
190 + B_cache[b, :, cache_len, :] = B[b]
191 +
192 + if D is not None:
193 + out = out + (x.float() * D[None]).to(out.dtype)
194 + if z is not None:
195 + out = out * F.silu(z.float())
196 + out = out.to(x.dtype)
197 + if not has_heads:
198 + out = out.squeeze(1)
199 + return out
200 +
201 +
202 +def allocate_update_caches(
203 + batch: int,
204 + nheads: int,
205 + ngroups: int,
206 + dim: int,
207 + dstate: int,
208 + max_cache_len: int,
209 + device: torch.device,
210 + x_dtype: torch.dtype,
211 + B_dtype: torch.dtype,
212 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
213 + """Allocate dense reference caches for standalone validation."""
214 + x_cache = torch.zeros(
215 + batch,
216 + nheads,
217 + max_cache_len,
218 + dim,
219 + device=device,
220 + dtype=x_dtype,
221 + )
222 + dt_cache = torch.zeros(
223 + batch,
224 + nheads,
225 + max_cache_len,
226 + device=device,
227 + dtype=torch.float32,
228 + )
229 + B_cache = torch.zeros(
230 + batch,
231 + ngroups,
232 + max_cache_len,
233 + dstate,
234 + device=device,
235 + dtype=B_dtype,
236 + )
237 + write_pos = torch.zeros(batch, dtype=torch.int32, device=device)
238 + return x_cache, dt_cache, B_cache, write_pos
tests/models/test_registry.py
+16
@@ -134,6 +134,22 @@ def test_registry_is_pp(model_arch, is_pp, init_cuda):
134 )
135
136
137 +@create_new_process_for_each_test()
138 +@pytest.mark.parametrize(
139 + "model_arch,supported",
140 + [
141 + # ReplaySSM is opt-in per model; only Nemotron-H sets the flag today.
142 + ("NemotronHForCausalLM", True),
143 + ("Mamba2ForCausalLM", False),
144 + ("Zamba2ForCausalLM", False),
145 + ],
146 +)
147 +def test_registry_supports_replayssm(model_arch, supported):
148 + model_info = ModelRegistry._try_inspect_model_cls(model_arch)
149 + assert model_info is not None
150 + assert model_info.supports_replayssm is supported
151 +
152 +
153 def test_lazy_modelinfo_package_hash_includes_submodules(tmp_path):
154 package_dir = tmp_path / "model_package"
155 package_dir.mkdir()
tests/v1/attention/test_mamba_update_block_table.py
+2
@@ -42,6 +42,8 @@ def _make_vllm_config(
42 cache_config=SimpleNamespace(
43 block_size=block_size,
44 mamba_cache_mode="all",
45 + use_replayssm=False,
46 + replayssm_buffer_len=16,
47 ),
48 compilation_config=SimpleNamespace(
49 cudagraph_mode=CUDAGraphMode.FULL,
tests/v1/attention/test_replayssm_metadata_builder.py new
+256
@@ -0,0 +1,256 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Mamba2 ReplaySSM decode write-position derivation in
4 +BaseMambaAttentionMetadataBuilder: write_pos and is_flush computed from the
5 +per-request ring origin (replayssm_decode_base) and num_computed.
6 +"""
7 +
8 +from dataclasses import dataclass
9 +
10 +import pytest
11 +import torch
12 +
13 +from tests.v1.attention.utils import (
14 + BatchSpec,
15 + MockMambaBuilder,
16 + create_common_attn_metadata,
17 + create_vllm_config,
18 +)
19 +from vllm.v1.kv_cache_interface import MambaSpec
20 +
21 +BLOCK_SIZE = 16
22 +DEVICE = torch.device("cpu")
23 +
24 +
25 +@dataclass
26 +class ReplaySSMBuildCase:
27 + """A decode batch and its expected per-row write_pos / is_flush.
28 +
29 + num_computed = seq_len - query_len; write_pos =
30 + (num_computed - decode_base) % buffer_len; is_flush = write_pos ==
31 + buffer_len - 1 (or a forced one-token flush when num_computed < decode_base).
32 + """
33 +
34 + seq_lens: list[int]
35 + query_lens: list[int]
36 + is_prefilling: list[bool]
37 + decode_base: list[int]
38 + buffer_len: int
39 + expected_write_pos: list[int]
40 + expected_is_flush: list[int]
41 + mamba_cache_mode: str = "none"
42 +
43 +
44 +REPLAYSSM_BUILD_CASES = {
45 + # decode_base == num_prompt (fresh request).
46 + "fresh_decode": ReplaySSMBuildCase(
47 + seq_lens=[106],
48 + query_lens=[1],
49 + is_prefilling=[False],
50 + decode_base=[100],
51 + buffer_len=16,
52 + expected_write_pos=[5],
53 + expected_is_flush=[0],
54 + ),
55 + # decode_base > num_prompt anchors write_pos at the resume point.
56 + "resumed_reanchors_to_zero": ReplaySSMBuildCase(
57 + seq_lens=[106],
58 + query_lens=[1],
59 + is_prefilling=[False],
60 + decode_base=[105],
61 + buffer_len=16,
62 + expected_write_pos=[0],
63 + expected_is_flush=[0],
64 + ),
65 + # write_pos == buffer_len - 1 flushes.
66 + "flush_boundary": ReplaySSMBuildCase(
67 + seq_lens=[116],
68 + query_lens=[1],
69 + is_prefilling=[False],
70 + decode_base=[100],
71 + buffer_len=16,
72 + expected_write_pos=[15],
73 + expected_is_flush=[1],
74 + ),
75 + # Resumed request landing on a flush boundary.
76 + "resumed_flush_boundary": ReplaySSMBuildCase(
77 + seq_lens=[121],
78 + query_lens=[1],
79 + is_prefilling=[False],
80 + decode_base=[105],
81 + buffer_len=16,
82 + expected_write_pos=[15],
83 + expected_is_flush=[1],
84 + ),
85 + # Per-row write_pos / is_flush are independent.
86 + "mixed_rows": ReplaySSMBuildCase(
87 + seq_lens=[104, 106, 216],
88 + query_lens=[1, 1, 1],
89 + is_prefilling=[False, False, False],
90 + decode_base=[100, 105, 200],
91 + buffer_len=16,
92 + expected_write_pos=[3, 0, 15],
93 + expected_is_flush=[0, 0, 1],
94 + ),
95 + # write_pos wraps within the buffer (6 % 4 == 2).
96 + "small_buffer_wrap": ReplaySSMBuildCase(
97 + seq_lens=[112],
98 + query_lens=[1],
99 + is_prefilling=[False],
100 + decode_base=[105],
101 + buffer_len=4,
102 + expected_write_pos=[2],
103 + expected_is_flush=[0],
104 + ),
105 + # Single-token prefill-as-decode still in the prompt (num_computed <
106 + # decode_base): forced one-token flush.
107 + "leftover_prompt_one_token_flush": ReplaySSMBuildCase(
108 + seq_lens=[100],
109 + query_lens=[1],
110 + is_prefilling=[True],
111 + decode_base=[100],
112 + buffer_len=16,
113 + expected_write_pos=[0],
114 + expected_is_flush=[1],
115 + ),
116 + # Align mode (block_size 16). Past the first boundary the ring re-anchors at
117 + # the block start: num_computed 117 -> block_start 112, write_pos 5 (vs 1 in
118 + # none mode).
119 + "align_reanchor_past_boundary": ReplaySSMBuildCase(
120 + seq_lens=[118],
121 + query_lens=[1],
122 + is_prefilling=[False],
123 + decode_base=[100],
124 + buffer_len=16,
125 + expected_write_pos=[5],
126 + expected_is_flush=[0],
127 + mamba_cache_mode="align",
128 + ),
129 + # First-block boundary (num_computed+1 == 112) forces a flush even though
130 + # write_pos (11) != buffer_len - 1.
131 + "align_first_block_boundary_flush": ReplaySSMBuildCase(
132 + seq_lens=[112],
133 + query_lens=[1],
134 + is_prefilling=[False],
135 + decode_base=[100],
136 + buffer_len=16,
137 + expected_write_pos=[11],
138 + expected_is_flush=[1],
139 + mamba_cache_mode="align",
140 + ),
141 + # First step of a new block re-anchors write_pos to 0.
142 + "align_new_block_start_zero": ReplaySSMBuildCase(
143 + seq_lens=[113],
144 + query_lens=[1],
145 + is_prefilling=[False],
146 + decode_base=[100],
147 + buffer_len=16,
148 + expected_write_pos=[0],
149 + expected_is_flush=[0],
150 + mamba_cache_mode="align",
151 + ),
152 + # block_size % buffer_len == 0: a later boundary lands on write_pos ==
153 + # buffer_len - 1, so the boundary flush coincides with the natural flush.
154 + "align_boundary_coincides_natural_flush": ReplaySSMBuildCase(
155 + seq_lens=[128],
156 + query_lens=[1],
157 + is_prefilling=[False],
158 + decode_base=[100],
159 + buffer_len=16,
160 + expected_write_pos=[15],
161 + expected_is_flush=[1],
162 + mamba_cache_mode="align",
163 + ),
164 + # block_size % buffer_len != 0 (buffer_len 6): the boundary step still flushes
165 + # although write_pos (3) != buffer_len - 1.
166 + "align_unaligned_buffer_forces_flush": ReplaySSMBuildCase(
167 + seq_lens=[128],
168 + query_lens=[1],
169 + is_prefilling=[False],
170 + decode_base=[100],
171 + buffer_len=6,
172 + expected_write_pos=[3],
173 + expected_is_flush=[1],
174 + mamba_cache_mode="align",
175 + ),
176 + # Per-row independence in align mode: partial-block / new-block / boundary.
177 + "align_mixed_rows": ReplaySSMBuildCase(
178 + seq_lens=[105, 113, 112],
179 + query_lens=[1, 1, 1],
180 + is_prefilling=[False, False, False],
181 + decode_base=[100, 100, 100],
182 + buffer_len=16,
183 + expected_write_pos=[4, 0, 11],
184 + expected_is_flush=[0, 0, 1],
185 + mamba_cache_mode="align",
186 + ),
187 +}
188 +
189 +
190 +def _make_mamba_spec(buffer_len: int) -> MambaSpec:
191 + # Five-tensor ReplaySSM page; the builder only reads shapes[4][0] (bc groups).
192 + return MambaSpec(
193 + block_size=BLOCK_SIZE,
194 + shapes=(
195 + (1, 1),
196 + (1, 1, 1),
197 + (1, buffer_len, 1),
198 + (1, buffer_len),
199 + (1, buffer_len, 1),
200 + ),
201 + dtypes=(torch.float32,),
202 + )
203 +
204 +
205 +def _create_replayssm_builder(
206 + buffer_len: int, mamba_cache_mode: str = "none"
207 +) -> MockMambaBuilder:
208 + vllm_config = create_vllm_config(
209 + model_name="Qwen/Qwen3.5-0.8B", block_size=BLOCK_SIZE
210 + )
211 + # Set the flags after construction to skip validate_mamba_cached_kernel
212 + # (it requires a Triton backend) on the mock model.
213 + vllm_config.cache_config.use_replayssm = True
214 + vllm_config.cache_config.replayssm_buffer_len = buffer_len
215 + vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
216 + return MockMambaBuilder(
217 + _make_mamba_spec(buffer_len), ["layer0"], vllm_config, DEVICE
218 + )
219 +
220 +
221 +def _build(builder: MockMambaBuilder, case: ReplaySSMBuildCase):
222 + batch = BatchSpec(seq_lens=case.seq_lens, query_lens=case.query_lens)
223 + common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE).replace(
224 + is_prefilling=torch.tensor(case.is_prefilling, dtype=torch.bool),
225 + replayssm_decode_base_cpu=torch.tensor(case.decode_base, dtype=torch.int32),
226 + )
227 + return builder.build(0, common)
228 +
229 +
230 +@pytest.mark.parametrize(
231 + "case", REPLAYSSM_BUILD_CASES.values(), ids=REPLAYSSM_BUILD_CASES.keys()
232 +)
233 +def test_replayssm_write_pos(case: ReplaySSMBuildCase):
234 + builder = _create_replayssm_builder(case.buffer_len, case.mamba_cache_mode)
235 + meta = _build(builder, case)
236 +
237 + assert meta.write_pos_d is not None
238 + assert meta.is_flush_d is not None
239 + n = len(case.expected_write_pos)
240 + assert meta.write_pos_d[:n].tolist() == case.expected_write_pos
241 + assert meta.is_flush_d[:n].tolist() == case.expected_is_flush
242 +
243 +
244 +def test_resumed_request_differs_from_fresh():
245 + """Same token count, different decode_base: fresh (base 100) -> write_pos 5,
246 + resumed (base 105) -> write_pos 0."""
247 + builder = _create_replayssm_builder(16)
248 + batch = BatchSpec(seq_lens=[106, 106], query_lens=[1, 1])
249 + common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE).replace(
250 + is_prefilling=torch.tensor([False, False]),
251 + replayssm_decode_base_cpu=torch.tensor([100, 105], dtype=torch.int32),
252 + )
253 + meta = builder.build(0, common)
254 +
255 + assert meta.write_pos_d.tolist()[:2] == [5, 0]
256 + assert meta.is_flush_d.tolist()[:2] == [0, 0]
tests/v1/e2e/test_replayssm_decode.py new
+138
@@ -0,0 +1,138 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Engine-level parity: ReplaySSM standard decode vs the baseline SSM kernel."""
4 +
5 +import pytest
6 +
7 +from vllm.v1.metrics.reader import Counter
8 +
9 +from ...models.utils import check_logprobs_close
10 +from ...utils import large_gpu_mark, multi_gpu_test
11 +
12 +# Mamba2 (Nemotron-3) hybrid.
13 +MAMBA2_MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
14 +MODELS = [
15 + pytest.param(MAMBA2_MODEL, marks=large_gpu_mark(min_gb=40)),
16 +]
17 +
18 +PROMPTS = [
19 + "The capital of France is",
20 + "Once upon a time, in a small village,",
21 +]
22 +
23 +
24 +def _check_replayssm_parity(vllm_runner, model_name, *, tensor_parallel_size=1):
25 + # Compare logprobs, not greedy ids: ReplaySSM's fp arithmetic can flip a
26 + # near-tie. Baseline and ReplaySSM run at the same TP, so TP numerics are
27 + # common-mode and only ReplaySSM varies.
28 + common = dict(
29 + max_model_len=1024,
30 + trust_remote_code=True,
31 + enable_prefix_caching=False,
32 + mamba_cache_mode="none",
33 + tensor_parallel_size=tensor_parallel_size,
34 + )
35 + with vllm_runner(model_name, **common) as llm:
36 + baseline = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
37 + with vllm_runner(
38 + model_name, use_replayssm=True, replayssm_buffer_len=16, **common
39 + ) as llm:
40 + replay = llm.generate_greedy_logprobs(PROMPTS, max_tokens=32, num_logprobs=5)
41 +
42 + check_logprobs_close(
43 + outputs_0_lst=baseline,
44 + outputs_1_lst=replay,
45 + name_0="baseline",
46 + name_1="replayssm",
47 + )
48 +
49 +
50 +@pytest.mark.parametrize("model_name", MODELS)
51 +def test_replayssm_decode_matches_baseline(vllm_runner, model_name):
52 + _check_replayssm_parity(vllm_runner, model_name)
53 +
54 +
55 +@multi_gpu_test(num_gpus=2)
56 +@pytest.mark.parametrize("model_name", [MAMBA2_MODEL])
57 +def test_replayssm_decode_matches_baseline_tp2(vllm_runner, model_name):
58 + # Tensor-parallel correctness: ReplaySSM's caches and checkpoint state are
59 + # sharded per rank, so TP2 decode must still match the baseline at TP2.
60 + _check_replayssm_parity(vllm_runner, model_name, tensor_parallel_size=2)
61 +
62 +
63 +# Prefix spans several mamba blocks; prefix caching only reuses full blocks.
64 +_PC_SENTENCE = (
65 + "In a detailed survey of state space models, the authors compared many "
66 + "architectures across a wide range of long-context language tasks and "
67 + "measured their throughput, memory use, and accuracy in careful detail. "
68 +)
69 +_PC_PREFIX = _PC_SENTENCE * 120
70 +PREFIX_CACHING_PROMPTS = [
71 + _PC_PREFIX + "The most important conclusion was that",
72 + _PC_PREFIX + "Surprisingly, the experiments showed that",
73 + _PC_PREFIX + "The most important conclusion was that",
74 +]
75 +
76 +
77 +def _prefix_cache_hits(llm) -> int:
78 + return sum(
79 + m.value
80 + for m in llm.llm.get_metrics()
81 + if isinstance(m, Counter) and m.name == "vllm:prefix_cache_hits"
82 + )
83 +
84 +
85 +def _check_replayssm_prefix_caching_parity(
86 + vllm_runner, model_name, *, tensor_parallel_size=1
87 +):
88 + # align mode materializes the exact SSM state at each block boundary, so
89 + # ReplaySSM's cached prefixes must match the always-materialized baseline.
90 + common = dict(
91 + max_model_len=8192,
92 + trust_remote_code=True,
93 + enable_prefix_caching=True,
94 + enable_chunked_prefill=True,
95 + mamba_cache_mode="align",
96 + disable_log_stats=False, # required for llm.get_metrics()
97 + tensor_parallel_size=tensor_parallel_size,
98 + )
99 + with vllm_runner(model_name, **common) as llm:
100 + baseline = llm.generate_greedy_logprobs(
101 + PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5
102 + )
103 + with vllm_runner(
104 + model_name, use_replayssm=True, replayssm_buffer_len=16, **common
105 + ) as llm:
106 + # Prime the cache, then measure, so cache hits are deterministic.
107 + llm.generate_greedy_logprobs(
108 + PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5
109 + )
110 + replay = llm.generate_greedy_logprobs(
111 + PREFIX_CACHING_PROMPTS, max_tokens=32, num_logprobs=5
112 + )
113 + replay_hits = _prefix_cache_hits(llm)
114 +
115 + # Without real cache hits the cached path is never exercised.
116 + assert replay_hits > 0, (
117 + "ReplaySSM align-mode run produced no prefix-cache hits; the shared "
118 + "prefix may be shorter than one mamba block, so prefix caching is inert"
119 + )
120 + check_logprobs_close(
121 + outputs_0_lst=baseline,
122 + outputs_1_lst=replay,
123 + name_0="baseline_align_pc",
124 + name_1="replayssm_align_pc",
125 + )
126 +
127 +
128 +@pytest.mark.parametrize("model_name", MODELS)
129 +def test_replayssm_prefix_caching_matches_baseline(vllm_runner, model_name):
130 + _check_replayssm_prefix_caching_parity(vllm_runner, model_name)
131 +
132 +
133 +@multi_gpu_test(num_gpus=2)
134 +@pytest.mark.parametrize("model_name", [MAMBA2_MODEL])
135 +def test_replayssm_prefix_caching_matches_baseline_tp2(vllm_runner, model_name):
136 + _check_replayssm_prefix_caching_parity(
137 + vllm_runner, model_name, tensor_parallel_size=2
138 + )
vllm/config/cache.py
+11
@@ -145,6 +145,17 @@ class CacheConfig:
145 - "align": only cache the mamba state of the last token of each scheduler step and
146 when the token is at position i * block_size.
147 """
148 + replayssm_buffer_len: int = Field(default=16, gt=0)
149 + """ReplaySSM history buffer length B: with use_replayssm, standard decode
150 + caches recent SSM inputs in a size-B ring buffer and flushes the checkpoint
151 + state to HBM every B steps. Default 16."""
152 + use_replayssm: bool = False
153 + """Use the ReplaySSM Mamba2 decode kernel: cache recent SSM inputs and skip
154 + the per-step full-state store, writing the checkpoint back only on flush.
155 + Requires mamba_cache_mode 'none' or 'align' (prefix caching) and the Triton
156 + mamba backend; standard (non-speculative) decode only. In align mode flushes
157 + are most efficient when mamba_block_size is a multiple of replayssm_buffer_len,
158 + but this is not required."""
159
160 # Will be set after profiling.
161 num_gpu_blocks: int | None = field(default=None, init=False)
vllm/config/model.py
+4
@@ -1696,6 +1696,10 @@ class ModelConfig:
1696 def supports_mamba_prefix_caching(self) -> bool:
1697 return self._model_info.supports_mamba_prefix_caching
1698
1699 + @property
1700 + def supports_replayssm(self) -> bool:
1701 + return self._model_info.supports_replayssm
1702 +
1703 @property
1704 def use_mla(self) -> bool:
1705 return self.is_deepseek_mla and not envs.VLLM_MLA_DISABLE
vllm/config/vllm.py
+32 -1
@@ -39,7 +39,7 @@ from .kv_events import KVEventsConfig
39 from .kv_transfer import KVTransferConfig
40 from .load import LoadConfig
41 from .lora import LoRAConfig
42 -from .mamba import MambaConfig
42 +from .mamba import MambaBackendEnum, MambaConfig
43 from .model import ModelConfig
44 from .observability import ObservabilityConfig
45 from .offload import OffloadConfig
@@ -2308,6 +2308,37 @@ class VllmConfig:
2308 )
2309 return self
2310
2311 + @model_validator(mode="after")
2312 + def validate_mamba_cached_kernel(self) -> "VllmConfig":
2313 + if not self.cache_config.use_replayssm:
2314 + return self
2315 + # ReplaySSM adds a 3-tensor ring to the mamba state; only models that
2316 + # opt in (supports_replayssm) build a consistent shape on both the layer
2317 + # and config paths. Reject others so the mamba page size cannot desync.
2318 + if self.model_config is not None and not self.model_config.supports_replayssm:
2319 + raise ValueError(
2320 + "--use-replayssm is only supported for Nemotron-H models "
2321 + f"(got architecture {self.model_config.architecture!r})"
2322 + )
2323 + if self.cache_config.mamba_cache_mode == "all":
2324 + raise ValueError(
2325 + "--use-replayssm supports prefix caching only in align mode; "
2326 + "pass --mamba-cache-mode align"
2327 + )
2328 + if self.num_speculative_tokens > 0:
2329 + raise ValueError("--use-replayssm does not support speculative decoding")
2330 + if self.mamba_config.backend != MambaBackendEnum.TRITON:
2331 + raise ValueError("--use-replayssm requires --mamba-backend triton")
2332 + if (
2333 + self.kv_transfer_config is not None
2334 + and self.kv_transfer_config.is_kv_transfer_instance
2335 + ):
2336 + raise ValueError(
2337 + "--use-replayssm is incompatible with KV connectors "
2338 + "(P/D disaggregation, KV cache offload)"
2339 + )
2340 + return self
2341 +
2342
2343 _current_vllm_config: VllmConfig | None = None
2344 _current_prefix: str | None = None
vllm/engine/arg_utils.py
+8
@@ -695,6 +695,8 @@ class EngineArgs:
695 mamba_block_size: int | None = get_field(CacheConfig, "mamba_block_size")
696 prefix_match_unit: int | None = get_field(CacheConfig, "prefix_match_unit")
697 mamba_cache_mode: MambaCacheMode = CacheConfig.mamba_cache_mode
698 + replayssm_buffer_len: int = CacheConfig.replayssm_buffer_len
699 + use_replayssm: bool = CacheConfig.use_replayssm
700
701 mamba_backend: MambaBackendEnum = MambaBackendEnum.TRITON
702 enable_mamba_cache_stochastic_rounding: bool = (
@@ -1201,6 +1203,10 @@ class EngineArgs:
1203 cache_group.add_argument(
1204 "--mamba-cache-mode", **cache_kwargs["mamba_cache_mode"]
1205 )
1206 + cache_group.add_argument(
1207 + "--replayssm-buffer-len", **cache_kwargs["replayssm_buffer_len"]
1208 + )
1209 + cache_group.add_argument("--use-replayssm", **cache_kwargs["use_replayssm"])
1210 cache_group.add_argument(
1211 "--kv-offloading-size", **cache_kwargs["kv_offloading_size"]
1212 )
@@ -1910,6 +1916,8 @@ class EngineArgs:
1916 mamba_block_size=self.mamba_block_size,
1917 prefix_match_unit=self.prefix_match_unit,
1918 mamba_cache_mode=self.mamba_cache_mode,
1919 + replayssm_buffer_len=self.replayssm_buffer_len,
1920 + use_replayssm=self.use_replayssm,
1921 kv_offloading_size=self.kv_offloading_size,
1922 kv_offloading_backend=self.kv_offloading_backend,
1923 )
vllm/model_executor/layers/mamba/mamba_mixer2.py
+96 -24
@@ -32,6 +32,9 @@ from vllm.model_executor.layers.mamba.ops.causal_conv1d import (
32 causal_conv1d_update,
33 )
34 from vllm.model_executor.layers.mamba.ops.layernorm_gated import rms_norm_gated
35 +from vllm.model_executor.layers.mamba.ops.selective_state_update_replayssm_output_only import ( # noqa: E501
36 + selective_state_update_replayssm_output_only,
37 +)
38 from vllm.model_executor.layers.mamba.ops.ssd_combined import (
39 mamba_chunk_scan_combined_varlen,
40 )
@@ -494,12 +497,27 @@ class MambaMixer2(MambaBase, PluggableLayer):
497 if prefix in compilation_config.static_forward_context:
498 raise ValueError(f"Duplicate layer name: {prefix}")
499 compilation_config.static_forward_context[prefix] = self
497 - # The tuple is (conv_state, ssm_state)
498 - self.kv_cache = (torch.tensor([]), torch.tensor([]))
500
501 self.model_config = model_config
502 self.cache_config = cache_config
503 self.prefix = prefix
504 + self.use_replayssm = (
505 + cache_config.use_replayssm if cache_config is not None else False
506 + )
507 + self.replayssm_buffer_len = (
508 + cache_config.replayssm_buffer_len
509 + if cache_config is not None and cache_config.use_replayssm
510 + else None
511 + )
512 + self.mamba_config = vllm_config.mamba_config
513 + if self.use_replayssm and self.num_heads % self.tp_size != 0:
514 + raise ValueError(
515 + "--use-replayssm requires tensor-parallel heads to divide evenly"
516 + )
517 + # The tuple is (conv_state, ssm_state); with the cached (ReplaySSM) decode
518 + # kernel enabled it is (conv_state, ssm_state, x_cache, dt_cache, B_cache).
519 + _n_state = 5 if self.use_replayssm else 2
520 + self.kv_cache = tuple(torch.tensor([]) for _ in range(_n_state))
521
522 self.num_spec = vllm_config.num_speculative_tokens
523 if self.num_spec > 0:
@@ -591,7 +609,7 @@ class MambaMixer2(MambaBase, PluggableLayer):
609
610 # Triton's autotuner includes tensor dtypes in its cache key,
611 # so state_dtype must match what real inference uses.
594 - _, ssm_state_dtype = self.get_state_dtype()
612 + ssm_state_dtype = self.get_state_dtype()[1]
613
614 # SSD kernel autotune keys depend on dtype and head dimensions,
615 # not on sequence length or batch size, so a single shape suffices.
@@ -702,6 +720,10 @@ class MambaMixer2(MambaBase, PluggableLayer):
720 else self.kv_cache[0].transpose(-1, -2)
721 )
722 ssm_state = self.kv_cache[1]
723 + if self.use_replayssm:
724 + x_cache, dt_cache, B_cache = self.kv_cache[2:]
725 + else:
726 + x_cache = dt_cache = B_cache = None
727 has_initial_states_p = attn_metadata.has_initial_states_p
728 prep_initial_states = attn_metadata.prep_initial_states
729 chunk_size = attn_metadata.chunk_size
@@ -1027,37 +1049,78 @@ class MambaMixer2(MambaBase, PluggableLayer):
1049 # - mamba_cache_params.ssm_state's slots will be selected
1050 # using state_indices_tensor_d
1051 # NOTE: final output is an in-place update of out tensor
1030 - selective_state_update(
1031 - ssm_state,
1032 - hidden_states_d,
1033 - dt_d,
1034 - A_d,
1035 - B_d,
1036 - C_d,
1037 - D_d,
1038 - dt_bias,
1039 - dt_softplus=True,
1040 - state_batch_indices=state_indices_tensor_d_input,
1041 - dst_state_batch_indices=state_indices_tensor_d_output,
1042 - out=preallocated_ssm_out_d.view(num_decode_tokens, -1, self.head_dim),
1043 - num_accepted_tokens=num_accepted_tokens,
1044 - cu_seqlens=query_start_loc_d,
1045 - is_blackwell=self.is_blackwell,
1052 + preallocated_ssm_out_d = preallocated_ssm_out_d.view(
1053 + num_decode_tokens, -1, self.head_dim
1054 )
1055 + if self.use_replayssm:
1056 + assert self.replayssm_buffer_len is not None
1057 + selective_state_update_replayssm_output_only(
1058 + ssm_state,
1059 + hidden_states_d,
1060 + dt_d,
1061 + A_d,
1062 + B_d,
1063 + C_d,
1064 + D_d,
1065 + dt_bias,
1066 + dt_softplus=True,
1067 + x_cache=x_cache,
1068 + dt_cache=dt_cache,
1069 + B_cache=B_cache,
1070 + bc_pre=attn_metadata.bc_pre_scratch,
1071 + write_pos=attn_metadata.write_pos_d,
1072 + is_flush=attn_metadata.is_flush_d,
1073 + max_cache_len=self.replayssm_buffer_len,
1074 + state_batch_indices=state_indices_tensor_d_input,
1075 + out=preallocated_ssm_out_d,
1076 + # Stochastic Rounding for the vanilla decode path is read
1077 + # from mamba_config inside ssu_dispatch; the replay kernel
1078 + # isn't a dispatch backend, so pass it here.
1079 + enable_stochastic_rounding=(
1080 + self.mamba_config.enable_stochastic_rounding
1081 + ),
1082 + cache_philox_rounds=(
1083 + self.mamba_config.stochastic_rounding_philox_rounds
1084 + ),
1085 + )
1086 + else:
1087 + selective_state_update(
1088 + ssm_state,
1089 + hidden_states_d,
1090 + dt_d,
1091 + A_d,
1092 + B_d,
1093 + C_d,
1094 + D_d,
1095 + dt_bias,
1096 + dt_softplus=True,
1097 + state_batch_indices=state_indices_tensor_d_input,
1098 + dst_state_batch_indices=state_indices_tensor_d_output,
1099 + out=preallocated_ssm_out_d,
1100 + num_accepted_tokens=num_accepted_tokens,
1101 + cu_seqlens=query_start_loc_d,
1102 + is_blackwell=self.is_blackwell,
1103 + )
1104
1048 - def get_state_dtype(self) -> tuple[torch.dtype, torch.dtype]:
1105 + def get_state_dtype(self) -> tuple[torch.dtype, ...]:
1106 assert self.model_config is not None
1107 assert self.cache_config is not None
1051 - return MambaStateDtypeCalculator.mamba2_state_dtype(
1108 + base_dtype = MambaStateDtypeCalculator.mamba2_state_dtype(
1109 self.model_config.dtype,
1110 self.cache_config.mamba_cache_dtype,
1111 self.cache_config.mamba_ssm_cache_dtype,
1112 )
1113 + if self.use_replayssm:
1114 + return MambaStateDtypeCalculator.append_replayssm_ring(
1115 + base_dtype, self.model_config.dtype
1116 + )
1117 + return base_dtype
1118
1057 - def get_state_shape(self) -> tuple[tuple[int, ...], tuple[int, ...]]:
1058 - return MambaStateShapeCalculator.mamba2_state_shape(
1119 + def get_state_shape(self) -> tuple[tuple[int, ...], ...]:
1120 + tp_world_size = get_tensor_model_parallel_world_size()
1121 + base_shape = MambaStateShapeCalculator.mamba2_state_shape(
1122 intermediate_size=self.intermediate_size,
1060 - tp_world_size=get_tensor_model_parallel_world_size(),
1123 + tp_world_size=tp_world_size,
1124 n_groups=self.n_groups,
1125 num_heads=self.num_heads,
1126 head_dim=self.head_dim,
@@ -1065,6 +1128,15 @@ class MambaMixer2(MambaBase, PluggableLayer):
1128 conv_kernel=self.conv_kernel_size,
1129 num_spec=self.num_spec,
1130 )
1131 + if self.use_replayssm:
1132 + assert self.replayssm_buffer_len is not None
1133 + return MambaStateShapeCalculator.append_replayssm_ring(
1134 + base_shape,
1135 + self.n_groups,
1136 + tp_world_size,
1137 + self.replayssm_buffer_len,
1138 + )
1139 + return base_shape
1140
1141 @property
1142 def mamba_type(self) -> MambaAttentionBackendEnum:
vllm/model_executor/layers/mamba/mamba_utils.py
+34
@@ -80,6 +80,18 @@ class MambaStateDtypeCalculator:
80 model_dtype, mamba_cache_dtype, mamba_ssm_cache_dtype
81 )
82
83 + @classmethod
84 + def append_replayssm_ring(
85 + cls,
86 + base_dtypes: tuple[torch.dtype, ...],
87 + model_dtype: ModelDType | torch.dtype,
88 + ) -> tuple[torch.dtype, ...]:
89 + """Append the ReplaySSM ring dtypes to a base ``(conv, ssm)`` tuple:
90 + ``(x_cache, dt_cache, B_cache)`` = ``(activation, fp32, activation)``.
91 + """
92 + activation_dtype = get_kv_cache_torch_dtype("auto", model_dtype)
93 + return (*base_dtypes, activation_dtype, torch.float32, activation_dtype)
94 +
95 @classmethod
96 def _mamba_state_dtype(
97 cls,
@@ -186,6 +198,28 @@ class MambaStateShapeCalculator:
198 temporal_state_shape = (divide(num_heads, tp_world_size), head_dim, state_size)
199 return conv_state_shape, temporal_state_shape
200
201 + @classmethod
202 + def append_replayssm_ring(
203 + cls,
204 + base_shapes: tuple[tuple[int, ...], ...],
205 + n_groups: int,
206 + tp_world_size: int,
207 + replayssm_buffer_len: int,
208 + ) -> tuple[tuple[int, ...], ...]:
209 + """Append the ReplaySSM ring shapes (x_cache, dt_cache, B_cache) to a
210 + base ``(conv, ssm)`` tuple. ``base_shapes[1]`` is the ssm shape
211 + ``(nheads // tp, head_dim, state_size)``; B_cache uses the un-extended
212 + ``n_groups``.
213 + """
214 + local_nheads, head_dim, state_size = base_shapes[1]
215 + local_ngroups = divide(n_groups, tp_world_size)
216 + return (
217 + *base_shapes,
218 + (local_nheads, replayssm_buffer_len, head_dim),
219 + (local_nheads, replayssm_buffer_len),
220 + (local_ngroups, replayssm_buffer_len, state_size),
221 + )
222 +
223 @classmethod
224 def short_conv_state_shape(
225 cls,
vllm/model_executor/layers/mamba/ops/replayssm_config.py new
+66
@@ -0,0 +1,66 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Launch-config selection for the ReplaySSM Mamba2 output_only decode kernel.
4 +
5 +Mirrors ``mamba_ssm.py``: a hard-coded heuristic per kernel, plus an
6 +``override`` context manager for benchmarks/tests/config sweeps. Hardware is
7 +auto-detected (Blackwell vs not) so call sites need not thread it through.
8 +"""
9 +
10 +import functools
11 +from contextlib import contextmanager
12 +
13 +from vllm.platforms import current_platform
14 +from vllm.triton_utils import triton
15 +
16 +
17 +@functools.cache
18 +def _is_blackwell() -> bool:
19 + try:
20 + return current_platform.is_device_capability_family(100)
21 + except Exception:
22 + return False
23 +
24 +
25 +# Per-kernel overrides keyed by the kernel name passed to get_replayssm_config.
26 +_overrides: dict[str, tuple] = {}
27 +
28 +
29 +@contextmanager
30 +def override_replayssm_config(kernel: str, config: tuple):
31 + """Pin ``kernel``'s launch config for the duration of the context."""
32 + prev = _overrides.get(kernel)
33 + _overrides[kernel] = config
34 + try:
35 + yield
36 + finally:
37 + if prev is None:
38 + _overrides.pop(kernel, None)
39 + else:
40 + _overrides[kernel] = prev
41 +
42 +
43 +def _dstate_tile(dstate: int, tile: int) -> int:
44 + return max(16, min(tile, triton.next_power_of_2(dstate)))
45 +
46 +
47 +def _mamba2_output_only(dstate, L, is_blackwell):
48 + # (block_size_m, num_warps, nf_dstate_tile, fl_dstate_tile, num_stages);
49 + # decoupled dstate tiling, serving-batch optimum, dtype-independent per device.
50 + if is_blackwell:
51 + return 64, 1, _dstate_tile(dstate, 32), _dstate_tile(dstate, 64), 2
52 + return 16, 1, _dstate_tile(dstate, 64), _dstate_tile(dstate, 128), 2
53 +
54 +
55 +def get_replayssm_config(kernel: str, **shape) -> tuple:
56 + """Return the launch config for ``kernel`` (override > tuned default).
57 +
58 + kernel: "mamba2_output_only". ``shape`` carries the keying dims (dstate;
59 + ``L`` for the buffer length, default 16); hardware is auto-detected.
60 + """
61 + if kernel in _overrides:
62 + return _overrides[kernel]
63 + bw = _is_blackwell()
64 + if kernel == "mamba2_output_only":
65 + return _mamba2_output_only(shape["dstate"], shape.get("L", 16), bw)
66 + raise ValueError(f"unknown ReplaySSM kernel config key: {kernel}")
vllm/model_executor/layers/mamba/ops/selective_state_update_replayssm_output_only.py new
+709
@@ -0,0 +1,709 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# ruff: noqa: E501
4 +
5 +import torch
6 +
7 +from vllm.model_executor.layers.mamba.ops.mamba_ssm import convert_rs_fp16x2, softplus
8 +from vllm.model_executor.layers.mamba.ops.replayssm_config import get_replayssm_config
9 +from vllm.triton_utils import tl, triton
10 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
11 +
12 +
13 +@triton.heuristics(
14 + {
15 + "HAS_STATE_BATCH_INDICES": lambda args: args["state_batch_indices_ptr"]
16 + is not None
17 + }
18 +)
19 +@triton.heuristics(
20 + {"BLOCK_SIZE_DSTATE": lambda args: triton.next_power_of_2(args["dstate"])}
21 +)
22 +@triton.jit
23 +def _replayssm_output_only_precompute_kernel(
24 + B_ptr,
25 + C_ptr,
26 + B_cache_ptr,
27 + write_pos_ptr,
28 + is_flush_ptr,
29 + bc_pre_ptr,
30 + state_batch_indices_ptr,
31 + null_block_id,
32 + # Matrix dimensions
33 + batch,
34 + ngroups,
35 + dstate,
36 + # Input strides
37 + stride_B_batch,
38 + stride_B_group,
39 + stride_B_dstate,
40 + stride_C_batch,
41 + stride_C_group,
42 + stride_C_dstate,
43 + # Cache strides
44 + stride_B_cache_batch,
45 + stride_B_cache_group,
46 + stride_B_cache_pos,
47 + stride_B_cache_dstate,
48 + stride_bc_pre_batch,
49 + stride_bc_pre_group,
50 + stride_bc_pre_pos,
51 + stride_state_indices_batch,
52 + stride_state_indices_T,
53 + # Meta-parameters
54 + MAX_CACHE_LEN: tl.constexpr,
55 + BLOCK_SIZE_K: tl.constexpr,
56 + # heuristic-computed
57 + BLOCK_SIZE_DSTATE: tl.constexpr,
58 + HAS_STATE_BATCH_INDICES: tl.constexpr,
59 +):
60 + pid_b = tl.program_id(axis=0)
61 + pid_g = tl.program_id(axis=1)
62 +
63 + # On flush steps the main kernel does not read bc_pre, so skip the work.
64 + is_flush = tl.load(is_flush_ptr + pid_b) != 0
65 + if is_flush:
66 + return
67 +
68 + if HAS_STATE_BATCH_INDICES:
69 + state_batch_idx = tl.load(
70 + state_batch_indices_ptr
71 + + pid_b * stride_state_indices_batch
72 + + 0 * stride_state_indices_T
73 + ).to(tl.int64)
74 + if state_batch_idx == null_block_id:
75 + return
76 + else:
77 + state_batch_idx = pid_b
78 +
79 + offs_k = tl.arange(0, BLOCK_SIZE_K)
80 + offs_n = tl.arange(0, BLOCK_SIZE_DSTATE)
81 +
82 + write_pos = tl.load(write_pos_ptr + pid_b).to(tl.int64)
83 +
84 + B_ptr += pid_b * stride_B_batch + pid_g * stride_B_group
85 + C_ptr += pid_b * stride_C_batch + pid_g * stride_C_group
86 + B_cache_ptr += state_batch_idx * stride_B_cache_batch + pid_g * stride_B_cache_group
87 + bc_pre_ptr += pid_b * stride_bc_pre_batch + pid_g * stride_bc_pre_group
88 +
89 + B_cur = tl.load(
90 + B_ptr + offs_n * stride_B_dstate,
91 + mask=offs_n < dstate,
92 + other=0.0,
93 + )
94 + C = tl.load(
95 + C_ptr + offs_n * stride_C_dstate,
96 + mask=offs_n < dstate,
97 + other=0.0,
98 + )
99 + B_cache_ptrs = (
100 + B_cache_ptr
101 + + offs_k[:, None] * stride_B_cache_pos
102 + + offs_n[None, :] * stride_B_cache_dstate
103 + )
104 + B_cache = tl.load(
105 + B_cache_ptrs,
106 + mask=(offs_k[:, None] < write_pos) & (offs_n[None, :] < dstate),
107 + other=0.0,
108 + )
109 + B_all = tl.where(offs_k[:, None] == write_pos, B_cur[None, :], B_cache)
110 + bc = tl.sum(B_all.to(tl.float32) * C[None, :].to(tl.float32), axis=1)
111 +
112 + tl.store(
113 + bc_pre_ptr + offs_k * stride_bc_pre_pos,
114 + bc,
115 + mask=(offs_k <= write_pos) & (offs_k < MAX_CACHE_LEN),
116 + )
117 +
118 +
119 +@triton.heuristics({"HAS_DT_BIAS": lambda args: args["dt_bias_ptr"] is not None})
120 +@triton.heuristics({"HAS_D": lambda args: args["D_ptr"] is not None})
121 +@triton.heuristics({"HAS_Z": lambda args: args["z_ptr"] is not None})
122 +@triton.heuristics(
123 + {
124 + "HAS_STATE_BATCH_INDICES": lambda args: args["state_batch_indices_ptr"]
125 + is not None
126 + }
127 +)
128 +@triton.heuristics(
129 + {"BLOCK_SIZE_DSTATE": lambda args: triton.next_power_of_2(args["dstate"])}
130 +)
131 +@triton.jit
132 +def _replayssm_output_only_kernel(
133 + # Pointers to matrices
134 + state_ptr,
135 + rand_seed_ptr,
136 + x_ptr,
137 + dt_ptr,
138 + dt_bias_ptr,
139 + A_ptr,
140 + B_ptr,
141 + C_ptr,
142 + D_ptr,
143 + z_ptr,
144 + out_ptr,
145 + x_cache_ptr,
146 + dt_cache_ptr,
147 + B_cache_ptr,
148 + bc_pre_ptr,
149 + write_pos_ptr,
150 + is_flush_ptr,
151 + state_batch_indices_ptr,
152 + null_block_id,
153 + # Matrix dimensions
154 + batch,
155 + nheads,
156 + dim,
157 + dstate,
158 + nheads_ngroups_ratio,
159 + # State strides
160 + stride_state_batch,
161 + stride_state_head,
162 + stride_state_dim,
163 + stride_state_dstate,
164 + # Input strides
165 + stride_x_batch,
166 + stride_x_head,
167 + stride_x_dim,
168 + stride_dt_batch,
169 + stride_dt_head,
170 + stride_dt_bias_head,
171 + stride_A_head,
172 + stride_B_batch,
173 + stride_B_group,
174 + stride_B_dstate,
175 + stride_C_batch,
176 + stride_C_group,
177 + stride_C_dstate,
178 + stride_D_head,
179 + stride_D_dim,
180 + stride_z_batch,
181 + stride_z_head,
182 + stride_z_dim,
183 + stride_out_batch,
184 + stride_out_head,
185 + stride_out_dim,
186 + # Cache strides
187 + stride_x_cache_batch,
188 + stride_x_cache_head,
189 + stride_x_cache_dim,
190 + stride_x_cache_pos,
191 + stride_dt_cache_batch,
192 + stride_dt_cache_head,
193 + stride_dt_cache_pos,
194 + stride_B_cache_batch,
195 + stride_B_cache_group,
196 + stride_B_cache_pos,
197 + stride_B_cache_dstate,
198 + stride_bc_pre_batch,
199 + stride_bc_pre_group,
200 + stride_bc_pre_pos,
201 + stride_state_indices_batch,
202 + stride_state_indices_T,
203 + # Meta-parameters
204 + DT_SOFTPLUS: tl.constexpr,
205 + MAX_CACHE_LEN: tl.constexpr,
206 + BLOCK_SIZE_M: tl.constexpr,
207 + BLOCK_SIZE_K_CACHE: tl.constexpr,
208 + BLOCK_SIZE_K_DOT: tl.constexpr,
209 + NF_DSTATE_TILE: tl.constexpr,
210 + NF_NDS: tl.constexpr,
211 + FL_DSTATE_TILE: tl.constexpr,
212 + FL_NDS: tl.constexpr,
213 + USE_RS_ROUNDING: tl.constexpr,
214 + PHILOX_ROUNDS: tl.constexpr,
215 + # heuristic-computed
216 + BLOCK_SIZE_DSTATE: tl.constexpr,
217 + HAS_DT_BIAS: tl.constexpr,
218 + HAS_D: tl.constexpr,
219 + HAS_Z: tl.constexpr,
220 + HAS_STATE_BATCH_INDICES: tl.constexpr,
221 +):
222 + pid_m = tl.program_id(axis=0)
223 + pid_b = tl.program_id(axis=1)
224 + pid_h = tl.program_id(axis=2)
225 +
226 + # Resolve the physical state slot for this decode row; skip padded rows.
227 + if HAS_STATE_BATCH_INDICES:
228 + state_batch_idx = tl.load(
229 + state_batch_indices_ptr
230 + + pid_b * stride_state_indices_batch
231 + + 0 * stride_state_indices_T
232 + ).to(tl.int64)
233 + if state_batch_idx == null_block_id:
234 + return
235 + else:
236 + state_batch_idx = pid_b
237 +
238 + offs_m = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M)
239 + offs_n_full = tl.arange(0, BLOCK_SIZE_DSTATE)
240 +
241 + # Buffer cursor (number of cached tokens so far) and the flush flag.
242 + write_pos = tl.load(write_pos_ptr + pid_b).to(tl.int64)
243 + is_flush = tl.load(is_flush_ptr + pid_b) != 0
244 +
245 + # Advance every pointer to this (row, head, group).
246 + state_ptr += state_batch_idx * stride_state_batch + pid_h * stride_state_head
247 + x_ptr += pid_b * stride_x_batch + pid_h * stride_x_head
248 + dt_ptr += pid_b * stride_dt_batch + pid_h * stride_dt_head
249 + B_ptr += pid_b * stride_B_batch + (pid_h // nheads_ngroups_ratio) * stride_B_group
250 + C_ptr += pid_b * stride_C_batch + (pid_h // nheads_ngroups_ratio) * stride_C_group
251 + out_ptr += pid_b * stride_out_batch + pid_h * stride_out_head
252 + x_cache_ptr += state_batch_idx * stride_x_cache_batch + pid_h * stride_x_cache_head
253 + dt_cache_ptr += (
254 + state_batch_idx * stride_dt_cache_batch + pid_h * stride_dt_cache_head
255 + )
256 + B_cache_ptr += (
257 + state_batch_idx * stride_B_cache_batch
258 + + (pid_h // nheads_ngroups_ratio) * stride_B_cache_group
259 + )
260 + bc_pre_ptr += (
261 + pid_b * stride_bc_pre_batch
262 + + (pid_h // nheads_ngroups_ratio) * stride_bc_pre_group
263 + )
264 +
265 + # Current-token dt (+ bias, softplus), scalar A, and current x. C, the
266 + # checkpoint state S_0, and current-token B are read per dstate tile below.
267 + dt_cur = tl.load(dt_ptr).to(tl.float32)
268 + if HAS_DT_BIAS:
269 + dt_cur += tl.load(dt_bias_ptr + pid_h * stride_dt_bias_head).to(tl.float32)
270 + if DT_SOFTPLUS:
271 + dt_cur = tl.where(dt_cur <= 20.0, softplus(dt_cur), dt_cur)
272 + A = tl.load(A_ptr + pid_h * stride_A_head).to(tl.float32)
273 + x_cur = tl.load(x_ptr + offs_m * stride_x_dim, mask=offs_m < dim, other=0.0)
274 +
275 + if not is_flush:
276 + # Output-only route: read y without materializing the state, using the
277 + # precomputed k^T q products (`bc`):
278 + # y = total_decay * (S_0 q) + sum_j s_j (k_j^T q) v_j.
279 + # Then append the current token to the buffer.
280 + offs_k_cache = tl.arange(0, BLOCK_SIZE_K_CACHE)
281 + # dt over the window (history + current token), then the decay weights.
282 + dt_all_cache = tl.load(
283 + dt_cache_ptr + offs_k_cache * stride_dt_cache_pos,
284 + mask=offs_k_cache < write_pos,
285 + other=0.0,
286 + ).to(tl.float32)
287 + dt_all_cache = tl.where(offs_k_cache == write_pos, dt_cur, dt_all_cache)
288 + dA_cumsum_cache = A * tl.cumsum(dt_all_cache, axis=0)
289 + dA_total_cache = A * tl.sum(dt_all_cache, axis=0)
290 + total_decay_cache = tl.exp(dA_total_cache)
291 + scale_cache = dt_all_cache * tl.exp(dA_total_cache - dA_cumsum_cache)
292 + scale_cache = tl.where(offs_k_cache <= write_pos, scale_cache, 0.0)
293 +
294 + # Gather buffered x over the window (history + current token).
295 + x_all_cache_ptrs = (
296 + x_cache_ptr
297 + + offs_m[:, None] * stride_x_cache_dim
298 + + offs_k_cache[None, :] * stride_x_cache_pos
299 + )
300 + x_all_cache = tl.load(
301 + x_all_cache_ptrs,
302 + mask=(offs_m[:, None] < dim) & (offs_k_cache[None, :] < write_pos),
303 + other=0.0,
304 + )
305 + x_all_cache = tl.where(
306 + offs_k_cache[None, :] == write_pos, x_cur[:, None], x_all_cache
307 + )
308 +
309 + # Decayed checkpoint readout sum_n S_0(m,n) q(n), streamed over NF dstate
310 + # tiles so the (M, N) state slice is never held whole.
311 + offs_nt = tl.arange(0, NF_DSTATE_TILE)
312 + ck_acc = tl.zeros([BLOCK_SIZE_M], dtype=tl.float32)
313 + for i in tl.static_range(NF_NDS):
314 + offs_n = i * NF_DSTATE_TILE + offs_nt
315 + nmask = offs_n < dstate
316 + st = tl.load(
317 + state_ptr
318 + + offs_m[:, None] * stride_state_dim
319 + + offs_n[None, :] * stride_state_dstate,
320 + mask=(offs_m[:, None] < dim) & nmask[None, :],
321 + other=0.0,
322 + )
323 + c_chunk = tl.load(
324 + C_ptr + offs_n * stride_C_dstate, mask=nmask, other=0.0
325 + ).to(tl.float32)
326 + ck_acc += tl.sum(st.to(tl.float32) * c_chunk[None, :], axis=1)
327 + checkpoint_out = ck_acc * total_decay_cache
328 + bc_cache = tl.load(
329 + bc_pre_ptr + offs_k_cache * stride_bc_pre_pos,
330 + mask=offs_k_cache <= write_pos,
331 + other=0.0,
332 + )
333 + cache_out = tl.sum(
334 + x_all_cache.to(tl.float32) * (scale_cache * bc_cache)[None, :], axis=1
335 + )
336 + out = checkpoint_out + cache_out
337 +
338 + # Append the current token (x, dt, B) into the buffer at write_pos.
339 + tl.store(
340 + x_cache_ptr + offs_m * stride_x_cache_dim + write_pos * stride_x_cache_pos,
341 + x_cur,
342 + mask=offs_m < dim,
343 + )
344 + if pid_m == 0:
345 + B_cur = tl.load(
346 + B_ptr + offs_n_full * stride_B_dstate,
347 + mask=offs_n_full < dstate,
348 + other=0.0,
349 + )
350 + tl.store(dt_cache_ptr + write_pos * stride_dt_cache_pos, dt_cur)
351 + tl.store(
352 + B_cache_ptr
353 + + write_pos * stride_B_cache_pos
354 + + offs_n_full * stride_B_cache_dstate,
355 + B_cur,
356 + mask=offs_n_full < dstate,
357 + )
358 + else:
359 + # Flush step: state route. Reconstruct the state from cached inputs,
360 + # S_t = total_decay * S_0 + sum_j s_j (v_j k_j^T), persist it as the new
361 + # checkpoint, then read y = S_t q -- streamed over FL dstate tiles.
362 + offs_k_dot = tl.arange(0, BLOCK_SIZE_K_DOT)
363 + dt_all_dot = tl.load(
364 + dt_cache_ptr + offs_k_dot * stride_dt_cache_pos,
365 + mask=offs_k_dot < write_pos,
366 + other=0.0,
367 + ).to(tl.float32)
368 + dt_all_dot = tl.where(offs_k_dot == write_pos, dt_cur, dt_all_dot)
369 + dA_cumsum_dot = A * tl.cumsum(dt_all_dot, axis=0)
370 + dA_total_dot = A * tl.sum(dt_all_dot, axis=0)
371 + total_decay_dot = tl.exp(dA_total_dot)
372 + scale_dot = dt_all_dot * tl.exp(dA_total_dot - dA_cumsum_dot)
373 + scale_dot = tl.where(offs_k_dot <= write_pos, scale_dot, 0.0)
374 +
375 + # Gather buffered x over the window (history + current token).
376 + x_all_dot_ptrs = (
377 + x_cache_ptr
378 + + offs_m[:, None] * stride_x_cache_dim
379 + + offs_k_dot[None, :] * stride_x_cache_pos
380 + )
381 + x_all_dot = tl.load(
382 + x_all_dot_ptrs,
383 + mask=(offs_m[:, None] < dim) & (offs_k_dot[None, :] < write_pos),
384 + other=0.0,
385 + )
386 + x_all_dot = tl.where(
387 + offs_k_dot[None, :] == write_pos, x_cur[:, None], x_all_dot
388 + )
389 + x_all_ty = x_all_dot.to(x_ptr.dtype.element_ty)
390 +
391 + # Distinct tile locals (_f) from the nf branch: differing tile widths
392 + # would force a shape-mismatched merge at the if/else exit.
393 + offs_nt_f = tl.arange(0, FL_DSTATE_TILE)
394 + out = tl.zeros([BLOCK_SIZE_M], dtype=tl.float32)
395 + for i in tl.static_range(FL_NDS):
396 + offs_n_f = i * FL_DSTATE_TILE + offs_nt_f
397 + nmask_f = offs_n_f < dstate
398 + # Gather buffered B over the window (history + current token).
399 + B_all_dot = tl.load(
400 + B_cache_ptr
401 + + offs_k_dot[:, None] * stride_B_cache_pos
402 + + offs_n_f[None, :] * stride_B_cache_dstate,
403 + mask=(offs_k_dot[:, None] < write_pos) & nmask_f[None, :],
404 + other=0.0,
405 + )
406 + B_cur_tile = tl.load(
407 + B_ptr + offs_n_f * stride_B_dstate, mask=nmask_f, other=0.0
408 + )
409 + B_all_dot = tl.where(
410 + offs_k_dot[:, None] == write_pos, B_cur_tile[None, :], B_all_dot
411 + )
412 + # tf32x3 keeps fp32 parity with the elementwise baseline (plain tf32 on
413 + # fp32 inputs drifts ~1e-2); bf16/fp16 inputs are unaffected by this flag.
414 + B_scaled = (B_all_dot.to(tl.float32) * scale_dot[:, None]).to(
415 + x_ptr.dtype.element_ty
416 + )
417 + delta_state = tl.dot(x_all_ty, B_scaled, input_precision="tf32x3")
418 + state_ptrs = (
419 + state_ptr
420 + + offs_m[:, None] * stride_state_dim
421 + + offs_n_f[None, :] * stride_state_dstate
422 + )
423 + state_mask = (offs_m[:, None] < dim) & nmask_f[None, :]
424 + st_f = tl.load(state_ptrs, mask=state_mask, other=0.0)
425 + state_new = st_f.to(tl.float32) * total_decay_dot + delta_state.to(
426 + tl.float32
427 + )
428 + if USE_RS_ROUNDING:
429 + # Stochastic-round fp32->fp16 (Blackwell cvt.rs), mirroring the
430 + # baseline. Only the flush step stores state, so this runs at 1/L
431 + # the baseline's per-step rate. Absolute per-element offsets seed
432 + # the RNG so each state element draws independently.
433 + rand_seed = tl.load(rand_seed_ptr)
434 + rand_offsets = (
435 + state_batch_idx * stride_state_batch
436 + + pid_h * stride_state_head
437 + + offs_m[:, None] * stride_state_dim
438 + + offs_n_f[None, :] * stride_state_dstate
439 + )
440 + if PHILOX_ROUNDS > 0:
441 + rand = tl.randint(rand_seed, rand_offsets, PHILOX_ROUNDS)
442 + else:
443 + rand = tl.randint(rand_seed, rand_offsets)
444 + tl.static_assert(
445 + state_ptrs.dtype.element_ty == tl.float16,
446 + "stochastic rounding requires an fp16 SSM state cache",
447 + )
448 + state_store = convert_rs_fp16x2(state_new, rand)
449 + else:
450 + state_store = state_new.to(st_f.dtype)
451 + tl.store(state_ptrs, state_store, mask=state_mask)
452 + c_chunk_f = tl.load(
453 + C_ptr + offs_n_f * stride_C_dstate, mask=nmask_f, other=0.0
454 + ).to(tl.float32)
455 + out += tl.sum(state_new * c_chunk_f[None, :], axis=1)
456 +
457 + # Skip connection (D) and output gate (z).
458 + if HAS_D:
459 + D_ptr += pid_h * stride_D_head
460 + D = tl.load(D_ptr + offs_m * stride_D_dim, mask=offs_m < dim, other=0.0).to(
461 + tl.float32
462 + )
463 + out += x_cur.to(tl.float32) * D
464 + if HAS_Z:
465 + z_ptr += pid_b * stride_z_batch + pid_h * stride_z_head
466 + z = tl.load(z_ptr + offs_m * stride_z_dim, mask=offs_m < dim, other=0.0).to(
467 + tl.float32
468 + )
469 + out *= z * tl.sigmoid(z)
470 +
471 + tl.store(out_ptr + offs_m * stride_out_dim, out, mask=offs_m < dim)
472 +
473 +
474 +def selective_state_update_replayssm_output_only(
475 + state: torch.Tensor,
476 + x: torch.Tensor,
477 + dt: torch.Tensor,
478 + A: torch.Tensor,
479 + B: torch.Tensor,
480 + C: torch.Tensor,
481 + D: torch.Tensor | None = None,
482 + dt_bias: torch.Tensor | None = None,
483 + z: torch.Tensor | None = None,
484 + dt_softplus: bool = False,
485 + x_cache: torch.Tensor | None = None,
486 + dt_cache: torch.Tensor | None = None,
487 + B_cache: torch.Tensor | None = None,
488 + bc_pre: torch.Tensor | None = None,
489 + write_pos: torch.Tensor | None = None,
490 + is_flush: torch.Tensor | None = None,
491 + max_cache_len: int = 16,
492 + state_batch_indices: torch.Tensor | None = None,
493 + null_block_id: int = NULL_BLOCK_ID,
494 + out: torch.Tensor | None = None,
495 + enable_stochastic_rounding: bool = False,
496 + cache_philox_rounds: int = 0,
497 +) -> torch.Tensor:
498 + """Cached-bc SSM update for vLLM's autoregressive Mamba2 decode path."""
499 + has_heads = state.dim() > 3
500 + if state.dim() == 3:
501 + state = state.unsqueeze(1)
502 + if x.dim() == 2:
503 + x = x.unsqueeze(1)
504 + if dt.dim() == 2:
505 + dt = dt.unsqueeze(1)
506 + if A.dim() == 2:
507 + A = A.unsqueeze(0)
508 + if B.dim() == 2:
509 + B = B.unsqueeze(1)
510 + if C.dim() == 2:
511 + C = C.unsqueeze(1)
512 + if D is not None and D.dim() == 1:
513 + D = D.unsqueeze(0)
514 + if z is not None and z.dim() == 2:
515 + z = z.unsqueeze(1)
516 + if dt_bias is not None and dt_bias.dim() == 1:
517 + dt_bias = dt_bias.unsqueeze(0)
518 + if out is not None and out.dim() == 2:
519 + out = out.unsqueeze(1)
520 + if state_batch_indices is not None and state_batch_indices.dim() == 1:
521 + state_batch_indices = state_batch_indices.unsqueeze(1)
522 +
523 + _, nheads, dim, dstate = state.shape
524 + batch = x.shape[0]
525 + assert x.shape == (batch, nheads, dim)
526 + assert dt.shape == x.shape
527 + assert A.shape == (nheads, dim, dstate)
528 + ngroups = B.shape[1]
529 + assert nheads % ngroups == 0, "nheads must be divisible by ngroups"
530 + assert B.shape == (batch, ngroups, dstate)
531 + assert C.shape == B.shape
532 + if D is not None:
533 + assert D.shape == (nheads, dim)
534 + if z is not None:
535 + assert z.shape == x.shape
536 + if dt_bias is not None:
537 + assert dt_bias.shape == (nheads, dim)
538 + assert out is not None and out.shape == x.shape
539 +
540 + assert A.stride(-1) == 0 and A.stride(-2) == 0, (
541 + "Cached kernel requires TIE_HDIM (A scalar per head)"
542 + )
543 + assert dt.stride(-1) == 0, "Cached kernel requires TIE_HDIM (dt scalar per head)"
544 + if dt_bias is not None:
545 + assert dt_bias.stride(-1) == 0, (
546 + "Cached kernel requires TIE_HDIM (dt_bias scalar per head)"
547 + )
548 +
549 + assert x_cache is not None
550 + assert dt_cache is not None
551 + assert B_cache is not None
552 + assert x_cache.shape[1:] == (nheads, max_cache_len, dim)
553 + assert dt_cache.shape[1:] == (nheads, max_cache_len)
554 + assert B_cache.shape[1:] == (ngroups, max_cache_len, dstate)
555 + assert write_pos is not None and write_pos.shape[0] >= batch
556 + assert write_pos.dtype == torch.int32
557 + assert is_flush is not None and is_flush.shape[0] >= batch
558 + assert is_flush.dtype in (torch.bool, torch.int8)
559 + assert bc_pre is not None
560 + assert bc_pre.shape[0] >= batch and bc_pre.shape[1] >= ngroups
561 + assert bc_pre.shape[2] == max_cache_len
562 + assert bc_pre.dtype == torch.float32
563 + if state_batch_indices is not None:
564 + assert state_batch_indices.shape[0] >= batch
565 + assert state_batch_indices.shape[1] >= 1
566 +
567 + block_size_k_cache = max(1, triton.next_power_of_2(max_cache_len))
568 + block_size_k_dot = max(16, block_size_k_cache)
569 + block_size_m, num_warps, nf_tile, fl_tile, num_stages = get_replayssm_config(
570 + "mamba2_output_only", dstate=dstate, L=max_cache_len
571 + )
572 + bs_dstate = triton.next_power_of_2(dstate)
573 + nf_dstate_tile = max(16, min(nf_tile, bs_dstate))
574 + nf_nds = triton.cdiv(bs_dstate, nf_dstate_tile)
575 + fl_dstate_tile = max(16, min(fl_tile, bs_dstate))
576 + fl_nds = triton.cdiv(bs_dstate, fl_dstate_tile)
577 +
578 + grid = lambda META: (triton.cdiv(dim, META["BLOCK_SIZE_M"]), batch, nheads)
579 + z_strides = (z.stride(0), z.stride(1), z.stride(2)) if z is not None else (0, 0, 0)
580 + state_indices_strides = (
581 + (state_batch_indices.stride(0), state_batch_indices.stride(1))
582 + if state_batch_indices is not None
583 + else (0, 0)
584 + )
585 + rand_seed = (
586 + torch.randint(0, 2**32, (1,), device=state.device)
587 + if enable_stochastic_rounding
588 + else None
589 + )
590 +
591 + with torch.accelerator.device_index(x.device.index):
592 + # Both kernels always launch: the precompute kernel self-skips flush
593 + # rows per row (the branch can't be hoisted out under CUDA graphs), so
594 + # it is a no-op when every row is flushing.
595 + _replayssm_output_only_precompute_kernel[(batch, ngroups)](
596 + B,
597 + C,
598 + B_cache,
599 + write_pos,
600 + is_flush,
601 + bc_pre,
602 + state_batch_indices,
603 + null_block_id,
604 + batch,
605 + ngroups,
606 + dstate,
607 + B.stride(0),
608 + B.stride(1),
609 + B.stride(2),
610 + C.stride(0),
611 + C.stride(1),
612 + C.stride(2),
613 + B_cache.stride(0),
614 + B_cache.stride(1),
615 + B_cache.stride(2),
616 + B_cache.stride(3),
617 + bc_pre.stride(0),
618 + bc_pre.stride(1),
619 + bc_pre.stride(2),
620 + state_indices_strides[0],
621 + state_indices_strides[1],
622 + max_cache_len,
623 + block_size_k_cache,
624 + num_warps=2,
625 + )
626 + _replayssm_output_only_kernel[grid](
627 + state,
628 + rand_seed,
629 + x,
630 + dt,
631 + dt_bias,
632 + A,
633 + B,
634 + C,
635 + D,
636 + z,
637 + out,
638 + x_cache,
639 + dt_cache,
640 + B_cache,
641 + bc_pre,
642 + write_pos,
643 + is_flush,
644 + state_batch_indices,
645 + null_block_id,
646 + batch,
647 + nheads,
648 + dim,
649 + dstate,
650 + nheads // ngroups,
651 + state.stride(0),
652 + state.stride(1),
653 + state.stride(2),
654 + state.stride(3),
655 + x.stride(0),
656 + x.stride(1),
657 + x.stride(2),
658 + dt.stride(0),
659 + dt.stride(1),
660 + dt_bias.stride(0) if dt_bias is not None else 0,
661 + A.stride(0),
662 + B.stride(0),
663 + B.stride(1),
664 + B.stride(2),
665 + C.stride(0),
666 + C.stride(1),
667 + C.stride(2),
668 + D.stride(0) if D is not None else 0,
669 + D.stride(1) if D is not None else 0,
670 + z_strides[0],
671 + z_strides[1],
672 + z_strides[2],
673 + out.stride(0),
674 + out.stride(1),
675 + out.stride(2),
676 + x_cache.stride(0),
677 + x_cache.stride(1),
678 + x_cache.stride(3),
679 + x_cache.stride(2),
680 + dt_cache.stride(0),
681 + dt_cache.stride(1),
682 + dt_cache.stride(2),
683 + B_cache.stride(0),
684 + B_cache.stride(1),
685 + B_cache.stride(2),
686 + B_cache.stride(3),
687 + bc_pre.stride(0),
688 + bc_pre.stride(1),
689 + bc_pre.stride(2),
690 + state_indices_strides[0],
691 + state_indices_strides[1],
692 + dt_softplus,
693 + max_cache_len,
694 + block_size_m,
695 + block_size_k_cache,
696 + block_size_k_dot,
697 + nf_dstate_tile,
698 + nf_nds,
699 + fl_dstate_tile,
700 + fl_nds,
701 + enable_stochastic_rounding,
702 + cache_philox_rounds,
703 + num_warps=num_warps,
704 + num_stages=num_stages,
705 + )
706 +
707 + if not has_heads:
708 + out = out.squeeze(1)
709 + return out
vllm/model_executor/models/interfaces.py
+25
@@ -984,6 +984,31 @@ def supports_mamba_prefix_caching(
984 return getattr(model, "supports_mamba_prefix_caching", False)
985
986
987 +@runtime_checkable
988 +class SupportsReplaySSM(Protocol):
989 + """The interface for models whose Mamba2 layers support ReplaySSM cached
990 + standard decode.
991 +
992 + This is currently experimental.
993 + """
994 +
995 + supports_replayssm: ClassVar[Literal[True]] = True
996 +
997 +
998 +@overload
999 +def supports_replayssm(model: object) -> TypeIs[SupportsReplaySSM]: ...
1000 +
1001 +
1002 +@overload
1003 +def supports_replayssm(model: type[object]) -> TypeIs[type[SupportsReplaySSM]]: ...
1004 +
1005 +
1006 +def supports_replayssm(
1007 + model: type[object] | object,
1008 +) -> TypeIs[type[SupportsReplaySSM]] | TypeIs[SupportsReplaySSM]:
1009 + return getattr(model, "supports_replayssm", False)
1010 +
1011 +
1012 @runtime_checkable
1013 class SupportsCrossEncoding(Protocol):
1014 """The interface required for all models that support cross encoding."""
vllm/model_executor/models/nemotron_h.py
+25 -6
@@ -69,6 +69,7 @@ from vllm.model_executor.models.interfaces import (
69 SupportsMambaPrefixCaching,
70 SupportsPP,
71 SupportsQuant,
72 + SupportsReplaySSM,
73 )
74 from vllm.model_executor.models.utils import (
75 AutoWeightsLoader,
@@ -707,6 +708,7 @@ class NemotronHForCausalLM(
708 SupportsQuant,
709 MixtureOfExperts,
710 SupportsMambaPrefixCaching,
711 + SupportsReplaySSM,
712 ):
713 # Relevant only if self.has_moe is True
714 is_non_gated_moe: bool = True
@@ -742,18 +744,24 @@ class NemotronHForCausalLM(
744 def get_mamba_state_dtype_from_config(
745 cls,
746 vllm_config: "VllmConfig",
745 - ) -> tuple[torch.dtype, torch.dtype]:
746 - return MambaStateDtypeCalculator.mamba2_state_dtype(
747 + ) -> tuple[torch.dtype, ...]:
748 + cache_config = vllm_config.cache_config
749 + base_dtype = MambaStateDtypeCalculator.mamba2_state_dtype(
750 vllm_config.model_config.dtype,
748 - vllm_config.cache_config.mamba_cache_dtype,
749 - vllm_config.cache_config.mamba_ssm_cache_dtype,
751 + cache_config.mamba_cache_dtype,
752 + cache_config.mamba_ssm_cache_dtype,
753 )
754 + if cache_config.use_replayssm:
755 + return MambaStateDtypeCalculator.append_replayssm_ring(
756 + base_dtype, vllm_config.model_config.dtype
757 + )
758 + return base_dtype
759
760 @classmethod
761 def get_mamba_state_shape_from_config(
762 cls,
763 vllm_config: "VllmConfig",
756 - ) -> tuple[tuple[int, int], tuple[int, int, int]]:
764 + ) -> tuple[tuple[int, ...], ...]:
765 """Calculate shapes for Mamba's convolutional and state caches.
766
767 Args:
@@ -763,12 +771,15 @@ class NemotronHForCausalLM(
771 Tuple containing:
772 - conv_state_shape: Shape for convolutional state cache
773 - temporal_state_shape: Shape for state space model cache
774 + - (when use_replayssm is enabled) the x_cache/dt_cache/B_cache
775 + ring-buffer shapes
776 """
777 parallel_config = vllm_config.parallel_config
778 + cache_config = vllm_config.cache_config
779 hf_config = vllm_config.model_config.hf_config
780 intermediate_size = hf_config.mamba_num_heads * hf_config.mamba_head_dim
781
771 - return MambaStateShapeCalculator.mamba2_state_shape(
782 + base_shape = MambaStateShapeCalculator.mamba2_state_shape(
783 intermediate_size=intermediate_size,
784 tp_world_size=parallel_config.tensor_parallel_size,
785 n_groups=hf_config.n_groups,
@@ -778,6 +789,14 @@ class NemotronHForCausalLM(
789 conv_kernel=hf_config.conv_kernel,
790 num_spec=vllm_config.num_speculative_tokens,
791 )
792 + if cache_config.use_replayssm:
793 + return MambaStateShapeCalculator.append_replayssm_ring(
794 + base_shape,
795 + hf_config.n_groups,
796 + parallel_config.tensor_parallel_size,
797 + cache_config.replayssm_buffer_len,
798 + )
799 + return base_shape
800
801 @classmethod
802 def get_mamba_state_copy_func(cls) -> tuple[MambaStateCopyFunc, MambaStateCopyFunc]:
vllm/model_executor/models/registry.py
+3
@@ -55,6 +55,7 @@ from .interfaces import (
55 supports_multimodal_encoder_tp_data,
56 supports_multimodal_raw_input_only,
57 supports_pp,
58 + supports_replayssm,
59 supports_transcription,
60 )
61 from .interfaces_base import (
@@ -789,6 +790,7 @@ class _ModelInfo:
790 is_hybrid: bool
791 has_noops: bool
792 supports_mamba_prefix_caching: bool
793 + supports_replayssm: bool
794 supports_transcription: bool
795 supports_transcription_only: bool
796
@@ -815,6 +817,7 @@ class _ModelInfo:
817 is_attention_free=is_attention_free(model),
818 is_hybrid=is_hybrid(model),
819 supports_mamba_prefix_caching=supports_mamba_prefix_caching(model),
820 + supports_replayssm=supports_replayssm(model),
821 supports_transcription=supports_transcription(model),
822 supports_transcription_only=(
823 supports_transcription(model) and model.supports_transcription_only
vllm/v1/attention/backend.py
+6
@@ -480,6 +480,11 @@ class CommonAttentionMetadata:
480 fixed sliding window. None disables R-SWA. The attention backend copies this
481 into its own persistent buffer and reads ``rswa_window`` from model config."""
482
483 + replayssm_decode_base_cpu: torch.Tensor | None = None
484 + """(batch_size,) CPU ring origin for Mamba2 ReplaySSM decode: num_computed
485 + at the current decode run's last full-state write. write_pos counts from
486 + here, so a preemption-resumed request re-anchors past the prompt boundary."""
487 +
488 # WARNING: Deprecated fields. Will be removed in a future release (v0.15.0)
489 _seq_lens_cpu: torch.Tensor | None = None
490 _num_computed_tokens_cpu: torch.Tensor | None = None
@@ -591,6 +596,7 @@ class CommonAttentionMetadata:
596 dcp_local_seq_lens_cpu=maybe_slice_reqs(self.dcp_local_seq_lens_cpu),
597 is_prefilling=maybe_slice_reqs(self.is_prefilling),
598 rswa_prefix_lens=maybe_slice_reqs(self.rswa_prefix_lens),
599 + replayssm_decode_base_cpu=maybe_slice_reqs(self.replayssm_decode_base_cpu),
600 )
601
602
vllm/v1/attention/backends/mamba_attn.py
+144
@@ -74,6 +74,12 @@ class BaseMambaAttentionMetadata:
74 nums_dict: dict | None = None
75 batch_ptr: torch.Tensor | None = None
76 token_chunk_offset_ptr: torch.Tensor | None = None
77 + # ReplaySSM standard decode: per-row ring cursor and flush flag, plus the
78 + # per-step (decode_rows, ngroups, replayssm_buffer_len) fp32 scratch for the
79 + # precomputed k^T q products. All None when use_replayssm is disabled.
80 + write_pos_d: torch.Tensor | None = None
81 + is_flush_d: torch.Tensor | None = None
82 + bc_pre_scratch: torch.Tensor | None = None
83
84
85 class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
@@ -98,6 +104,8 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
104 self.compilation_config = vllm_config.compilation_config
105 self.num_spec_tokens: int = vllm_config.num_speculative_tokens
106 self.use_spec_decode = self.num_spec_tokens > 0
107 + self.use_replayssm = vllm_config.cache_config.use_replayssm
108 + self.replayssm_buffer_len = vllm_config.cache_config.replayssm_buffer_len
109
110 assert isinstance(kv_cache_spec, MambaSpec)
111 scheduler_config = vllm_config.scheduler_config
@@ -158,6 +166,36 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
166 dtype=torch.int32,
167 device=device,
168 )
169 + # ReplaySSM standard-decode CUDA-graph buffers: per-row ring cursor,
170 + # flush flag, and the k^T q precompute scratch.
171 + if self.use_replayssm:
172 + self.decode_write_pos_d: torch.Tensor = torch.empty(
173 + (self.decode_cudagraph_max_bs,),
174 + dtype=torch.int32,
175 + device=device,
176 + )
177 + self.decode_is_flush_d: torch.Tensor = torch.empty(
178 + (self.decode_cudagraph_max_bs,),
179 + dtype=torch.int8,
180 + device=device,
181 + )
182 + # B_cache shape = (ngroups, replayssm_buffer_len, dstate); the page
183 + # layout is (conv_state, ssm_state, x_cache, dt_cache, B_cache).
184 + bc_ngroups = kv_cache_spec.shapes[4][0]
185 + bc_scratch_bs = max(
186 + self.decode_cudagraph_max_bs, scheduler_config.max_num_seqs
187 + )
188 + self.decode_bc_pre_scratch: torch.Tensor = torch.empty(
189 + (
190 + bc_scratch_bs,
191 + bc_ngroups,
192 + self.replayssm_buffer_len,
193 + ),
194 + dtype=torch.float32,
195 + device=device,
196 + )
197 + else:
198 + self.decode_bc_pre_scratch = None
199
200 self._init_reorder_batch_threshold(1, self.use_spec_decode)
201 if self.use_spec_decode:
@@ -414,6 +452,8 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
452 has_prior_state = seq_lens_cpu > 1
453 prefill_to_decode = single_token_prefill_rows & has_prior_state
454 if torch.any(prefill_to_decode).item():
455 + # ReplaySSM handles these rows as single-token flushes (see the
456 + # write-position derivation below), same as the baseline decode path.
457 is_prefilling = is_prefilling.clone()
458 is_prefilling[prefill_to_decode] = False
459 common_attn_metadata = common_attn_metadata.replace(
@@ -444,6 +484,8 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
484
485 # for causal_conv1d
486 nums_dict, batch_ptr, token_chunk_offset_ptr = None, None, None
487 + write_pos_d = None
488 + is_flush_d = None
489
490 if self.vllm_config.cache_config.mamba_cache_mode == "all":
491 num_computed_tokens = common_attn_metadata.compute_num_computed_tokens()
@@ -530,6 +572,79 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
572 num_reqs - num_prefills : num_reqs
573 ]
574
575 + if self.use_replayssm and num_decodes > 0:
576 + decode_base_cpu = common_attn_metadata.replayssm_decode_base_cpu
577 + num_computed_tokens_cpu = common_attn_metadata._num_computed_tokens_cpu
578 + if decode_base_cpu is None or num_computed_tokens_cpu is None:
579 + raise ValueError(
580 + "--use-replayssm requires CPU decode-base and "
581 + "computed-token counts to derive decode write positions"
582 + )
583 + num_computed_d = num_computed_tokens_cpu[:num_decodes]
584 + decode_base_d = decode_base_cpu[:num_decodes]
585 + align_mode = self.vllm_config.cache_config.mamba_cache_mode == "align"
586 + block_size = self.kv_cache_spec.block_size
587 + if align_mode:
588 + # After a boundary the align copy leaves an exact checkpoint at
589 + # the block start and the new block's ring restarts empty, so
590 + # re-anchor there; max() keeps the prompt-end anchor for the
591 + # first (partial) block.
592 + effective_base = torch.maximum(
593 + decode_base_d, (num_computed_d // block_size) * block_size
594 + )
595 + else:
596 + effective_base = decode_base_d
597 + # write_pos counts decode steps since the ring's last full-state
598 + # write (the anchor), so a resumed request re-anchors correctly.
599 + decode_steps_cpu = num_computed_d - effective_base
600 + query_lens_cpu = (
601 + common_attn_metadata.query_start_loc_cpu[1 : num_decodes + 1]
602 + - common_attn_metadata.query_start_loc_cpu[:num_decodes]
603 + )
604 + valid_decode_rows = query_lens_cpu > 0
605 + # A single-token prefill row replayed as decode (query_len==1 with
606 + # prior state) has decode_steps < 0; force it to a one-token flush
607 + # (write_pos=0, is_flush=1). The flush branch reads an empty history
608 + # window, so it applies exactly one recurrence step off the checkpoint
609 + # -- identical to the baseline decode kernel for that row. The split
610 + # (treat_short_extends_as_decodes=False) admits only such rows here.
611 + leftover_prompt = valid_decode_rows & (decode_steps_cpu < 0)
612 + decode_steps_cpu = torch.where(
613 + valid_decode_rows & ~leftover_prompt,
614 + decode_steps_cpu,
615 + torch.zeros_like(decode_steps_cpu),
616 + )
617 + write_pos_cpu = torch.remainder(decode_steps_cpu, self.replayssm_buffer_len)
618 + is_flush_cpu = (
619 + write_pos_cpu == self.replayssm_buffer_len - 1
620 + ) | leftover_prompt
621 + if align_mode:
622 + # Force a flush on the step completing a mamba block so the exact
623 + # boundary state is materialized for prefix caching.
624 + is_flush_cpu = is_flush_cpu | (
625 + valid_decode_rows
626 + & ((num_computed_d + query_lens_cpu) % block_size == 0)
627 + )
628 + is_flush_cpu = is_flush_cpu.to(torch.int8)
629 + write_pos_d = async_tensor_h2d(
630 + write_pos_cpu.to(torch.int32).tolist(),
631 + dtype=torch.int32,
632 + device=common_attn_metadata.query_start_loc.device,
633 + )
634 + is_flush_d = async_tensor_h2d(
635 + is_flush_cpu.tolist(),
636 + dtype=torch.int8,
637 + device=common_attn_metadata.query_start_loc.device,
638 + )
639 +
640 + bc_pre_scratch = None
641 + if (
642 + self.use_replayssm
643 + and self.decode_bc_pre_scratch is not None
644 + and num_decodes > 0
645 + ):
646 + bc_pre_scratch = self.decode_bc_pre_scratch[:num_decodes]
647 +
648 metadata = self.metadata_cls(
649 num_prefills=num_prefills,
650 num_prefill_tokens=num_prefill_tokens,
@@ -539,6 +654,9 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
654 has_initial_states_p=has_initial_states_p,
655 state_indices_tensor_p=state_indices_tensor_p,
656 state_indices_tensor_d=state_indices_tensor_d,
657 + write_pos_d=write_pos_d,
658 + is_flush_d=is_flush_d,
659 + bc_pre_scratch=bc_pre_scratch,
660 num_accepted_tokens=num_accepted_tokens,
661 query_start_loc_d=query_start_loc_d,
662 block_idx_last_scheduled_token=block_idx_last_scheduled_token,
@@ -573,6 +691,9 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
691 block_idx_last_scheduled_token_prev_step = (
692 metadata.block_idx_last_scheduled_token_prev_step
693 )
694 + write_pos_d = metadata.write_pos_d
695 + is_flush_d = metadata.is_flush_d
696 + bc_pre_scratch = metadata.bc_pre_scratch
697 if (
698 metadata.num_prefills == 0
699 and metadata.num_decodes <= self.decode_cudagraph_max_bs
@@ -634,11 +755,34 @@ class BaseMambaAttentionMetadataBuilder(AttentionMetadataBuilder[M], abc.ABC):
755 )
756 block_idx_last_scheduled_token_prev_step[metadata.num_decodes :] = 0
757
758 + if self.use_replayssm:
759 + assert write_pos_d is not None
760 + assert is_flush_d is not None
761 + self.decode_write_pos_d[: metadata.num_decodes].copy_(
762 + write_pos_d[: metadata.num_decodes],
763 + non_blocking=True,
764 + )
765 + write_pos_d = self.decode_write_pos_d[:padded_bs]
766 + write_pos_d[metadata.num_decodes :] = 0
767 +
768 + self.decode_is_flush_d[: metadata.num_decodes].copy_(
769 + is_flush_d[: metadata.num_decodes],
770 + non_blocking=True,
771 + )
772 + is_flush_d = self.decode_is_flush_d[:padded_bs]
773 + is_flush_d[metadata.num_decodes :] = 0
774 +
775 + if self.decode_bc_pre_scratch is not None:
776 + bc_pre_scratch = self.decode_bc_pre_scratch[:padded_bs]
777 +
778 return replace(
779 metadata,
780 state_indices_tensor_d=state_indices_tensor_d,
781 query_start_loc_d=query_start_loc_d,
782 num_accepted_tokens=num_accepted_tokens,
783 + write_pos_d=write_pos_d,
784 + is_flush_d=is_flush_d,
785 + bc_pre_scratch=bc_pre_scratch,
786 block_idx_last_scheduled_token=block_idx_last_scheduled_token,
787 block_idx_last_computed_token=block_idx_last_computed_token,
788 block_idx_last_scheduled_token_prev_step=(
vllm/v1/worker/gpu_input_batch.py
+26
@@ -106,6 +106,7 @@ class InputBatch:
106 is_pooling_model: bool = False,
107 cp_kv_cache_interleave_size: int = 1,
108 reasoning_config: ReasoningConfig | None = None,
109 + use_replayssm: bool = False,
110 slot_mapping_modes: list[SlotMappingMode] | None = None,
111 ):
112 self.thinking_budget_state_holder = maybe_create_thinking_budget_state_holder(
@@ -170,6 +171,17 @@ class InputBatch:
171 )
172 self.num_computed_tokens_cpu = self.num_computed_tokens_cpu_tensor.numpy()
173
174 + # Mamba2 ReplaySSM decode ring origin (num_computed at each request's
175 + # last full-state write); populated only when the feature is on.
176 + self.use_replayssm = use_replayssm
177 + self.replayssm_decode_base_cpu_tensor = torch.zeros(
178 + (max_num_reqs,),
179 + device="cpu",
180 + dtype=torch.int32,
181 + pin_memory=PIN_MEMORY,
182 + )
183 + self.replayssm_decode_base = self.replayssm_decode_base_cpu_tensor.numpy()
184 +
185 # Block table.
186 self.block_table = MultiGroupBlockTable(
187 max_num_reqs=max_num_reqs,
@@ -377,6 +389,11 @@ class InputBatch:
389 # Number of tokens without spec decode tokens.
390 self.num_tokens_no_spec[req_index] = request.num_tokens
391
392 + if self.use_replayssm:
393 + # Ring origin = full context at (re)admission (prompt + any resumed
394 + # output), so a resumed request re-anchors past the prompt.
395 + self.replayssm_decode_base[req_index] = request.num_tokens
396 +
397 self.num_computed_tokens_cpu[req_index] = request.num_computed_tokens
398 self.block_table.add_row(request.block_ids, req_index)
399
@@ -597,6 +614,11 @@ class InputBatch:
614 self.num_prompt_tokens[i2],
615 self.num_prompt_tokens[i1],
616 )
617 + if self.use_replayssm:
618 + self.replayssm_decode_base[i1], self.replayssm_decode_base[i2] = (
619 + self.replayssm_decode_base[i2],
620 + self.replayssm_decode_base[i1],
621 + )
622 self.num_computed_tokens_cpu[i1], self.num_computed_tokens_cpu[i2] = (
623 self.num_computed_tokens_cpu[i2],
624 self.num_computed_tokens_cpu[i1],
@@ -754,6 +776,10 @@ class InputBatch:
776 last_req_index
777 ]
778 self.num_prompt_tokens[empty_index] = self.num_prompt_tokens[last_req_index]
779 + if self.use_replayssm:
780 + self.replayssm_decode_base[empty_index] = self.replayssm_decode_base[
781 + last_req_index
782 + ]
783 self.num_computed_tokens_cpu[empty_index] = self.num_computed_tokens_cpu[
784 last_req_index
785 ]
vllm/v1/worker/gpu_model_runner.py
+9
@@ -727,6 +727,7 @@ class GPUModelRunner(
727 is_pooling_model=self.is_pooling_model,
728 cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size,
729 reasoning_config=self.vllm_config.reasoning_config,
730 + use_replayssm=self.cache_config.use_replayssm,
731 )
732
733 # Separate cuda stream for overlapping transfer of sampled token ids from
@@ -2413,6 +2414,12 @@ class GPUModelRunner(
2414 if self.model_config.rswa_window is not None:
2415 rswa_prefix_lens = num_prompt_tokens_cpu
2416
2417 + replayssm_decode_base_cpu = None
2418 + if self.cache_config.use_replayssm:
2419 + replayssm_decode_base_cpu = (
2420 + self.input_batch.replayssm_decode_base_cpu_tensor[:num_reqs_padded]
2421 + )
2422 +
2423 cm_base = CommonAttentionMetadata(
2424 query_start_loc=self.query_start_loc.gpu[: num_reqs_padded + 1],
2425 query_start_loc_cpu=self.query_start_loc.cpu[: num_reqs_padded + 1],
@@ -2420,6 +2427,7 @@ class GPUModelRunner(
2427 _seq_lens_cpu=seq_lens_cpu,
2428 _num_computed_tokens_cpu=num_computed_tokens_cpu,
2429 seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
2430 + replayssm_decode_base_cpu=replayssm_decode_base_cpu,
2431 num_reqs=num_reqs_padded,
2432 num_actual_tokens=num_tokens_padded,
2433 max_query_len=max_query_len,
@@ -7262,6 +7270,7 @@ class GPUModelRunner(
7270 is_pooling_model=self.is_pooling_model,
7271 cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size,
7272 reasoning_config=self.vllm_config.reasoning_config,
7273 + use_replayssm=self.cache_config.use_replayssm,
7274 slot_mapping_modes=slot_mapping_modes,
7275 )
7276