Repository navigation
[GDN][KDA] ReplaySSM buffered output-only decode for Linear Attention - #28451
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
5e08c14 to
ecea9a5
Compare
|
Working on KDA/GDN unified solution. |
d5a9d86 to
54ff2fb
Compare
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
c1d88fe to
94083f2
Compare
Allow --enable-gdn-replayssm with the radix prefix cache ENABLED by aligning the ring flush with the radix track snapshot: - force_flush = the radix track mask (seq_lens_cpu % mamba_track_interval == 0), computed identically to schedule_batch.py's mamba_track_mask from the SAME post-increment seq_lens_cpu, threaded to the decode kernel (eager + a static per-bs cuda-graph buffer mirroring write_pos). So the kernel force-flushes the partial ring into temporal[slot] on exactly the steps the radix cache snapshots it -> the snapshot reads a current checkpoint. - write_pos advance now: next = 0 if (write_pos==L-1 OR force_flush) else +1. - COW reset: MambaPool.copy_from zeros replayssm_write_pos[dst] (the copied checkpoint is a fully-flushed snapshot -> dst ring starts empty). - server_args: drop --disable-radix-cache requirement; ADD a no_buffer guard (extra_buffer/ping-pong donation path not yet covered -> follow-up).
@kaixih Thanks for the comment. PD disaggregated decode-side pool HybridMambaDecodeReqToTokenPool doesn't receive these args. I agree that we need this guard. Added a guard in this PR, and I'll follow up a more decent PR to update HybridMambaDecodeReqToTokenPool accordingly. |
@Swipe4057 Agree, I'll do it in a follow-up PR. |
b4b0325 to
0a3ceeb
Compare
0a3ceeb to
df3fb6a
Compare
|
Similar method for the flashinfer support: #27725 cc @Jane1019-Libra @nvpohanh @ameynaik-hub |
I feel your mentioned one is closer to #28695. Since the flashinfer is currently updating to follow this |
|
|
Thanks for this work. The unified kernel works correctly for both gates, but I'm seeing GDN and KDA behave very differently and wanted to check if this is expected. Setup: amortized decode microbench — GDN (scalar gate, KDA (per-K gate,
The KDA Questions:
|
@yyq0210 Thanks for the insight comment. Your numbers match my verification, KDA loses performance, GDN wins at batch >= 64. KDA's ReplaySSM win is on the spec-decode memory side (#27658), not decode. Rather than gate it off, I documented it as following: the flag help + a dispatch comment now flag that KDA decode regresses and isn't recommended. Keeping the kernel wired. Since KDA hit is compute-bound, we'd better revisit in Blackwell. |
Reconcile the GDN ReplaySSM spec-verify (chunked (I+A)^-1 verify ring) onto main's linear_replayssm decode ring (sgl-project#28451): reuse its replayssm_d/k/g + write_pos + is_kda + ssm_dtype, and add only the per-slot circular cursors replayssm_cache_base (int32) + replayssm_is_flush (int8) for the verify path. Gate: --enable-gdn-replayssm-spec (implies the ring; reuses linear_replayssm_cache_len). GDN-only and linear-chain (topk<=1) only; KDA and the absent-ring case fall back to the recurrent verify. The decode-time linear_replayssm path is unchanged. fp16 checkpoint works via --mamba-ssm-dtype float16 (ring stored in ssm_dtype; verify casts honor h0.dtype). Co-Authored-By: vincentzed <207368749+vincentzed@users.noreply.github.com>
…sgl-project#28451) Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
Motivation
To address #28511.
The GDN (Gated DeltaNet) decode kernel reads the full recurrent state
S [HV, V, K]from HBM and writes it back every decode step. On the H20‑3e this packed decode is 70–79% memory-bandwidth-bound at batch ≥ 64 — the per-step state read+write traffic dominates.ReplaySSM (Dao AI Lab, https://dao-lab.ai/blog/2026/replayssm/) removes the per-step state write: keep a small per-slot ring buffer of the last
Lsteps'(d, k, g), read the output via the history route, and flush the full state only everyLsteps. Per-step state traffic drops from read+write ( ~8dn) to read-only ( ~4dn) → roughly halved.This PR ports the ReplaySSM decode path into SGLang behind a flag, fully integrated with CUDA graph and the radix prefix cache. It covers both linear-attention gate granularities with one gate-type-parametrized kernel: GDN (per-head scalar gate, radix + CUDA graph) and KDA (Kimi Delta Attention, per-K-channel gate).
Modifications
Adds$\alpha$ ):
--enable-linear-replayssm(with--linear-replayssm-cache-len L, default 16). When enabled, single-token linear-attention decode runs a buffered output-only kernel (fused_recurrent_linear_replayssm_decode,IS_KDAconstexpr) instead of the packed decode —fused_recurrent_gated_delta_rule_packed_decodefor GDN orfused_recurrent_kda_packed_decodefor KDA. The GDN math (per-head scalar gatewhere$S \in \mathbb{R}^{V \times K}$ ; $Sk$ and $Sq$ are matrix-vector contractions over the key dimension; and $q$ is already L2-normed (optional) and scaled.
The$[V, K]$ state is reconstructed from the checkpoint $S_0$ + ring in K-tiles and read with $q$ / $k$ immediately, so it is never materialized to HBM on a non-flush step. The reconstruction $L$ -deep reconstruction stays hidden under the reduced memory traffic.
tl.dotruns on tensor cores (TF32 for fp32, bf16 tensor cores for bf16) — required so theDesign
This is a memory-traffic optimization for a memory-bound decode kernel, so several decisions trade extra (cheap, tensor-core) compute for less HBM traffic, and several more are about doing that safely under CUDA graph + the radix prefix cache. The non-obvious ones:
1. Output-only reconstruction, never materialize the state
On a non-flush step the buffered state before the current token is
with total decay$A = \prod_i \alpha_i$ and per-position replay decay $w_j = \prod_{i>j} \alpha_i$ , both built from the cached per-step gates $g_j$ via a $S$ is reconstructed in K-tiles and each tile is immediately contracted with $q$ and $k$ into the scalars $S q$ / $S k$ , so the full $[V,K]$ tile never lands in HBM. At $L=1$ the ring is always empty and the kernel reduces algebraically to
cumsum(no per-step state needed).fused_recurrent_gated_delta_rule_packed_decode(used as the bit-exact correctness oracle).2. Tensor cores are mandatory, not optional (the main perf trap)
The reconstruction is$O(L\cdot K\cdot V)$ compute — $L\times$ the baseline's rank-1 update. That only stays hidden under the (halved) memory traffic if the $\alpha<1$ attenuates old reconstruction error so it does not accumulate across flushes over a long decode.
tl.dotruns on tensor cores. We hit this directly: forcing an IEEE fp32 dot (e.g. to pass an over-tight unit-test tolerance) drops tensor cores and makes the kernel ~10–20× slower — turning a 1.5× win into a 15× regression. The fix is to keep the dot on tensor cores (TF32 for fp32 inputs, bf16 tensor cores for bf16) and set tensor-core-realistic test tolerances instead. TF32 (~4e-4) / bf16 (~1e-3) reconstruction error is benign end-to-end (GSM8K parity below); GDN's per-step decay3.
write_posis a per-slot decode-position counter, not derived fromseq_lenThe ring cursor is tracked per physical mamba slot (in
MambaPool), not computed fromseq_len. Deriving it fromseq_len % Lwould be wrong:seq_lenincludes the prefill length, so a slot's first decode step would read a non-empty ring of zeros. Instead:temporal[slot], which is exactly the correct checkpointwrite_posis the decode position, shared by all GDN layers in a step. Advance rule:next = 0 if (write_pos == L-1 or force_flush) else write_pos + 1.The prefill path is untouched; it writes the full state to
temporal[slot], which becomes the decode checkpoint.4. CUDA-graph safety
The decode forward is captured once and replayed, so
write_poscannot be a fresh tensor per step. It is a static per-bs buffer captured by the graph and refreshed in-place each replay from the persistent per-slot counter, mirroring howmamba_cache_indicesis handled (_replay_metadata, outside the captured region). The per-slot advance also runs there, guarded bynot in_captureso warmup/capture never mutates the persistent counter. The flush decision is a device-side branch (is_flush = write_pos == L-1), so a single captured graph handles every step regardless of where in the ring cycle each row is.5. Radix coordination — flush exactly when the cache snapshots
The radix prefix cache snapshots the working
temporal[slot]atseq_lens % mamba_track_interval == 0boundaries for prefix reuse. For ReplaySSMtemporal[slot]is the checkpoint, which is stale between flushes. So the decode kernel must flush on exactly those steps.force_flushis computed from the identical condition and the same post-incrementseq_lens_cputhe radix cache uses (schedule_batch.py), so there is no off-by-one — the ring is folded intotemporal[slot]on the same step the snapshot reads it. The kernel ORsforce_flushintois_flushwhilecache_validstill uses the truewrite_pos(only committed ring entries are read, even when flushing mid-buffer).On a prefix hit,
MambaPool.copy_fromclones the (now always fully-flushed) snapshot into the request's private slot; we reset that slot'swrite_posto 0 so the destination ring starts empty.6.
flag-OFFis byte-identicalWhen the flag is off no ring tensors are allocated, the metadata fields stay
None, and the kernel dispatch falls through to the existing packed decode — zero change to the current path.7. One kernel for GDN (scalar gate) and KDA (per-K gate)
GDN and KDA are the same delta-rule decode modulo gate granularity: GDN decays the whole state by a per-head scalar$\alpha=\exp(g)$ ; KDA decays it per key channel, $S' = S,\mathrm{Diag}(a) + d,k^{\top}$ with $a=\exp(g)\in\mathbb{R}^{K}$ . A single
IS_KDAconstexpr selects the gate path (mirroring the baselinefused_recurrent_*_packed_decodesplit), and the GDN path is bit-for-bit the original kernel (no regression — see Validation).The non-obvious part is that ReplaySSM accumulates the gate into the reconstruction, so per-K gating touches more than the one baseline line:
g_cachebecomes per-K:[…, L](GDN) →[…, L, K](KDA);cumsumover the buffer (Everything else — the rank-1 reconstruction skeleton, output-only readout, K-tiling, ring /
write_pos/ flush, and the entire runtime integration (memory pool ring, CUDA graph, COW) — is gate-agnostic and shared.MambaPoolsizesg_cachefromcache_params.is_kda(Mamba2 → scalar, KimiLinear → per-K). KDA ships without radix coordination for now (force_flushleft off for KDA; KimiLinear has the radix cache disabled upstream anyway), so the KDA ring flushes only at the naturalwrite_pos == L-1wrap.Notes investigated
cache_finished_reqdonates the workingtemporalat the request's full (non-L-aligned) length. We checked whether this exposes a stale ReplaySSM checkpoint on exact full-prefix reuse; in practice it is TF32-level only and GSM8K parity (below) shows no correctness impact.Usage
Implementation
Landed as reviewable slices (one commit each):
fused_recurrent_linear_replayssm_decodeTriton kernel (IS_KDAconstexpr) + wrapper + unit test (GDN + KDA) + microbenchMambaPoolring (d/k/g+ per-slotwrite_pos); GDN backend decode dispatch behind the flag (eager)write_pos(static per-bs buffer refreshed/advanced outside the captured region)force_flushinput (`is_flushforce_flush= the radix track mask (seq_lens_cpu % mamba_track_interval == 0, identical toschedule_batch.py), so the ring flushes on exactly the steps the radix cache snapshotstemporal[slot];write_posreset on COW (MambaPool.copy_from)IS_KDAconstexpr; GDN path byte-identical);MambaPoolsizes per-Kg_cachefromcache_params.is_kda; KDA backend +kda_tritondecode dispatch threading; flag generalized--enable-gdn-replayssm→--enable-linear-replayssmwrite_posis a per-SLOT decode-position counter (reset to 0 at slot alloc and at COW), advanced once per forward; flag-OFF is byte-identical to the current path.Validation (H20-3e)
GDN correctness — Qwen3.5-35B-A3B (GDN-hybrid)
test_linear_replayssm_decode.py): GDN fp32 bit-exact atL=1, tensor-core-realistic tolerances forL>1, forced-flush mid-buffer — all pass.KDA correctness — per-K gate
fused_recurrent_kda_packed_decodeat the same tensor-core tolerances (L=1andL>1), forced-flush included — confirms the per-K reconstruction math.MambaPoolallocates per-Kg_cache [.,L,K](g≈kin size, vs GDN's scalarg≈k/128— confirmsis_kdarouting), decode runs end-to-end without crash.Performance
Scope / follow-ups
force_flushcurrently off for KDA — it flushes only at the naturalL-1wrap).mamba_cache_per_reqdoes not yet include the ReplaySSM ring bytes, so the KV pool can be over-sized when the rings are large (KDA / bigL) → OOM at defaultmem_fraction_static. Workaround: lower--mem-fraction-static. Proper fix (count ring bytes inmamba_cache_per_req) is a follow-up.--linear-attn-decode-backend tritonandmamba_scheduler_strategy=no_buffer(the default). Theextra_buffer/ping-pong donation path is not yet covered and is guarded out.fused_recurrent_replayssm.py(ReplaySSM); thevllm::persistent-style math is preserved for diff-friendly upstream sync.Credit
Algorithm: ReplaySSM, Dao AI Lab (2026).
CI States
Latest PR Test (Base): ❌ Run #28182074297
Latest PR Test (Extra): ✅ Run #28182074008