[GDN][KDA] ReplaySSM buffered output-only decode for Linear Attention (#28451)

Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
This commit is contained in:
Yuan Luo
2026-06-26 14:17:02 +08:00
committed by GitHub
co-authored by luoyuan.luo
parent dc113e8804
commit a10a24e9a7
14 changed files with 1768 additions and 8 deletions
@@ -0,0 +1,344 @@
"""Correctness test for the buffered output-only linear-attention decode kernel
(ReplaySSM Part A), implemented in
``python/sglang/srt/layers/attention/fla/fused_recurrent_linear_replayssm.py``.
One kernel, two gate granularities (selected by ``is_kda``):
* GDN (``is_kda=False``): per-head SCALAR gate. Ground truth =
``fused_recurrent_gated_delta_rule_packed_decode`` (fused_recurrent.py:186).
* KDA (``is_kda=True``): per-K-channel gate. Ground truth =
``fused_recurrent_kda_packed_decode`` (fused_recurrent.py:406). The KDA
baseline takes the per-K gate flattened (``a``=[B, HV*K], ``dt_bias``=[HV*K]);
the ReplaySSM kernel takes it as [B, HV, K] / [HV, K]. Same memory layout,
so we build [B, HV, K] / [HV, K] and reshape for the baseline.
We run a multi-step decode through BOTH kernels and assert:
* per-step outputs ``y`` match, AND
* the ground-truth full state matches the ReplaySSM checkpoint *after a flush*
(folding the ring buffer into S0). At a non-flush step the ReplaySSM
checkpoint legitimately lags (the update lives in the ring), so we only
compare state at flush boundaries / after a forced flush.
L sweep:
* L=1 (flush every step): the ring is always empty and ``write_pos == L-1``
every step, so the reconstruction term is zero, the total decay is 1, and
the kernel reduces to the packed decode. For GDN the only fp difference is a
scalar ``alpha*(sum x)`` vs ``sum(alpha*x)`` reorder, so we assert ~bit-exact
(atol 2e-6). For KDA the analogous reorder is per-K (``alpha[k]`` folded into
q/k vs into S0), bounded by ~K*eps, so KDA uses the same (tighter-than-L>1)
tensor-core tolerances rather than 2e-6.
* L in {4,8,16}: algebraically equivalent but floating-point reordered (cumsum
of gates, replay-decay scaling, the d@k reconstruction dot on tensor cores
vs. the materialized recurrence). Tight fp tolerance, see ``_tols``.
Runnable as ``pytest`` and as ``__main__``.
"""
import sys
import unittest
from pathlib import Path
import torch
from sglang.test.test_utils import CustomTestCase
# Mirror sibling GDN unittests: register for CUDA/AMD CI. This is a kernel-math
# unit test; it lives with the other linear-attention kernel correctness tests.
# The registry calls MUST be module-level (the CI collector / check-registered-
# tests hook parses them statically via AST and only scans top-level statements
# -- a try/except wrapper hides them and fails the hook).
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
register_cuda_ci(est_time=20, stage="base-b", runner_config="1-gpu-large")
register_amd_ci(est_time=20, suite="stage-b-test-1-gpu-large-amd")
def _build_inputs(B, H, HV, K, V, dtype, device, seed=0, is_kda=False):
"""Random packed-decode inputs for one step.
Returns a packed ``mixed_qkv`` [B, 2*H*K + HV*V] plus the per-row gate
inputs. ``b`` (beta) is [B, HV] for both gate types. ``a`` is [B, HV] for
the GDN scalar gate and [B, HV, K] for the KDA per-K gate.
"""
g = torch.Generator(device=device).manual_seed(seed)
qk_dim = 2 * H * K
v_dim = HV * V
mixed_qkv = torch.randn(B, qk_dim + v_dim, generator=g, device=device, dtype=dtype)
# Keep gate input `a` modest so softplus / decay stay in a stable range.
if is_kda:
a = (
torch.randn(B, HV, K, generator=g, device=device, dtype=dtype) * 0.5
).contiguous()
else:
a = torch.randn(B, HV, generator=g, device=device, dtype=dtype) * 0.5
b = torch.randn(B, HV, generator=g, device=device, dtype=dtype)
return mixed_qkv, a, b
def _tols(dtype):
# L>1 reconstructs the state via a tensor-core tl.dot (TF32 for fp32 inputs,
# bf16 tensor cores for bf16) -- the performance-critical path that keeps the
# L-deep reconstruction hidden under the reduced memory traffic. TF32 carries
# ~4e-4 error, bf16 ~1e-3, both benign end-to-end (ReplaySSM GSM8K parity);
# vLLM's own ReplaySSM test uses fp32 atol 1e-4. GDN L=1 has an empty buffer
# (dot == 0) and is asserted bit-exact separately.
if dtype == torch.float32:
return dict(atol=1e-4, rtol=1e-3)
return dict(atol=1e-3, rtol=1e-2) # bf16
@unittest.skipIf(not torch.cuda.is_available(), "CUDA is required")
class TestLinearReplaySSMDecode(CustomTestCase):
# Small config: HV=8 or 16 heads, K=V=128, batch B=4.
CONFIGS = (
dict(B=4, H=4, HV=8, K=128, V=128),
dict(B=4, H=8, HV=16, K=128, V=128),
)
NUM_STEPS = 40 # > 16 -> multiple flush cycles for L in {4,8,16}
L_SWEEP = (1, 4, 8, 16)
def _run_one(self, cfg, L, dtype, force_flush_steps=(), is_kda=False):
from sglang.srt.layers.attention.fla.fused_recurrent import (
fused_recurrent_gated_delta_rule_packed_decode,
fused_recurrent_kda_packed_decode,
)
from sglang.srt.layers.attention.fla.fused_recurrent_linear_replayssm import (
fused_recurrent_linear_replayssm_decode,
)
device = "cuda"
B, H, HV, K, V = (cfg[x] for x in ("B", "H", "HV", "K", "V"))
scale = K**-0.5
# Shared static params across steps. A_log is per-head [HV] for both
# gate types; dt_bias is per-head [HV] (GDN) or per-K [HV, K] (KDA).
A_log = (torch.randn(HV, device=device, dtype=torch.float32) * 0.3).contiguous()
if is_kda:
dt_bias = (
torch.randn(HV, K, device=device, dtype=torch.float32) * 0.1
).contiguous()
else:
dt_bias = (
torch.randn(HV, device=device, dtype=torch.float32) * 0.1
).contiguous()
# Two independent state pools (same init), one per kernel. One slot/req.
num_slots = B
# Seed the state init so the marginal bf16 flush-state element is
# reproducible run-to-run (otherwise the pass/fail at the tolerance
# boundary flickers with the global RNG).
sg = torch.Generator(device=device).manual_seed(2024)
ref_state = torch.randn(
num_slots, HV, V, K, generator=sg, device=device, dtype=torch.float32
)
rep_state = ref_state.clone()
cache_indices = torch.arange(B, device=device, dtype=torch.int32)
# ReplaySSM ring tensors (caller-owned; this increment's caller == test).
# g_cache is per-step scalar [HV, L] for GDN, per-K [HV, L, K] for KDA.
d_cache = torch.zeros(num_slots, HV, L, V, device=device, dtype=dtype)
k_cache = torch.zeros(num_slots, H, L, K, device=device, dtype=dtype)
if is_kda:
g_cache = torch.zeros(
num_slots, HV, L, K, device=device, dtype=torch.float32
)
else:
g_cache = torch.zeros(num_slots, HV, L, device=device, dtype=torch.float32)
write_pos = torch.zeros(B, device=device, dtype=torch.int32)
tols = _tols(dtype)
flush_step_seen = False
for step in range(self.NUM_STEPS):
mixed_qkv, a, b = _build_inputs(
B, H, HV, K, V, dtype, device, seed=1000 + step, is_kda=is_kda
)
# ---- ground truth: packed decode, full-state write every step ----
ref_out = mixed_qkv.new_empty(B, 1, HV, V)
if is_kda:
# KDA baseline takes the per-K gate flattened: a=[B, HV*K],
# dt_bias=[HV*K] (same memory as our [B, HV, K] / [HV, K]).
fused_recurrent_kda_packed_decode(
mixed_qkv=mixed_qkv,
a=a.reshape(B, HV * K).contiguous(),
b=b,
A_log=A_log,
dt_bias=dt_bias.reshape(HV * K).contiguous(),
scale=scale,
initial_state=ref_state,
out=ref_out,
ssm_state_indices=cache_indices,
use_qk_l2norm_in_kernel=True,
)
else:
fused_recurrent_gated_delta_rule_packed_decode(
mixed_qkv=mixed_qkv,
a=a,
b=b,
A_log=A_log,
dt_bias=dt_bias,
scale=scale,
initial_state=ref_state,
out=ref_out,
ssm_state_indices=cache_indices,
use_qk_l2norm_in_kernel=True,
)
# ---- ReplaySSM buffered decode for this L ----
rep_out = mixed_qkv.new_empty(B, 1, HV, V)
# slice 2: force a flush at a radix track boundary even mid-buffer.
force_now = step in force_flush_steps
ff_tensor = (
torch.ones(B, device=device, dtype=torch.int32) if force_now else None
)
is_flush = bool((write_pos[0].item()) == L - 1) or force_now
flush_step_seen = flush_step_seen or is_flush
fused_recurrent_linear_replayssm_decode(
mixed_qkv=mixed_qkv,
a=a,
b=b,
A_log=A_log,
dt_bias=dt_bias,
scale=scale,
initial_state=rep_state,
d_cache=d_cache,
k_cache=k_cache,
g_cache=g_cache,
out=rep_out,
ssm_state_indices=cache_indices,
write_pos=write_pos,
force_flush=ff_tensor,
use_qk_l2norm_in_kernel=True,
is_kda=is_kda,
# nk=1 at L=1 keeps the K-reduction in a single tile (no
# K-tiling), matching the reference's single-pass reduction;
# nk=2 for L>1 exercises the K-tiled reconstruction path.
nk=1 if L == 1 else 2,
)
# Output must match every step.
if dtype == torch.float32 and L == 1 and not is_kda:
# GDN L=1: essentially bit-exact -- only an alpha*(sum) vs
# sum(alpha*) scalar-multiply reordering separates this from the
# reference. (KDA L=1 has the analogous per-K reorder, bounded by
# ~K*eps, so it uses the regular tensor-core tolerance below.)
torch.testing.assert_close(
rep_out,
ref_out,
atol=2e-6,
rtol=1e-5,
msg=f"GDN L=1 step={step} out",
)
else:
torch.testing.assert_close(
rep_out,
ref_out,
msg=f"is_kda={is_kda} L={L} step={step} out",
**tols,
)
# On a flush step the ReplaySSM checkpoint is brought fully
# up-to-date and the ring logically clears -> compare states.
if is_flush:
if dtype == torch.float32 and L == 1 and not is_kda:
torch.testing.assert_close(
rep_state,
ref_state,
atol=2e-6,
rtol=1e-5,
msg=f"GDN L=1 step={step} state",
)
else:
# The flush state is reconstructed via the tensor-core dot,
# so it carries tensor-core precision: ~tf32 (~4e-4, measured
# max_abs ~7e-4) for fp32, ~bf16 (measured max_abs ~1.9e-3)
# for bf16 -- looser than the per-step OUTPUT, which passes at
# the tighter output tol because the tf32/bf16 error partly
# cancels in the q-reduction. The per-step decay (alpha<1)
# attenuates old reconstruction error so it does not
# accumulate across flushes over a long decode; e2e accuracy
# is unaffected (ReplaySSM GSM8K parity).
state_tols = (
dict(atol=1.5e-3, rtol=1e-3)
if dtype == torch.float32
else dict(atol=3e-3, rtol=1e-2)
)
torch.testing.assert_close(
rep_state,
ref_state,
msg=f"is_kda={is_kda} L={L} step={step} state(flush)",
**state_tols,
)
# Advance the ring cursor (caller's responsibility in this phase):
# wrap to 0 after a flush (natural L-1 OR a forced track-boundary
# flush), otherwise increment.
if force_now:
write_pos = torch.zeros_like(write_pos)
else:
write_pos = torch.where(
write_pos == L - 1,
torch.zeros_like(write_pos),
write_pos + 1,
)
# Explicit wrap/flush boundary must have been exercised for L>1; for
# L=1 every step is a flush.
self.assertTrue(flush_step_seen, f"flush boundary never hit for L={L}")
# ---- GDN (scalar gate) ----
def test_replayssm_matches_gdn_packed_decode_fp32(self):
for cfg in self.CONFIGS:
for L in self.L_SWEEP:
with self.subTest(cfg=cfg, L=L, dtype="fp32"):
self._run_one(cfg, L, torch.float32)
def test_replayssm_matches_gdn_packed_decode_bf16(self):
for cfg in self.CONFIGS:
for L in self.L_SWEEP:
with self.subTest(cfg=cfg, L=L, dtype="bf16"):
self._run_one(cfg, L, torch.bfloat16)
# ---- KDA (per-K gate) ----
def test_replayssm_matches_kda_packed_decode_fp32(self):
for cfg in self.CONFIGS:
for L in self.L_SWEEP:
with self.subTest(cfg=cfg, L=L, dtype="fp32"):
self._run_one(cfg, L, torch.float32, is_kda=True)
def test_replayssm_matches_kda_packed_decode_bf16(self):
for cfg in self.CONFIGS:
for L in self.L_SWEEP:
with self.subTest(cfg=cfg, L=L, dtype="bf16"):
self._run_one(cfg, L, torch.bfloat16, is_kda=True)
# ---- forced (radix track-boundary) flush, both gate types ----
def test_force_flush_matches_reference(self):
"""A radix track-boundary forces a flush mid-buffer (write_pos != L-1).
The kernel must fold the partial ring + current token into the
checkpoint so an external snapshot reads an up-to-date state; output
every step and the forced-flush checkpoint must still match the running
recurrence -- for both the GDN scalar gate and the KDA per-K gate."""
cfg = dict(B=4, H=8, HV=16, K=128, V=128)
for is_kda in (False, True):
for dtype in (torch.float32, torch.bfloat16):
with self.subTest(is_kda=is_kda, dtype=str(dtype)):
self._run_one(
cfg,
L=8,
dtype=dtype,
force_flush_steps=(3, 5, 11, 20),
is_kda=is_kda,
)
def test_flush_boundary_state_exact_l1(self):
"""At L=1 (GDN) every step is a flush; the kernel is algebraically the
packed decode. Each step reproduces the reference output AND the full
state to atol 2e-6 / rtol 1e-5 (essentially bit-exact) in fp32. This also
exercises the ``write_pos == L-1`` flush boundary on every step."""
self._run_one(dict(B=4, H=4, HV=8, K=128, V=128), L=1, dtype=torch.float32)
if __name__ == "__main__":
unittest.main()