Repository navigation
[KDA] Support ReplaySSM ring-write in the fused chain-verify kernel - #36821
Conversation
04fcc46 to
948bfdd
Compare
948bfdd to
c3c1a77
Compare
| or intermediate_state_cache is None | ||
| ): | ||
| return False | ||
| if replayssm_on: |
There was a problem hiding this comment.
This enables fused+ring for all eligible batches, but the runtime still uses BV=4. In the H20 results above, B=4/16/64 go from 10.18/14.99/51.46 us to 19.06/67.38/290.05 us. Can we keep the unfused path for the batch/architecture combinations that regress? The best-BV sweep isn't wired into dispatch, and B=16/64 still regress even with that tuning.
There was a problem hiding this comment.
Agree. The best-BV sweep isn't used by the runtime. I've restricted the new fused+ring path to SM90 with B=1.
|
|
||
|
|
||
| @pytest.mark.parametrize("case", _CASES) | ||
| def test_matches_unfused_reference(case): | ||
| _compare_case(case, num_warps=4) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("case", _RING_CASES) | ||
| def test_replayssm_ring_matches_unfused(case): | ||
| _compare_case(case, num_warps=4, use_ring=True) |
There was a problem hiding this comment.
Could we add a backend test that runs verify -> commit -> verify with both partial and full acceptance? This test compares outputs and ring contents, but doesn't exercise commit/conv rollback or check the next round's output. Please compare the committed SSM state, conv state, and next verify output against the unfused path.
There was a problem hiding this comment.
Added backend-level verify -> commit -> verify tests covering partial and full acceptance, multiple layers, and GQA.
62b8a21 to
cb7d2f7
Compare
5dcea01 to
64683c2
Compare
The fused KDA chain-verify kernel and ReplaySSM spec-verify were mutually
exclusive: _can_run_fused_chain_verify bailed on replayssm_rawv, forcing
the unfused reference pair (transpose-copy + conv1d + transpose-copy +
recurrence) whenever the ring replaced the per-step ssm snapshots. Teach
the fused kernel CACHE_RING so the two compose:
- kernel: stash each step's pre-l2norm k / pre-delta v / gate / beta into
the per-slot rings at the same point as the unfused CACHE_RING block
(fused_sigmoid_gating_recurrent.py), reusing h0_idx and is_qk_owner;
keep the t < MAX_CACHE_LEN absorb-overflow guard. Off -> dead code.
- wrapper: same cache_ring/replayssm_* parameter names as the unfused
kernel so the backend's ring_kwargs pass through both unchanged.
- backend: hoist ring_kwargs ahead of the fused branch and share it with
the unfused dispatcher call; drop the replayssm bail-out and gate the
intermediate_state_cache checks (None under ReplaySSM) behind
_replayssm_ring_ok ring validation, falling back to unfused on any
layout mismatch.
- test: ring cases assert output/conv-state/conv-window and all four
rings bitwise-equal against unfused+ring (shared garbage init catches
out-of-slot scribbles), including HV != H GQA shapes that pin the
per-k-head rawk vs per-v-head g/beta writer split.
- benchmark/kernels/bench_kda_verify_sweep.py: in-graph sweep over
{unfused,fused} x {snapshots,ring} plus a KDA_VERIFY_BLOCK_V re-tune
mode (BV=4 was tuned with snapshot writes on).
Ring values are elementwise and upstream of every tl.sum, so they are
bit-exact at num_warps=4; the commit fold is unchanged and replays the
same bytes either way.
The fixed BV=4 fused ring path regresses at larger batches on H20-3e. Restrict it to SM90 single-request batches until other configurations have been benchmarked, leaving snapshot and CuTe dispatch unchanged. Add backend verify -> commit -> verify tests for partial/full acceptance, multi-layer state and GQA. Check committed SSM against unfused snapshots, conv rollback against the input history, and next-round output against the unfused path. Cover fallback and existing snapshot dispatch too. Cross-kernel compares use ulp tolerances instead of bitwise equality: the fused and unfused verify kernels reduce K with different tile shapes (BV=4 vs BV=32), so their fp32 partials differ by ~1 ulp and the bf16 output flips on rare rounding-boundary elements (1 in ~1k-8k on H100 and B300 -- the snapshot-only dispatch test hits it too, so it predates the ring path), and the ReplaySSM fold vs the snapshot recurrence differ by ~1 fp32 ulp per element on B300. Output compares allow one bf16 ulp; the snapshot oracle allows 1e-5, still three orders below a wrong-step commit. Rings, conv state/windows, the fused-vs-unfused committed state and the fallback path stay bitwise.
CI (H100) failed test_replayssm_ring_matches_unfused[case10] on the kernel test's torch.equal output check: one element out of 8192 off by one bf16 ulp. Same cause as the backend-test tolerances in the previous commit -- the fused BV=4 tile reduces K in a different order than the reference's BV=32 tile, so fp32 partials differ by ~1 ulp and the bf16 output flips on rare rounding-boundary elements. The added GQA cases simply gave the pre-existing effect more elements to land on; B300 does not flip this input at num_warps=4 but does flip another at num_warps=1, so the effect is input- and architecture-dependent rather than a ring bug (all four rings, conv state and conv windows stay bitwise on H100, H20-3e and B300). Compare the output with rtol=2**-7 (one bf16 ulp) instead of torch.equal; conv state, windows and rings keep their bitwise asserts. Correct the wrapper comment that claimed bit-identical output and keep the tolerance comments to the one fact the code does not show.
The B=1 restriction stays; the SM90-only restriction had no measurement
behind it. bench_kda_verify_sweep.py, us/layer in-graph, runtime BV=4,
fused+ring vs unfused+ring:
B300 (sm_103) H=8 T=4 safe: B=1 10.66 -> 7.14 (1.49x), B=2 1.24x,
B=4 0.79x, B=16 0.29x
H=12 T=8 safe: B=1 19.04 -> 13.75 (1.38x), B=2 0.78x
H20-3e (sm_90) H=8 T=4 safe: B=1 9.56 -> 7.52 (1.27x), B=2 0.80x,
B=4 0.53x
H=12 T=8 safe: B=1 17.29 -> 15.12 (1.14x), B=2 0.63x
Both architectures win at B=1 and lose from B=2 (T=8) or B=4 (T=4), so
the gate becomes B=1 on SM90 or SM100. Larger batches only win with
BV=16/32 (up to B=16 at T=4), which is the pending BV re-tune.
The backend tests take a platform dict instead of a single is_sm90 flag:
the enabled path is also exercised under SM100, the fallback cases pin
both facts off, and the snapshot dispatch is checked on SM90, SM100 and
neither.
64683c2 to
5daddf2
Compare

Motivation
The fused KDA chain-verify kernel (
fused_kda_conv_gating_verify, gated bySGLANG_OPT_FUSED_KDA_VERIFY) and ReplaySSM spec-verify (--enable-linear-replayssm+ spec ring) were mutually exclusive:_can_run_fused_chain_verifyreturnedFalsewheneverreplayssm_rawvwas set, so enabling ReplaySSM silently dropped the verify hot path back to the unfused reference pair (transpose-copy +causal_conv1d_update+ transpose-copy + recurrence). We could have the ~1.3x fused verify kernel or the ReplaySSM memory win (nointermediate_ssmsnapshot buffer, the dominant spec scratch), but not both.On Blackwell the CuTe DSL dspark MTP kernel already composes ring-write with a fused verify, but that path is SM100-only with a fixed Kimi-K3 shape contract. This PR brings the same composition to the Triton fused chain-verify, which is the only fused verify available on Hopper and on non-dspark chain speculation.
Beyond unblocking the combination, ring mode removes the fused kernel's dominant store: the per-step
[K, V]fp32 state snapshot (64 KB per head-step) is replaced by the ring records (raw v + raw k + gate + beta, ~1 KB per head-step), on a kernel that is latency-bound.Technical principle
Per draft step$t$ the fused kernel computes the conv activations, the gates, and one delta-rule update:
In ring mode the kernel stores$(\hat{k}_t,\ \hat{v}_t,\ g_t,\ \beta_t)$ into the per-slot ring at exactly the point where $\hat{k}_t$ is still pre-l2norm and $\hat{v}_t$ still pre-delta ($g_t$ , $\beta_t$ just formed), instead of snapshotting $h_t$ . On commit, the accepted prefix $a \le T$ is folded back into the persistent checkpoint in place:
The fold kernel is a bit-clone of the recurrence (same decay-delta-rank-1 op order,$4KV = 65536$ B (snapshot) to $2V + 2K + 4K + 4 \approx 1028$ B (ring), about $64\times$ , and the pool-level $\mathrm{slots} \times (\gamma{+}1) \times HV \times K \times V$ fp32) disappears entirely.
num_warps=1), so replaying the ring reproduces the committed state bit-identically — which is why the stored operands must be the pre-mutation values. Write traffic per head-step drops fromintermediate_ssmbuffer (Modifications
python/sglang/kernels/ops/attention/fla/fused_kda_conv_recurrent_verify.py— addCACHE_RINGto the kernel: each draft step stashes its pre-l2norm k, pre-delta v, in-kernel gateb_g, andb_betainto the per-slot ReplaySSM rings, at the exact point the unfusedCACHE_RINGblock infused_sigmoid_gating_recurrent.pydoes (after beta is formed, before l2norm mutatesb_kand the delta rule mutatesb_v), so the commit-time exact fold replays identical bytes. The ring slot reuses the already-loadedh0_idx;rawkdedups via the existingis_qk_owner(one writer per k-head);g/betawrite once per v-head ati_v == 0; thet < MAX_CACHE_LENguard mirrors the reference kernel's absorb-overflow protection. WithCACHE_RING=False(the default) everything folds away — the existing paths compile to the same code as before. The wrapper takes the samecache_ring/replayssm_*parameter names as the unfused kernel so the backend passes one kwargs dict through both, and validates ring shapes/dtypes/inner strides.python/sglang/srt/layers/attention/linear/kda_backend.py— hoist thering_kwargsconstruction ahead of the fused branch and share it between the fused chain-verify call and the unfused dispatcher call._can_run_fused_chain_verifyno longer bails onreplayssm_rawv; under ReplaySSM it skips theintermediate_state_cachechecks (the buffer isNonethere by design) and instead validates the four per-layer ring views via the new_replayssm_ring_okhelper (ndim, shape, dtype, packed inner strides,L >= draft_token_num, device). Any mismatch falls back to the unfused path, which handles more layouts.test/registered/kernels/test_fused_kda_conv_recurrent_verify.py— newtest_replayssm_ring_matches_unfusedparameterized over the existing 8 cases plus 3 GQA (HV != H) cases. Ring mode runs both kernels withintermediate_states_buffer=None(exactly the production ReplaySSM configuration) and asserts the output, conv state, conv windows, and all four rings are bitwise equal between fused and unfused. The rings share a garbage-filled template cloned per side, so a full-tensor compare also catches out-of-slot scribbles and negative-slot writes. The GQA cases pin the per-k-headrawkvs per-v-headg/betawriter split — the one mistake that would silently corrupt another head's ring. Ring values are elementwise (conv FMA chain, gate, sigmoid) and sit upstream of everytl.sum, so bitwise equality holds at the productionnum_warps=4.benchmark/kernels/bench_kda_verify_sweep.py(new) — in-graph sweep over the four dispatchable variants ({unfused, fused} x {snapshots, ring}), CUDA-graph timed (these are ~10us kernels; bare launches would measure launch overhead). Includes a built-in fused-vs-unfused ring bitwise guard before timing, a--sweep-bvmode to re-tuneKDA_VERIFY_BLOCK_V(BV=4 was tuned with snapshot writes on, so ring mode may move the optimum),--lower-boundfor the safe-gate (ling-v3) vs standard-gate shapes, and--hv-headsfor GQA. Imports onlysglang.kernels.*so it runs on boxes where thesglang.srtimport chain is unavailable.Correctness
The acceptance criterion is deliberately stronger than "matches the current fused path": under ReplaySSM the committed state is produced by the fold kernel replaying the ring, so the invariant that matters is that fused+ring and unfused+ring fill bit-identical rings (then the unchanged fold produces bit-identical committed states). The new test asserts exactly that, plus bitwise-equal outputs, conv states, and conv windows.
Performance
fused+ringvsunfused+ringis the end-to-end win this PR delivers under ReplaySSM (previously forced unfused);fused+ringvsfusedisolates the snapshot-write removal. Measured on H20-3e (sm90), ling-v3 TP4 shape (H = HV = 8, K = V = 128, T = 4, safe gate), us/layer, CUDA-graph timed, repeat-run variance ~1-2%:H20-3e (sm90):
B300 (sm103):
Takeaways on this card: with BV re-tuned,
fused+ringbeatsunfused+ringby 1.44x at B=1 and 1.30x at B=4, and beatsfused(snapshots) by up to 2.0x at B=16 / 1.75x at B=64 — the snapshot store is exactly what dominates at larger batch, and ring mode removes it. The crossover to the unfused pair sits around B~8-16 even at the best BV, confirming the batch-aware dispatch follow-up; and the optimal BV shifts larger once the snapshot write is gone (BV=16-32 vs the current BV=4), confirming the BV re-tune follow-up. B200 (production target) numbers pending — the fixed BV=4 constant was tuned there and the crossover point will differ.Follow-ups
KDA_VERIFY_BLOCK_Von B200 with--sweep-bvonce ring mode lands; the current constant was measured with the snapshot store dominating the write traffic.retrieve_*) and ragged verify layouts keep the unfused kernels, unchanged.Checklist
CI States
Latest PR Test (Base): ❌ Run #34865490140
Latest PR Test (Extra): ❌ Run #34865489076
Latest PR Test (AMD ROCm 10): ❌ Run #34865489373