Repository navigation
[Kernel] ReplaySSM: cache SSM inputs for faster Gated DeltaNet speculative decode - #49887
Johnny-Liou wants to merge 8 commits into
Conversation
Verify a whole draft window against one checkpoint plus a circular input ring instead of one recurrent state snapshot per draft token, so per-step traffic stops scaling with the window length. Adds selective_state_update_replayssm_spec (verify and flush launches, fused scatter and precompute) plus the device-side cursor helpers commit_replayssm_spec and reset_replayssm_spec_cursors, and their launch configs. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Johnny-Liou <a897111@gmail.com>
Adds --use-replayssm-spec. The validator requires speculative decoding, mamba_cache_mode none and the Triton backend, and rejects prefix caching, KV connectors and stochastic rounding. The page grows to (conv, ssm, post_conv_cache, dt_cache); the ring is next_pow2(B + num_spec) so wraparound is a bit mask. num_speculative_blocks drops to 0 because the spec kernel never writes the baseline's per-draft slots, so the conv window is sized from max_spec_len instead of the block table width. The metadata builder owns the block-keyed cursors and advances them on-device: commit folds the previous step's acceptance into the next build, which keeps num_accepted_tokens off the host and the launch sequence fixed for CUDA graphs. Rows entering decode reset off replayssm_decode_base, so a preempted request re-anchors instead of inheriting its old cursors. The runner passes the spec metadata on draft-less steps too, or those rows would fall through to the baseline kernel and advance a stale checkpoint. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Johnny-Liou <a897111@gmail.com>
Kernel: a T-window verify step against decoding the same window one token
at a time, over multi-flush sequences, three precisions, five geometries,
B in {16,32} and T in {2,4,6,8}, plus rollback, continuous batching and
bit-exact non-flush checkpoints.
Metadata builder: commit, rollback, flush cadence, the early-flush rule,
first-decode reset of a recycled block, preemption re-anchor, the forced
flush for a prefill-as-decode row, and the buffers a captured graph depends
on.
Engine: native spec against ReplaySSM spec with check_logprobs_close.
Also adds use_replayssm_spec to the block-table test's cache-config mock,
which enumerates fields explicitly.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Johnny-Liou <a897111@gmail.com>
Runs standard decode, native spec and ReplaySSM spec on GSM8K in separate subprocesses and reports tokens/s, mean acceptance length and both speedup ratios. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Johnny-Liou <a897111@gmail.com>
Signed-off-by: Johnny-Liou <a897111@gmail.com>
Reuses --use-replayssm-spec; no new config surface. Qwen3.5 declares SupportsReplaySSM, and the page grows to (conv, ssm, d_cache, k_cache, g_cache) with the ring at next_pow2(B + num_spec). The d/k caches are fp16 for bf16 activations, which reconstructs the state more accurately at the same width; g stays fp32 for the decay replay. Every post-prefill row routes through the spec kernel, a draft-less row as a T=1 window. num_decode_draft_tokens_cpu cannot drive that mask: it is stale on draft-less steps and -1 for rows whose drafts were dropped. The builder owns the block-keyed cursors and advances them on-device, folding the previous step's acceptance into the next build so num_accepted_tokens never reaches the host and the launch sequence stays fixed for CUDA graphs. Rows entering decode reset off replayssm_decode_base, so a preempted request re-anchors. num_speculative_blocks is already 0 under the flag, so the conv window is sized from max_spec_len instead of the block-table width. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Johnny-Liou <a897111@gmail.com>
Kernel: a T-window verify step against decoding the same window one token at a
time, over three precisions, three geometries, B in {16,32} and T in {2,4,6,8},
plus rollback and continuous batching. The oracle is the upstream baseline
packed decode; history is filled with draft-less T=1 spec steps.
Metadata builder: commit, rollback, flush cadence, the early-flush rule,
first-decode reset of a recycled block, preemption re-anchor, draft-less routing,
and that a still-prefilling chunk stays a prefill.
Engine: native spec against ReplaySSM spec with check_logprobs_close on
Qwen3.5-4B.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Johnny-Liou <a897111@gmail.com>
…mark Signed-off-by: Johnny-Liou <a897111@gmail.com>
|
This pull request has merge conflicts that must be resolved before it can be |
|
Hi @Johnny-Liou, thanks for working on this. We independently explored the same GDN speculative-state traffic bottleneck, and I would like to coordinate rather than open a duplicate PR. We have a complementary exact-order design:
The main difference from #49887 is the intended contract. #49887 currently requires We first ported the current ReplaySSM GDN design and tested it with the public Preliminary evidence for the exact-order path:
Would you and the maintainers prefer this as:
I am happy to rebase the minimal implementation onto current |
|
@F21HGG regarding #49887 (comment) First, I'll say we are working on adding cache mode align to replay, just not in that PR. I don't think adding align support is any easier or harder in a replay implementation vs what I think you're proposing (see next); both require a kernel that converts an older state and an input buffer to some intermediate state when old state + # accepted tokens passes an alignment boundary. If I'm following you correctly, I think you're proposing what we've been calling "recover ssm", which was implemented for Kimi K3 in #51855, and which there's some in-progress work to speed up, although our long-term goal is to move to a replay model. There are 3 paradigms for MTP when you don't write every state:
Then there is an orthogonal algorithmic question to the kernel internals for any of these, whether the math is done recurrently or via a prefill-ish UT / WY transform, and if it's the prefill-ish one when do we use a precision other than fp32. In our experiments, even for GDN we have not seen an issue with accuracy using replay, although we were using the Flashinfer GDN replay kernels that will be dropped in on a follow-up PR. It's true you can get some divergence, but that's true just by switching between recurrent MTP and recurrent STP, because STP will quantize to fp16 every token whereas recurrent MTP carries state w/in step in fp32, and replay-ish methods accumulate in fp32 state. ReplaySSM should actually be more accurate with fp16 state than what you propose, because state writing and quantization happens only when the buffer is full, vs every step. It's true that some math happens in 16b for tensor cores, but prefill does that as well, and prefill accuracy is fine. |
|
Thanks — yes, this is a GDN "recover SSM" path. I suggested contributing it to this series rather than opening a competing PR because the integration surface is the same: Qwen3.5 dispatch, accepted-token metadata, cache lifecycle, preemption/compaction, CUDA Graph handling, and cache-mode support. The independent part is the commit policy/kernel. It can live beside ReplaySSM as an exact policy for native-MTP FP16 semantics and On The implementation is rebased onto current RTX PRO 5000 validation passed 11 kernel tests, 13 metadata tests, and 5 runner lifecycle tests. Would you be open to taking this as an isolated commit set in the ReplaySSM series, sharing the existing metadata and lifecycle infrastructure? |
|
@F21HGG thanks for starting the discussion!
|
|
Bit-exactness is not a hard requirement for me; it was a conservative correctness gate. If the planned ReplaySSM + FlashInfer path supports Qwen3.5 speculative decode with |
|
+1, let's focus our effort on trying to get the implementation right the first time. To me, that's ReplaySSM not RecoverSSM. Prefix caching is a hard requirement and we will need to have a concrete plan for this if we do not intend to have support after landing the first PR in the sequence. It sounds like we do. |
|
Production datapoint in support of "prefix caching is a hard requirement" — from a config that runs hybrid GDN + MTP + prefix caching together today, since numbers for that combination seem scarce in this thread. Config: Qwen3.8-27B (hybrid GDN, NVFP4/ModelOpt), vLLM v0.27.1, RTX 5090 (SM_120, 32 GB), FLASHINFER, fp8 e4m3 KV, MTP Measured over a 5h serving window today:
So both features carry their weight simultaneously on this workload — losing prefix caching to land spec-decode improvements would cost us roughly half our prefill on cached turns, which is why the hard-requirement stance matters to downstream users like us. The known interaction tax we eat today is #54360 (spec decode enlarging the effective block-hash unit, so cache hits quantize coarsely under MTP). If the ReplaySSM sequence changes that behavior in either direction, we are happy to A/B candidate PRs on this box — 5090/SM_120 with fp8 KV is a config the CI matrix does not cover, and we can run identical multi-hour gateway load against both arms. |
Background
This is the fourth sub-PR split from the larger ReplaySSM draft PR #47576, which contains the complete Mamba2 and Gated DeltaNet implementation for both standard and speculative decode. Mamba2 standard decode landed as
866fea2b9(#48018); GDN standard decode is #48792, and Mamba2 speculative decode is #49847. This PR adds GDN speculative decode. It is a collaboration between Dao AI Lab and NVIDIA, and the full derivation and figures are in the blog post. AI assistance was used for parts of this work, and the authors have reviewed every changed line.Core concept
To roll back rejected drafts, vLLM's native GDN spec path keeps a separate recurrent state snapshot for every draft token, so a step with
T = 1 + num_speculative_tokenstokens writesTfullhead_v x head_kstates per value head. Cost grows linearly inT.ReplaySSM replaces the snapshots with one checkpoint plus a small circular buffer of the per-step inputs. For GDN the buffer holds
(d, k, g), whered = beta * (v - S k)is the per-token delta-rule update andSis the decayed state before the rank-one update. The spec kernel computes these updates for the whole window asD_specand writes them tod_cache. The namedis deliberate: GDN prefill already usesUfor its chunk-transformed value tensor in the WY representation. Cachingdrather thanvremoves the serial state dependency during replay. This changes three things:What this PR adds
Everything is behind the flag added in #49847. With ReplaySSM disabled, the GDN decode path is unchanged.
gdn_replayssm_spec_decode, its device-side cursor helperscommit_gdn_replayssm_specandreset_gdn_replayssm_spec_cursors, and the swept Blackwell launch configs.--use-replayssm-spec, the GDN metadata builder owns the block-keyed ring cursors, and the state-shape and state-dtype calculators size thed/k/gring on the paged state. Qwen3.5 declaresSupportsReplaySSM. The conv path reuses vLLM'scausal_conv1d_updateunchanged.Design notes
The flush rule, cache sizing, on-device commit, and fixed verify-plus-flush launch pair are inherited from #49847. Three details are GDN-specific.
Paged cache layout
The ring and checkpoint live inside the paged KV block, appended as
d_cache,k_cache, andg_cacheon the baseline(conv, ssm)state for five tensors total.d_cachestores the delta-rule update defined above. Thedandkcaches use fp16 when activations are bf16, which reconstructs the state more accurately at the same width.gis always fp32 because it drives the cumulative decay replay.Only the small per-block cursors live outside the page:
write_pos,cache_base, andis_flush, all shaped(num_gpu_blocks,)and indexed by physical block id.Routing every post-prefill row
Under the flag, the builder marks every post-prefill row as a spec row, so a draft-less row runs as a
T=1window.num_decode_draft_tokens_cpucannot drive that mask: it is stale on steps where the drafter proposed nothing and is-1for decode rows whose drafts were dropped. Sending either row to the baseline decode path would read the checkpoint page, which lags the committed ring.Prefill chunks and captured graphs
A final single-token prompt chunk stays on the prefill path. This differs from GDN standard decode, where leaving it as a prefill lets a shape-uniform batch replay a decode graph against a stale cursor.
That batch is not uniform on the spec path.
_is_uniform_decoderequires bothmax_num_scheduled_tokens == 1 + num_speculative_tokensandnum_tokens == max * num_reqs. A draft-less step fails the first condition because every row is one token wide, and a mixed batch fails the second because the prompt chunk is shorter than the window. Standard decode hasuniform_decode_query_len == 1, so the same batch is uniform there. A builder test covers the classification.Results
Qwen3.5-122B-A10B-NVFP4 on one B300, TP1, fp32 SSM state,
B=16, in-checkpoint MTP drafter.Kernel-level
Isolated per-layer-step CUDA-graph latency on B300. The baseline is
fused_sigmoid_gating_delta_rule_update; ReplaySSM is the full step (verify plus flush) amortized over the exact steady-state flush cadencef = a/(B-T+a), at a mean acceptance of 2 tokens forT=2and 3 forT=4,6,8. AtB=16:The ratio grows with
Tbecause the baseline writesTstates per step while ReplaySSM's traffic is flat inT.End-to-end throughput
GSM8K, greedy,
ignore_eos, 256 output tokens,max_model_len2048, CUDA graphs on. Produced withbenchmarks/replayssm/e2e_spec_decode_throughput.py.ReplaySSM spec over native spec:
ReplaySSM spec over standard (no-spec) decode:
Compared with native spec, the gain grows with batch size, where its
Tstate writes per step cost the most. Compared with standard decode, ReplaySSM is 1.4x to 2.1x faster at every batch size. At bs512, native spec reaches 9,223 tok/s at T=2 and 9,395 tok/s at T=4, below standard decode at 10,398 tok/s. ReplaySSM spec reaches 19,707 tok/s.The two bs512 runs at T=6 and T=8 fail to start. The failure happens in
_init_minimal_kv_cache_for_profiling, which allocates a temporary KV cache before vLLM determines the size of the real one. This isn't a ReplaySSM memory regression. The temporary cache is block-based, so it charges the larger ReplaySSM page size without accounting for the reduced per-request footprint whennum_speculative_blocks = 0.Acceptance length
At bs256:
Serving capacity
Native spec holds
1 + num_speculative_tokensrecurrent states per request for rollback. ReplaySSM holds one checkpoint plus the input ring. Maximum decode concurrency atmax_model_len2048,(B, T) = (16, 4), one B300:These are worst-case caps at the full request length.
Accuracy
Official model-card presets with paired seeds. Qwen3.5-122B-A10B-NVFP4 on one B300 with CUDA graphs (FP32 state, d/k cache in FP16 instead of BF16 for precision purpose):
Every ReplaySSM result is within 0.9 percentage points of the baseline, and degenerate-loop counts stay within the baseline range. Qwen3.5-35B-A3B on 2xH100 TP2 shows the same parity. Native spec scores 85.8% on MMLU-Pro against a model-card value of 85.3%, and 87.9% on HMMT against 89.0%.
Acceptance length is only comparable between runs with identical settings. With the model-card presets, native spec and ReplaySSM both average 2.6 to 2.8 accepted tokens; the greedy throughput runs average about 3.4 on the same model.
Tests
Per-step kernel correctness is covered by
tests/kernels/test_replayssm_spec_decode_gdn.py. AT-window verify step is checked against decoding the same window one token at a time, across three state and activation precision combinations, the production geometries (small, Qwen3.5-4B, Qwen3.5-122B),Bin {16, 32}, andTin {2, 4, 6, 8}. It also covers rollback across a flush and continuous batching with per-row cursors and null-padded slots. The reference is the upstreamfused_recurrent_gated_delta_rule_packed_decode, run on a clone from the checkpoint through the history and then the window. The committed history is built with draft-lessT=1spec steps. One case corrupts a cached history key and asserts that the output changes.Metadata builder unit tests are in
tests/v1/attention/test_gdn_spec_metadata_builder.py. They cover commit and flush cursor updates, the early-flush boundary, rejected drafts, recycled blocks, resume after preemption, mixed batches, draft-less rows, final prefill chunks, persistent block-keyed cursor buffers under graph capture, and matching page sizes from the layer and config paths. The last check guards against silent truncation when those paths are zipped.Engine-level parity lives in
tests/v1/e2e/test_replayssm_decode.py, which compares native spec against ReplaySSM spec with greedy decode andcheck_logprobs_closeon Qwen3.5-4B.Results: the GDN kernel file is 67 passed (131 seconds on one A100), and the GDN builder file is 13 passed. Together with the Mamba2 suites inherited from #49847, the combined run is 273 passed. Engine parity on Qwen3.5-4B passes on one H100.
Duplicate check
No open PR addresses ReplaySSM GDN speculative decode. This PR is split from the large draft PR #47576, which stays open as the full design. The related prior ReplaySSM PRs we are aware of cover standard decode only:
Out of scope