From b8ec544946f1c5b6e17a919a691b05c5b3e7af84 Mon Sep 17 00:00:00 2001 From: "Ho-Ren (Jack) Chuang" Date: Sat, 18 Jul 2026 20:58:16 -0700 Subject: [PATCH] [DSA] Integrate Q8KV8 FP8 Sparse MLA Prefill into the DSA Backend (DeepSeek-V3.2) (#30514) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Xiaoyu Zhang <1182563586@qq.com> --- .../autoregressive/DeepSeek/DeepSeek-V3_2.mdx | 2 +- .../advanced_features/attention_backend.mdx | 6 + .../advanced_features/server_arguments.mdx | 2 +- .../sparse_mla_q8kv8_prefill_sm90/kernel.cuh | 273 ++++++++++++------ .../ops/attention/dsa/dequant_k_cache.py | 132 +++++++++ python/sglang/kernels/ops/attention/utils.py | 6 + .../sglang/kernels/ops/kvcache/cache_ops.py | 67 +++++ .../srt/layers/attention/dsa_backend.py | 220 +++++++++++++- python/sglang/srt/server_args.py | 1 + .../jit/test_sparse_mla_q8kv8_prefill_sm90.py | 187 +++++++++++- 10 files changed, 801 insertions(+), 95 deletions(-) diff --git a/docs_new/cookbook/autoregressive/DeepSeek/DeepSeek-V3_2.mdx b/docs_new/cookbook/autoregressive/DeepSeek/DeepSeek-V3_2.mdx index 5d821666b..14166f925 100644 --- a/docs_new/cookbook/autoregressive/DeepSeek/DeepSeek-V3_2.mdx +++ b/docs_new/cookbook/autoregressive/DeepSeek/DeepSeek-V3_2.mdx @@ -64,7 +64,7 @@ import { DeepSeekV32Deployment } from "/src/snippets/autoregressive/deepseek-v32 ### 3.2 Configuration Tips - **Short-sequence MHA prefill (adaptive):** For prefill sequences shorter than 2048 tokens (default threshold), the DSA backend automatically switches to standard MHA (using FlashAttention variable-length on SM90, TRT-LLM ragged MHA on SM100). To extend this to longer sequences set env var `SGLANG_DSA_PREFILL_DENSE_ATTN_KV_LEN_THRESHOLD` to a larger value (potential minor accuracy trade-off). -- **DSA prefill/decode attention kernels (`--dsa-prefill-backend`, `--dsa-decode-backend`):** The `dsa` backend is automatically selected for DeepSeek-V3.2. Available kernels: `flashmla_sparse`, `flashmla_kv`, `flashmla_auto`, `fa3` (Hopper only), `tilelang` (GPU/HPU/NPU), `aiter` (AMD, decode only), `trtllm` (Blackwell only). Defaults: Hopper BF16 KV → `flashmla_sparse` prefill / `fa3` decode; Hopper FP8 KV → `flashmla_kv` both; Blackwell BF16 → `flashmla_sparse` / `trtllm`; Blackwell FP8 → `trtllm` both. +- **DSA prefill/decode attention kernels (`--dsa-prefill-backend`, `--dsa-decode-backend`):** The `dsa` backend is automatically selected for DeepSeek-V3.2. Available kernels: `flashmla_sparse`, `flashmla_sparse_q8` (native FP8 e4m3 sparse prefill — no fp8→bf16 dequantization round-trip; Hopper SM90 + `--kv-cache-dtype fp8_e4m3` only, prefill only), `flashmla_kv`, `flashmla_auto`, `fa3` (Hopper only), `tilelang` (GPU/HPU/NPU), `aiter` (AMD, decode only), `trtllm` (Blackwell only). Defaults: Hopper BF16 KV → `flashmla_sparse` prefill / `fa3` decode; Hopper FP8 KV → `flashmla_kv` both; Blackwell BF16 → `flashmla_sparse` / `trtllm`; Blackwell FP8 → `trtllm` both. - **Index Cache:** Reuses indexer results across layers for efficiency at negligible accuracy cost. For **GLM-5** specifically, append `--json-model-override-args '{"index_topk_pattern": "FFSFSSSFSSFFFSSSFFFSFSSSSSSFFSFFSFFSSFFFFFFSFFFFFSFFSSSSSSFSFFFSFSSSFSFFSFFSSS"}'` for a better speed/accuracy tradeoff. - **HiSparse (experimental):** Reduces per-request GPU memory during long-context decode by offloading KV data to CPU pinned memory. Requires PD disaggregation mode (decode instance only). See [HiSparse Guide](../../../docs/advanced_features/hisparse_guide). - **NVFP4 on Blackwell:** Specify `--quantization modelopt_fp4` and `--moe-runner-backend flashinfer_trtllm` (recommended) / `flashinfer_cutlass` / `flashinfer_cutedsl`. Full example: diff --git a/docs_new/docs/advanced_features/attention_backend.mdx b/docs_new/docs/advanced_features/attention_backend.mdx index f31adc78d..5010de17c 100644 --- a/docs_new/docs/advanced_features/attention_backend.mdx +++ b/docs_new/docs/advanced_features/attention_backend.mdx @@ -422,6 +422,12 @@ Internally, the DSA backend dispatches to different sub-backends for prefill and ✅ Default prefill on Hopper and Blackwell (BF16) + + flashmla_sparse_q8 + ✅ + ❌ + Native FP8 (q8×kv8) sparse prefill on Hopper (SM90); requires --kv-cache-dtype fp8_e4m3 + flashmla_kv ✅ diff --git a/docs_new/docs/advanced_features/server_arguments.mdx b/docs_new/docs/advanced_features/server_arguments.mdx index 9ec59267b..2fe313a5f 100644 --- a/docs_new/docs/advanced_features/server_arguments.mdx +++ b/docs_new/docs/advanced_features/server_arguments.mdx @@ -1408,7 +1408,7 @@ Please consult the documentation below and [server_args.py](https://github.com/s `--dsa-prefill-backend` DSA backend for the prefill stage (overrides `--attention-backend` when running DeepSeek DSA-style attention). Auto (hardware-dependent) - flashmla_sparse, flashmla_kv, flashmla_auto, fa3, tilelang, aiter, trtllm + flashmla_sparse, flashmla_sparse_q8, flashmla_kv, flashmla_auto, fa3, tilelang, aiter, trtllm `--dsa-decode-backend` diff --git a/python/sglang/jit_kernel/csrc/sparse_mla_q8kv8_prefill_sm90/kernel.cuh b/python/sglang/jit_kernel/csrc/sparse_mla_q8kv8_prefill_sm90/kernel.cuh index dc8c1ccac..9e1f47a71 100644 --- a/python/sglang/jit_kernel/csrc/sparse_mla_q8kv8_prefill_sm90/kernel.cuh +++ b/python/sglang/jit_kernel/csrc/sparse_mla_q8kv8_prefill_sm90/kernel.cuh @@ -14,11 +14,11 @@ limitations under the License. ==============================================================================*/ // SM90 FP8 native sparse MLA prefill kernel. -// + // Algorithm inspired by DeepSeek FlashMLA // (https://github.com/deepseek-ai/FlashMLA); the kernel itself is a // clean-room re-implementation targeting the Q8KV8 sparse prefill path. -// + // Design: Native fp8 GMMA path // QK GEMM: fp8 SS (E4M3 x E4M3 -> F32, k=32, 2x throughput vs bf16) // PV GEMM: fp8 RS/SS (E4M3 x E4M3 -> F32, V physically transposed in smem) @@ -137,15 +137,36 @@ struct SparseMlaQ8Kv8PrefillKernel { array_aligned> q; // B_H * D_Q fp8 array_aligned> o; // B_H * D_V/2 bf16 } q_o; - array_aligned> k[2]; // 2x K double-buffer, fp8 - array_aligned> vt[2]; // 2x Vt transposed buffer, fp8 + array_aligned> k[2]; // 2x K double-buffer, fp8 + // Vt is split into four half-buffers (256x64 each) so each half can be + // handed to its consumer warpgroup as soon as it is transposed: + // vt_loc[i]: i=0 block0-left (WG0 local), i=1 block1-right (WG1 local) + // vt_rem[i]: i=0 block1-left (WG0 remote), i=1 block0-right (WG1 remote) + array_aligned> vt_loc[2]; + array_aligned> vt_rem[2]; array_aligned s[2]; // 2x S buffer, padded to a 36B row stride to avoid bank conflicts. - bool is_kv_valid[2][B_TOPK]; - float2 sM[32]; + // double-buffer the VALIDITY DATA by iteration-pair + // parity, matching bar_is_kv_valid_ready[2]. The consumer arrives + // bar_k_free BEFORE mask_rP reads these bits (perf: releases the producer's + // next K-load early), so a single-buffered array let the producer overwrite + // pair N+1's bits while a laggard consumer was still masking pair N -> + // wrong -INF pattern (nondeterministic corruption growing with CTA + // count). Dims: [k-buf/warpgroup][pair + // parity][slot]; producer ahead-ness is bounded to one pair by the + // bar_k_free protocol, so depth 2 suffices (same argument as the barrier). + bool is_kv_valid[2][2][B_TOPK]; + // double-buffer the WG0<->WG1 running-max exchange + // by iteration-pair parity (same recipe as is_kv_valid): any +-1-iteration + // overrun of the producer side lands in the OTHER slot instead of + // overwriting a value the peer is still reading. Empirical signature of + // the race: single-row, ~half-heads rescale blow-up (peer_scale computed + // from the wrong iteration's max), vanishing on same-tensor retry. + float2 sM[2][32]; float2 sL[64]; float final_max_logits[64], final_lse[64]; - transac_bar_t bar_q, bar_k0_ready[2], bar_k1_ready[2], bar_is_kv_valid_ready; + transac_bar_t bar_q, bar_k0_ready[2], bar_k1_ready[2], + bar_is_kv_valid_ready[2]; // double-buffer is_kv_valid_ready transac_bar_t bar_k0_free, bar_k1_free; // Consumers arrive after PV drains; the producer waits before reusing the Vt buffer. // These barriers are separate from K-free so K buffers can be released earlier. @@ -187,7 +208,10 @@ struct SparseMlaQ8Kv8PrefillKernel { plan.bar_k0_ready[i].init(128); plan.bar_k1_ready[i].init(128); } - plan.bar_is_kv_valid_ready.init(16); + CUTE_UNROLL + for (int i = 0; i < 2; ++i) { + plan.bar_is_kv_valid_ready[i].init(16); // double-buffer + } CUTE_UNROLL for (int i = 0; i < 2; ++i) { // Transaction barriers for Vt buffer safety: 128 arrivals from each consumer WG. @@ -215,8 +239,11 @@ struct SparseMlaQ8Kv8PrefillKernel { const fp8_t* gQ = reinterpret_cast(params.q) + s_q_idx * (int64_t)params.stride_q_s_q + q_h_idx * B_H * (int64_t)params.stride_q_h_q; - // Vectorized Q loading via cp.async.cg (16 bytes per op) - constexpr int Q_GROUP_SIZE = 8; + // Vectorized Q loading via cp.async.cg (16 bytes per op). + // Group size 4 (not 8): with 8-row groups two warps' cp.async stores + // could overlap the same Q smem rows (WAW hazard); 4 keeps each row + // owned by exactly one group. + constexpr int Q_GROUP_SIZE = 4; constexpr int Q_NUM_GROUPS = 128 / Q_GROUP_SIZE; constexpr int Q_ROWS_PER_GROUP = B_H / Q_NUM_GROUPS; int q_ig = idx_in_warpgroup % Q_GROUP_SIZE; @@ -248,6 +275,12 @@ struct SparseMlaQ8Kv8PrefillKernel { // -------------------------------------------------------- float rM[2] = {MAX_INIT_VAL, MAX_INIT_VAL}; float rL[2] = {0.0f, 0.0f}; + // WG1 consumes WG0's peer P (s[0]) which is in WG0's LOCAL max frame + // (max(prev,block0)), not the combined frame. peer_scale = exp2(WG0_local - combined) + // brings it into the combined frame (<=1, ==1 at low magnitude => no-op). Clamped + // away from 0 so the 1/peer_scale pre-scale cannot overflow. WG0's peer (s[1]) is + // already combined-frame, so peer_scale stays 1.0 for WG0. + float peer_scale[2] = {1.0f, 1.0f}; Tensor rO = partition_fragment_C(TiledMMA_PV_LocalP{}, Shape, Int>{}); Tensor rP = partition_fragment_C(TiledMMA_QK{}, Shape, Int>{}); cute::fill(rO, 0.0f); @@ -259,6 +292,7 @@ struct SparseMlaQ8Kv8PrefillKernel { Tensor rP_fp8_local = make_tensor(rP_fp8_layout_t{}); bool cur_bar_wait_phase = 0; + bool kv_valid_phase[2] = {false, false}; // per-instance phase for bar_is_kv_valid_ready[2] struct Warpgroup0 {}; struct Warpgroup1 {}; @@ -272,28 +306,39 @@ struct SparseMlaQ8Kv8PrefillKernel { gemm_ss(clear_accum, tiled_mma_QK, sQ_tile, sK_tile, rP, idx_in_warpgroup); }; - auto mask_rP = [&](auto wg_tag) { + auto mask_rP = [&](auto wg_tag, int block_idx) { constexpr bool IS_WG1 = std::is_same_v; - plan.bar_is_kv_valid_ready.wait(cur_bar_wait_phase); + // bar_is_kv_valid_ready was single-buffered but arrived EARLY by the + // producer and waited LATE here (after the prefetched QK gemm). The producer runs 1 + // loop-iter ahead, so it can arrive the single-bit barrier TWICE before this wait + // consumes the first flip -> the parity aliases back and the laggard consumer waits for + // a flip that never comes (producer wedged at bar_vt_free) -> deadlock + // under load. Fix: double-buffer by iteration parity (producer is at most 1 loop-iter + // ahead => always the OTHER instance) with an independent per-instance phase. + int _kv_buf = (block_idx >> 1) & 1; + plan.bar_is_kv_valid_ready[_kv_buf].wait(kv_valid_phase[_kv_buf]); + kv_valid_phase[_kv_buf] ^= 1; CUTE_UNROLL for (int row_idx = 0; row_idx < 2; ++row_idx) { CUTE_UNROLL for (int i = row_idx * 2; i < size(rP); i += 4) { int col = 8 * (i / 4) + (idx_in_warpgroup % 4) * 2; - if (!plan.is_kv_valid[IS_WG1][col]) rP(i) = -INFINITY; - if (!plan.is_kv_valid[IS_WG1][col + 1]) rP(i + 1) = -INFINITY; + if (!plan.is_kv_valid[IS_WG1][_kv_buf][col]) rP(i) = -INFINITY; // parity-buffered + if (!plan.is_kv_valid[IS_WG1][_kv_buf][col + 1]) rP(i + 1) = -INFINITY; } } }; // online_softmax: compute softmax on rP (f32), then convert to fp8 - auto online_softmax_and_rescale_o = [&](auto wg_tag) { + // _par = iteration-pair parity ((block_idx>>1)&1) selecting + // the sM slot for this iteration's WG0<->WG1 max exchange. + auto online_softmax_and_rescale_o = [&](auto wg_tag, int _par) { // mask_rP already waits for the validity mask. constexpr bool IS_WG1 = std::is_same_v; const float scale = qk_combined_scale_div_log2; float r_sM[2]; if constexpr (IS_WG1) { - *(float2*)r_sM = plan.sM[idx_in_warpgroup / 4]; + *(float2*)r_sM = plan.sM[_par][idx_in_warpgroup / 4]; } float new_maxs[2]; CUTE_UNROLL @@ -307,6 +352,12 @@ struct SparseMlaQ8Kv8PrefillKernel { cur_max = max(cur_max, __shfl_xor_sync(0xffffffff, cur_max, 2)); cur_max *= scale; new_maxs[row_idx] = max(IS_WG1 ? r_sM[row_idx] : rM[row_idx], cur_max); + // peer P (WG0's s[0]) is in WG0's local frame r_sM; bring it to the + // combined frame new_maxs. <=1, ==1 at low magnitude. Floor at 2^-30 so the + // 1/peer_scale pre-scale stays finite (peer weight there is negligible anyway). + if constexpr (IS_WG1) { + peer_scale[row_idx] = fmaxf(exp2f(r_sM[row_idx] - new_maxs[row_idx]), exp2f(-30.0f)); + } float scale_for_o = exp2f(rM[row_idx] - new_maxs[row_idx]); CUTE_UNROLL for (int i = row_idx * 2; i < size(rO); i += 4) { @@ -326,7 +377,7 @@ struct SparseMlaQ8Kv8PrefillKernel { } __syncwarp(); if (idx_in_warpgroup % 4 == 0) { - plan.sM[idx_in_warpgroup / 4] = *(float2*)new_maxs; + plan.sM[_par][idx_in_warpgroup / 4] = *(float2*)new_maxs; } rM[0] = new_maxs[0]; rM[1] = new_maxs[1]; @@ -441,32 +492,10 @@ struct SparseMlaQ8Kv8PrefillKernel { } }; - auto undo_v_transpose_col_permutation = [&]() { - // Undo the column permutation from the fp8 V transpose before writing O. - // CLayout_64x256: col bit0 = t1_bit0 (thread), col bit3 = v1 (register). - // V transpose introduces bit0<->bit3 swap. Fix by cross-thread exchange: - // thread with t1_bit0=0, v1=1 <-> thread with t1_bit0=1, v1=0 - // Within each 4-element group (same v2=row): idx%4 in {0,1} are v1=0, {2,3} are v1=1. - int t1_bit0 = (threadIdx.x >> 2) & 1; -#pragma unroll - for (int g = 0; g < 32; g++) { - float a = rO(4 * g + 0); - float b = rO(4 * g + 1); - float c = rO(4 * g + 2); - float d = rO(4 * g + 3); - float send0 = t1_bit0 ? a : c; - float send1 = t1_bit0 ? b : d; - float recv0 = __shfl_xor_sync(0xFFFFFFFF, send0, 4); - float recv1 = __shfl_xor_sync(0xFFFFFFFF, send1, 4); - if (t1_bit0 == 0) { - rO(4 * g + 2) = recv0; - rO(4 * g + 3) = recv1; - } else { - rO(4 * g + 0) = recv0; - rO(4 * g + 1) = recv1; - } - } - }; + // No output un-permutation is needed here: the PV output columns are + // produced by the Vt B-operand, not by the permute_Cregs_fp8-rearranged P + // A-operand (the permute only reorders the P contraction/K dimension, + // never the output N). // ============================================================ // WG0 Pipeline -- native fp8 @@ -508,7 +537,7 @@ struct SparseMlaQ8Kv8PrefillKernel { CUTE_NO_UNROLL for (int block_idx = 0; block_idx < num_topk_blocks; block_idx += 2) { // Vt[0] left half: (256, 64) fp8 -- only half we transpose & use - Tensor sVt0l = make_tensor(make_smem_ptr(plan.vt[0].data()), SmemLayoutHalfVt{}); + Tensor sVt0l = make_tensor(make_smem_ptr(plan.vt_loc[0].data()), SmemLayoutHalfVt{}); if (block_idx == 0) { pipelined_wait_and_qkt_gemm_l(); @@ -518,11 +547,16 @@ struct SparseMlaQ8Kv8PrefillKernel { plan.bar_k0_free.arrive(); } - mask_rP(Warpgroup0{}); - online_softmax_and_rescale_o(Warpgroup0{}); + mask_rP(Warpgroup0{}, block_idx); // pass block_idx for kv_buf parity + online_softmax_and_rescale_o(Warpgroup0{}, (block_idx >> 1) & 1); save_rP_fp8_to_sS(plan.s[0].data()); - NamedBarrier::arrive(256, NamedBarriers::wg0_bunch_0_ready); + // was arrive-only: a +1-iteration overrun by WG0 could + // alias the named-barrier count against WG1's pending wait. Full + // rendezvous kills the aliasing; WG1's path here (mask_rP -> its + // bar_is_kv_valid wait) does not depend on anything WG0 does after + // this point, so no deadlock surface is added. + NamedBarrier::arrive_and_wait(256, NamedBarriers::wg0_bunch_0_ready); // Wait for Vt[0] left half only (producer + WG0 arrivals). // V[0]-RIGHT may still be transposing; WG0 doesn't need it. @@ -535,7 +569,7 @@ struct SparseMlaQ8Kv8PrefillKernel { // Overlap PV-local GMMA drain with barrier waits, sM read, and peer P load. NamedBarrier::arrive_and_wait(256, NamedBarriers::wg1_bunch_0_ready); float new_rM[2], scale_factors_arr[2]; - *(float2*)new_rM = plan.sM[idx_in_warpgroup / 4]; + *(float2*)new_rM = plan.sM[(block_idx >> 1) & 1][idx_in_warpgroup / 4]; CUTE_UNROLL for (int i = 0; i < 2; ++i) { scale_factors_arr[i] = exp2f(rM[i] - new_rM[i]); @@ -556,7 +590,7 @@ struct SparseMlaQ8Kv8PrefillKernel { // Rescale rO: must be after wait<0> since rO is PV-local accumulator rescale_rO(scale_factors_arr); - Tensor sVt1l = make_tensor(make_smem_ptr(plan.vt[1].data()), SmemLayoutHalfVt{}); + Tensor sVt1l = make_tensor(make_smem_ptr(plan.vt_rem[0].data()), SmemLayoutHalfVt{}); gemm_rs(false, TiledMMA_PV_LocalP{}, rP_fp8_local, sVt1l, rO, idx_in_warpgroup); warpgroup_commit_batch(); @@ -579,8 +613,6 @@ struct SparseMlaQ8Kv8PrefillKernel { } } - undo_v_transpose_col_permutation(); - reduce_L(); store_O(); @@ -614,7 +646,7 @@ struct SparseMlaQ8Kv8PrefillKernel { CUTE_NO_UNROLL for (int block_idx = 0; block_idx < num_topk_blocks; block_idx += 2) { // Vt[1] right half: (256, 64) fp8 -- only half we transpose & use - Tensor sVt1r = make_tensor(make_smem_ptr(plan.vt[1].data() + 256 * B_TOPK), SmemLayoutHalfVt{}); + Tensor sVt1r = make_tensor(make_smem_ptr(plan.vt_loc[1].data()), SmemLayoutHalfVt{}); if (block_idx == 0) { pipelined_wait_and_qkt_gemm_r_wg1(); @@ -624,13 +656,20 @@ struct SparseMlaQ8Kv8PrefillKernel { plan.bar_k1_free.arrive(); } - mask_rP(Warpgroup1{}); + mask_rP(Warpgroup1{}, block_idx); // pass block_idx for kv_buf parity NamedBarrier::arrive_and_wait(256, NamedBarriers::wg0_bunch_0_ready); - online_softmax_and_rescale_o(Warpgroup1{}); + online_softmax_and_rescale_o(Warpgroup1{}, (block_idx >> 1) & 1); save_rP_fp8_to_sS(plan.s[1].data()); - NamedBarrier::arrive(256, NamedBarriers::wg1_bunch_0_ready); + // was arrive-only — the mirror of the wg0_bunch case: if WG1 runs a + // full iteration ahead, its second arrive aliases the named-barrier + // count against WG0's pending wait at this rendezvous, so WG0 releases + // early and reads the combined max (sM) BEFORE WG1 wrote it -> wrong + // rescale factors on one CTA's 64-head half. No deadlock surface: + // WG0 reaches its wait via wg0_bunch (which WG1 arrives earlier) plus + // producer vt0_left; neither depends on WG1's progress past here. + NamedBarrier::arrive_and_wait(256, NamedBarriers::wg1_bunch_0_ready); // Wait for Vt[1] transpose (prod+WG1 barrier) NamedBarrier::arrive_and_wait(256, vt1_for_wg1); @@ -643,6 +682,18 @@ struct SparseMlaQ8Kv8PrefillKernel { warpgroup_fence_operand(rO); warpgroup_fence_operand(rP_fp8_local); plan.bar_vt_free[1].arrive(); + // pre-scale rO (= local block1, combined frame) by 1/peer_scale so the + // peer PV (WG0's block0 P in WG0's local frame) lands correct after the post-scale: + // rO/peer_scale; rO += peer_PV; rO *= peer_scale => local + peer_scale*peer_PV. + CUTE_UNROLL + for (int row = 0; row < 2; ++row) { + float inv = 1.0f / peer_scale[row]; + CUTE_UNROLL + for (int i = row * 2; i < size(rO); i += 4) { + rO(i) *= inv; + rO(i + 1) *= inv; + } + } load_sS_to_rP(plan.s[0].data()); NamedBarrier::arrive_and_wait(256, s_consumed_ready); @@ -650,7 +701,7 @@ struct SparseMlaQ8Kv8PrefillKernel { // V[0]-LEFT was signaled earlier; WG1 doesn't need it. NamedBarrier::arrive_and_wait(256, vt0_right_ready); - Tensor sVt0r = make_tensor(make_smem_ptr(plan.vt[0].data() + 256 * B_TOPK), SmemLayoutHalfVt{}); + Tensor sVt0r = make_tensor(make_smem_ptr(plan.vt_rem[1].data()), SmemLayoutHalfVt{}); gemm_rs(false, TiledMMA_PV_LocalP{}, rP_fp8_local, sVt0r, rO, idx_in_warpgroup); warpgroup_commit_batch(); @@ -658,8 +709,17 @@ struct SparseMlaQ8Kv8PrefillKernel { cur_bar_wait_phase ^= 1; // Overlap: start next-iteration QK-right while PV drains pipelined_wait_and_qkt_gemm_r_wg1(); - warpgroup_wait<1>(); + warpgroup_wait<1>(); // drains the peer PV (committed before the QK-right batch) warpgroup_fence_operand(rO); + // post-scale: rO = local + peer_scale * peer_PV (combined frame). + CUTE_UNROLL + for (int row = 0; row < 2; ++row) { + CUTE_UNROLL + for (int i = row * 2; i < size(rO); i += 4) { + rO(i) *= peer_scale[row]; + rO(i + 1) *= peer_scale[row]; + } + } warpgroup_fence_operand(rP_fp8_local); plan.bar_vt_free[0].arrive(); pipelined_wait_and_qkt_gemm_l_wg1(); @@ -667,14 +727,21 @@ struct SparseMlaQ8Kv8PrefillKernel { warpgroup_fence_operand(rP); plan.bar_k1_free.arrive(); } else { - warpgroup_wait<0>(); + warpgroup_wait<0>(); // drains the peer PV warpgroup_fence_operand(rO); + // post-scale (final iteration). + CUTE_UNROLL + for (int row = 0; row < 2; ++row) { + CUTE_UNROLL + for (int i = row * 2; i < size(rO); i += 4) { + rO(i) *= peer_scale[row]; + rO(i + 1) *= peer_scale[row]; + } + } plan.bar_vt_free[0].arrive(); } } - undo_v_transpose_col_permutation(); - reduce_L(); store_O(); @@ -720,6 +787,9 @@ struct SparseMlaQ8Kv8PrefillKernel { int64_t token_indices[2][NUM_ROWS_PER_GROUP]; bool is_token_valid[2][NUM_ROWS_PER_GROUP]; + // Hoisted invariant: base of the `topk` trailing zero pad + // rows in kv. -1 sentinels map to (pad_base + slot) = distinct zero rows. + const int pad_base = params.s_kv - params.topk; auto load_token_indices = [&](int block_idx) { CUTE_UNROLL for (int buf_idx = 0; buf_idx < 2; ++buf_idx) { @@ -727,9 +797,31 @@ struct SparseMlaQ8Kv8PrefillKernel { for (int local_row = 0; local_row < NUM_ROWS_PER_GROUP; ++local_row) { int offs = (block_idx + buf_idx) * B_TOPK + local_row * NUM_GROUPS + group_idx; int t = __ldg(gIndices + offs); - bool is_cur_token_valid = t >= 0 && t < params.s_kv; - if constexpr (HAVE_TOPK_LENGTH) { - is_cur_token_valid &= offs < topk_length; + bool is_cur_token_valid; + if constexpr (!HAVE_TOPK_LENGTH) { + // Map -1 sentinels to DISTINCT zero pad rows (slot `offs` -> + // pad_base+offs, distinct within each query) -> avoids the + // duplicate-index kernel slowdown while keeping uniform full-topk + // loads (data-independent, no DP hang). Replaces the per-layer + // torch.where in the integration (eliminates an elementwise launch). + // After clamping, t in [0, s_kv) by construction (offs < topk), so + // the LOAD is always safe (real or zero-pad row). + + // but the pad slots must be MASKED in the + // softmax (is_valid=false -> -INF in mask_rP), NOT scored: a zero + // KV row contributes exp(0 - max) to the denominator, which for + // few-valid rows (ctx << topk, e.g. the first tokens of a prompt) + // crushes the output by up to ~2048x. chunk<=16384 never exposed + // this because rows with ctx<=topk take the DENSE prefill path + // (per-rank chunk 2048 = topk); chunk32768 packs them into the + // sparse call, exposing the bug. Control + // flow is UNCHANGED (uniform full-topk loads, data-independent; + // only the validity bit differs -> no-hang properties preserved). + const bool t_is_pad = (t < 0); + t = t_is_pad ? (pad_base + offs) : t; + is_cur_token_valid = !t_is_pad; + } else { + is_cur_token_valid = (t >= 0 && t < params.s_kv) && (offs < topk_length); } token_indices[buf_idx][local_row] = (int64_t)t * (int64_t)params.stride_kv_s_kv; is_token_valid[buf_idx][local_row] = is_cur_token_valid; @@ -766,23 +858,28 @@ struct SparseMlaQ8Kv8PrefillKernel { SmemTransposeV smem_transpose_v; using SmemLayoutTransposeV_t = typename SmemTransposeV::SmemLayoutTransposeV; using SmemLayoutTransposeVt_t = typename SmemTransposeV::SmemLayoutTransposeVt; + // Half (256x64) Vt dst layout for the split vt_loc/vt_rem half-buffers. + // Source stays the FULL [64,512] K layout (tile index j handles the swizzle); only the DST + // is a half-buffer, tiles remapped tile_start..end -> 0..3. transpose_pair is tile-agnostic + // (operates on 64x64 tiles) so smem_transpose_v is reused. + using SmemTransposeV_half_t = SmemTransposeFp8_64x64; + using SmemLayoutTransposeVt_half_t = typename SmemTransposeV_half_t::SmemLayoutTransposeVt; // Use the FA3-style STSM thread layout for the fp8 V transpose. - // but same composition-based framework as before. - auto transpose_v_half = [&](int smem_k_buf, int vt_buf, int tile_start, int tile_end) { + auto transpose_v_to_half = [&](int smem_k_buf, fp8_t* vt_dst, int tile_start, int tile_end) { Tensor sV_src = as_position_independent_swizzle_tensor( make_tensor(make_smem_ptr(plan.k[smem_k_buf].data()), SmemLayoutTransposeV_t{})); - Tensor sVt_dst = as_position_independent_swizzle_tensor( - make_tensor(make_smem_ptr(plan.vt[vt_buf].data()), SmemLayoutTransposeVt_t{})); + Tensor sVt_dst = + as_position_independent_swizzle_tensor(make_tensor(make_smem_ptr(vt_dst), SmemLayoutTransposeVt_half_t{})); static_assert((D_V / 64 / 2) % 2 == 0, "half tile count must be even for pair transpose"); CUTE_UNROLL for (int j = tile_start; j < tile_end; j += 2) { smem_transpose_v.transpose_pair( flatten(sV_src(_, 0, j)), - flatten(sVt_dst(_, 0, j)), + flatten(sVt_dst(_, 0, j - tile_start)), flatten(sV_src(_, 0, j + 1)), - flatten(sVt_dst(_, 0, j + 1))); + flatten(sVt_dst(_, 0, j + 1 - tile_start))); } asm volatile("" ::: "memory"); }; @@ -797,7 +894,6 @@ struct SparseMlaQ8Kv8PrefillKernel { CUTE_NO_UNROLL for (int block_idx = 0; block_idx < num_topk_blocks; block_idx += 2) { // Indices are already loaded by the prologue or the previous iteration's prefetch. - plan.bar_k0_free.wait(cur_bar_wait_phase_prod); plan.bar_k1_free.wait(cur_bar_wait_phase_prod); @@ -810,8 +906,9 @@ struct SparseMlaQ8Kv8PrefillKernel { for (int buf_idx = 0; buf_idx < 2; ++buf_idx) CUTE_UNROLL for (int local_row = 0; local_row < NUM_ROWS_PER_GROUP; ++local_row) - plan.is_kv_valid[buf_idx][local_row * NUM_GROUPS + group_idx] = is_token_valid[buf_idx][local_row]; - plan.bar_is_kv_valid_ready.arrive(); + plan.is_kv_valid[buf_idx][(block_idx >> 1) & 1][local_row * NUM_GROUPS + group_idx] = + is_token_valid[buf_idx][local_row]; // parity-buffered + plan.bar_is_kv_valid_ready[(block_idx >> 1) & 1].arrive(); // index by iter parity } copy_tiles(0, 0, 0, 4); @@ -830,7 +927,9 @@ struct SparseMlaQ8Kv8PrefillKernel { commit_to_mbar(plan.bar_k1_ready[0]); asm volatile("cp.async.commit_group;\n" ::); - // Wait for K[0]-left (group-0) + // Wait for K[0]-left. NOTE: keep cp.async.wait_group at 1, not 0 - + // wait_group 0 re-times the producer into a latent cross-iteration + // race; the consumer must overlap the in-flight K[1] load here. asm volatile("cp.async.wait_group 1;\n" ::); // fence.proxy.async: make cp.async data visible through generic proxy // (required for LDSM reads in V transpose; cp.async uses async proxy) @@ -847,16 +946,19 @@ struct SparseMlaQ8Kv8PrefillKernel { load_token_indices(block_idx + 2); } - transpose_v_half(0, 0, 0, 4); - NamedBarrier::arrive(256, vt0_left_ready); + transpose_v_to_half(0, plan.vt_loc[0].data(), 0, 4); // block0-left -> WG0 local + // The arrive is placed below the wait_group-0 + fence point: the + // fence_view_async_shared there also orders these STSM (generic proxy) + // writes for the consumer's WGMMA (async proxy) reads. Without the + // fence the STSM->WGMMA proxy crossing is unordered and corrupts values. // Transpose V[1] left before V[0] right to match the consumer handoff order. // WG0 is on the critical path (feeds WG1 via sM/wg0_bunch). // WG0 waits for vt1_for_wg0 (V[1]-LEFT) for PV-remote. // Moving V[1]-LEFT earlier (2nd instead of 4th) reduces WG0 - // critical-path stall by ~768 cycles per iteration. - // - // v52 CRASH FIX: K[1]-left tiles 0-3 are in cp.async group-1, + // critical-path stall. + + // NOTE: K[1]-left tiles 0-3 are in cp.async group-1, // NOT group-0. wait_group 1 only waits for group-0. Under high // CTA counts (512+), memory bandwidth saturation delays group-1 // completion past the V[0]-LEFT transpose timing margin, causing @@ -870,20 +972,25 @@ struct SparseMlaQ8Kv8PrefillKernel { // generic proxy for LDSM reads in V transpose fence_view_async_shared(); asm volatile("bar.sync 7, 128;\n" ::: "memory"); + NamedBarrier::arrive(256, vt0_left_ready); // covered by the fence above // V[1]-LEFT: tiles 0-3 from K[1] -- WG0 needs this for PV-remote if (block_idx > 0) { plan.bar_vt_free[1].wait(cur_bar_wait_phase_prod); } - transpose_v_half(1, 1, 0, 4); + transpose_v_to_half(1, plan.vt_rem[0].data(), 0, 4); // block1-left -> WG0 remote + // The fence is cheap here: cp.async queue already drained by + // wait_group 0 above; orders V[1]L STSM writes before the arrive. + fence_view_async_shared(); NamedBarrier::arrive(256, vt1_for_wg0); // V[0]-RIGHT: tiles 4-7 from K[0] - transpose_v_half(0, 0, 4, 8); - NamedBarrier::arrive(256, vt0_right_ready); - + transpose_v_to_half(0, plan.vt_rem[1].data(), 4, 8); // block0-right -> WG1 remote // V[1]-RIGHT: tiles 4-7 from K[1] - transpose_v_half(1, 1, 4, 8); + transpose_v_to_half(1, plan.vt_loc[1].data(), 4, 8); // block1-right -> WG1 local + // One fence covers both right-half transposes, then arrive both. + fence_view_async_shared(); + NamedBarrier::arrive(256, vt0_right_ready); NamedBarrier::arrive(256, vt1_for_wg1); asm volatile("bar.sync 7, 128;\n" ::: "memory"); diff --git a/python/sglang/kernels/ops/attention/dsa/dequant_k_cache.py b/python/sglang/kernels/ops/attention/dsa/dequant_k_cache.py index 5ffca4a39..c437441d7 100644 --- a/python/sglang/kernels/ops/attention/dsa/dequant_k_cache.py +++ b/python/sglang/kernels/ops/attention/dsa/dequant_k_cache.py @@ -285,5 +285,137 @@ def _dequantize_k_cache_paged_kernel( tl.store(dst_ptr, data, mask=mask) +def gather_dequant_requant_fp8_paged( + quant_k_cache: torch.Tensor, + page_table_1_flattened: torch.Tensor, + group_size: int = 128, + extra_rows: int = 0, +) -> torch.Tensor: + """Gather paged fp8 KV tokens and re-pack into flat [576] fp8 layout. + + The paged KV cache stores 656 bytes per token: + [512 nope_fp8 | 16 scales_f32 (4 groups) | 128 rope_bf16_bytes] + This kernel gathers the requested tokens, de-quantises nope with the + per-group scales, and re-quantises to per-tensor fp8 (scale=1.0). + Rope is cast bf16->fp8. The whole operation is fused into a single + Triton kernel to avoid allocating an intermediate bf16 buffer. + + Args: + quant_k_cache: [total_num_tokens, 1, 656] fp8_e4m3fn + page_table_1_flattened: [num_tokens] int32 + group_size: per-group dequant tile size (default 128) + extra_rows: number of zero-filled landing-pad rows to append at + the end of the output (used by the SM90 sparse MLA Q8KV8 + kernel which over-reads past end-of-buffer for masked + indices) + Returns: + output: [num_tokens + extra_rows, 1, 576] fp8_e4m3fn + """ + dim_quant = quant_k_cache.shape[-1] + assert dim_quant == 656 + quant_k_cache = quant_k_cache.view((-1, dim_quant)) + + num_tokens = page_table_1_flattened.shape[0] + assert quant_k_cache.dtype == torch.float8_e4m3fn + dim_nope = 512 + dim_rope = 64 + num_tiles = dim_nope // group_size # 4 + out_dim = dim_nope + dim_rope # 576 + assert num_tiles * group_size == dim_nope + + total_rows = num_tokens + extra_rows + # Allocate a fresh zero-filled buffer. The extra landing-pad rows at + # the tail must read as zeros (the kernel may over-read past + # num_tokens for masked indices). A future optimization could cache + # this buffer but baseline allocates fresh. + output = torch.zeros( + (total_rows, 1, out_dim), + dtype=torch.float8_e4m3fn, + device=quant_k_cache.device, + ) + + num_blocks_per_token = triton.cdiv(dim_nope + dim_rope, group_size) # 5 + assert num_blocks_per_token == 5 + + input_nope_q = quant_k_cache[:, :dim_nope] + input_nope_s = quant_k_cache[:, dim_nope : dim_nope + num_tiles * 4].view( + torch.float32 + ) + input_rope = quant_k_cache[:, dim_nope + num_tiles * 4 :].view(torch.bfloat16) + + _gather_dequant_requant_fp8_paged_kernel[(num_tokens, num_blocks_per_token)]( + output, + input_nope_q, + input_nope_s, + input_rope, + page_table_1_flattened, + output.stride(0), + input_nope_q.stride(0), + input_nope_s.stride(0), + input_rope.stride(0), + NUM_NOPE_BLOCKS=num_tiles, + GROUP_SIZE=group_size, + DIM_NOPE=dim_nope, + DIM_ROPE=dim_rope, + ) + + return output + + +@triton.jit +def _gather_dequant_requant_fp8_paged_kernel( + output_ptr, + input_nope_q_ptr, + input_nope_s_ptr, + input_rope_ptr, + page_table_1_ptr, + output_stride_0: int, + input_nope_q_stride_0: int, + input_nope_s_stride_0: int, + input_rope_stride_0: int, + NUM_NOPE_BLOCKS: tl.constexpr, + GROUP_SIZE: tl.constexpr, + DIM_NOPE: tl.constexpr, + DIM_ROPE: tl.constexpr, +): + """Fused gather + dequant(per-group) + requant(per-tensor) -> fp8.""" + token_id = tl.program_id(0) + token_id_paged = tl.load(page_table_1_ptr + token_id).to(tl.int32) + raw_block_id = tl.program_id(1) + + if raw_block_id < NUM_NOPE_BLOCKS: + # nope: read fp8, mul group scale -> f32, cast to fp8_e4m3fn + effective_block_id = raw_block_id + offs_q = effective_block_id * GROUP_SIZE + tl.arange(0, GROUP_SIZE) + mask = offs_q < DIM_NOPE + + ptr_q = input_nope_q_ptr + token_id_paged * input_nope_q_stride_0 + offs_q + ptr_s = ( + input_nope_s_ptr + + token_id_paged * input_nope_s_stride_0 + + effective_block_id + ) + + y_q = tl.load(ptr_q, mask=mask, other=0.0).to(tl.float32) + y_s = tl.load(ptr_s) + + # dequant -> f32 -> requant to fp8 + y = (y_q * y_s).to(tl.float8e4nv) + + dst_ptr = output_ptr + token_id * output_stride_0 + offs_q + tl.store(dst_ptr, y, mask=mask) + else: + # rope: read bf16, cast to fp8 + effective_block_id = raw_block_id - NUM_NOPE_BLOCKS + offs = effective_block_id * GROUP_SIZE + tl.arange(0, GROUP_SIZE) + mask = offs < DIM_ROPE + + src_ptr = input_rope_ptr + token_id_paged * input_rope_stride_0 + offs + dst_ptr = output_ptr + token_id * output_stride_0 + DIM_NOPE + offs + + data = tl.load(src_ptr, mask=mask).to(tl.float8e4nv) + tl.store(dst_ptr, data, mask=mask) + + if __name__ == "__main__": raise Exception("UT is in quant_k_cache.py") diff --git a/python/sglang/kernels/ops/attention/utils.py b/python/sglang/kernels/ops/attention/utils.py index 6f479c953..74b130803 100644 --- a/python/sglang/kernels/ops/attention/utils.py +++ b/python/sglang/kernels/ops/attention/utils.py @@ -21,6 +21,12 @@ from sglang.kernels.ops.kvcache.cache_ops import ( from sglang.kernels.ops.kvcache.cache_ops import ( concat_and_cast_mha_k_triton as concat_and_cast_mha_k_triton, ) +from sglang.kernels.ops.kvcache.cache_ops import ( + concat_and_cast_q_fp8_pad as concat_and_cast_q_fp8_pad, +) +from sglang.kernels.ops.kvcache.cache_ops import ( + concat_and_cast_q_fp8_pad_kernel as concat_and_cast_q_fp8_pad_kernel, +) from sglang.kernels.ops.kvcache.cache_ops import ( launch_reshape_and_cache_flash as launch_reshape_and_cache_flash, ) diff --git a/python/sglang/kernels/ops/kvcache/cache_ops.py b/python/sglang/kernels/ops/kvcache/cache_ops.py index 7e1da0b53..4393bfb5a 100644 --- a/python/sglang/kernels/ops/kvcache/cache_ops.py +++ b/python/sglang/kernels/ops/kvcache/cache_ops.py @@ -264,3 +264,70 @@ def launch_reshape_and_cache_flash( HAS_SWA=(swa_slot_mapping is not None), USE_SCALE=(k_scale is not None), ) + + +@triton.jit +def concat_and_cast_q_fp8_pad_kernel( + qpad_ptr, # [num_tokens, pad_heads, NOPE+ROPE] fp8 (dst; only [:, :H, :] written) + q_nope_ptr, # [num_tokens, H, NOPE] bf16 + q_rope_ptr, # [num_tokens, H, ROPE] bf16 + qpad_s0, + qpad_s1, + nope_s0, + nope_s1, + rope_s0, + rope_s1, + H: tl.constexpr, + NOPE: tl.constexpr, + ROPE: tl.constexpr, +): + # One program per token: write the H active heads of the padded fp8 q buffer, + # fusing the bf16->fp8 cast (on store) with the nope/rope concat. Bit-exact vs the + # two strided copy_() it replaces; ~3.7x faster because copy_ into the + # 64-head-padded buffer is strided (~4.5x off memory-bound). Strides are passed in, + # so q_nope/q_rope may be views of a [T, H, NOPE+ROPE] q (head-stride != last-dim). + pid = tl.program_id(0) + hr = tl.arange(0, H) + qpad_head = qpad_ptr + pid * qpad_s0 + hr[:, None] * qpad_s1 + no = tl.arange(0, NOPE) + src_n = tl.load(q_nope_ptr + pid * nope_s0 + hr[:, None] * nope_s1 + no[None, :]) + tl.store(qpad_head + no[None, :], src_n) + ro = tl.arange(0, ROPE) + src_r = tl.load(q_rope_ptr + pid * rope_s0 + hr[:, None] * rope_s1 + ro[None, :]) + tl.store(qpad_head + NOPE + ro[None, :], src_r) + + +def concat_and_cast_q_fp8_pad(q_fp8_pad, q_nope, q_rope, num_heads): + """fused bf16->fp8 concat-cast of q_nope/q_rope into the active + [:, :num_heads, :] slice of the padded fp8 q buffer. Bit-exact replacement for the + two strided converting copy_() in the Q8KV8 prefill q-prep, ~3.7x faster. Requires + num_heads / nope_dim / rope_dim to be powers of two (always true for DeepSeek: 128 + heads / any TP, 512 nope, 64 rope).""" + num_tokens = q_nope.shape[0] + nope_dim = q_nope.shape[-1] + rope_dim = q_rope.shape[-1] + concat_and_cast_q_fp8_pad_kernel[(num_tokens,)]( + q_fp8_pad, + q_nope, + q_rope, + q_fp8_pad.stride(0), + q_fp8_pad.stride(1), + q_nope.stride(0), + q_nope.stride(1), + q_rope.stride(0), + q_rope.stride(1), + H=num_heads, + NOPE=nope_dim, + ROPE=rope_dim, + ) + + +# --------------------------------------------------------------------------- +# Decode Context Parallel (DCP) helpers. +# +# Not part of upstream main (PR #26000 centralized the other Triton utility +# kernels into triton_ops/*). These three live here because they are DCP-only: +# - create_triton_kv_indices_for_dcp_triton: per-rank local KV indices +# - get_dcp_lens: per-rank visible KV length +# - cp_lse_ag_out_rs: merge DCP partial attention via natural-log LSE +# --------------------------------------------------------------------------- diff --git a/python/sglang/srt/layers/attention/dsa_backend.py b/python/sglang/srt/layers/attention/dsa_backend.py index 35a5487fe..2606b6081 100644 --- a/python/sglang/srt/layers/attention/dsa_backend.py +++ b/python/sglang/srt/layers/attention/dsa_backend.py @@ -18,7 +18,10 @@ from sglang.srt.configs.model_config import get_dsa_index_topk, is_deepseek_dsa from sglang.srt.runtime_context import get_parallel logger = logging.getLogger(__name__) -from sglang.kernels.ops.attention.dsa.dequant_k_cache import dequantize_k_cache_paged +from sglang.kernels.ops.attention.dsa.dequant_k_cache import ( + dequantize_k_cache_paged, + gather_dequant_requant_fp8_paged, +) from sglang.kernels.ops.attention.dsa.quant_k_cache import quantize_k_cache from sglang.kernels.ops.attention.dsa.transform_index import ( transform_index_page_table_decode, @@ -29,6 +32,7 @@ from sglang.kernels.ops.attention.utils import ( mla_quantize_and_rope_for_fp8, seqlens_expand_triton, ) +from sglang.kernels.ops.kvcache.cache_ops import concat_and_cast_q_fp8_pad from sglang.srt.environ import envs from sglang.srt.layers.attention.base_attn_backend import AttentionBackend from sglang.srt.layers.attention.dsa.dsa_backend_mtp_precompute import ( @@ -323,7 +327,7 @@ class DSAIndexerMetadata(BaseIndexerMetadata): _DSA_IMPL_T: TypeAlias = Literal[ - "flashmla_sparse", "flashmla_kv", "fa3", "tilelang", "trtllm" + "flashmla_sparse", "flashmla_sparse_q8", "flashmla_kv", "fa3", "tilelang", "trtllm" ] @@ -448,6 +452,40 @@ class DeepseekSparseAttnBackend( self.device_sm_major = self.device_capability[0] self.kv_cache_dtype = model_runner.kv_cache_dtype + # `flashmla_sparse_q8` = the native FP8 SM90 sparse-prefill kernel. It always + # runs FP8 (requires fp8_e4m3 KV) and is SM90-only, so validate both at + # construction: an unsupported config must fail at launch rather than + # mid-forward. `flashmla_sparse` remains the bf16 path with no such + # requirement. + if self.dsa_prefill_impl == "flashmla_sparse_q8": + if self.kv_cache_dtype != torch.float8_e4m3fn: + raise ValueError( + "--dsa-prefill-backend flashmla_sparse_q8 is native FP8 and requires " + f"--kv-cache-dtype fp8_e4m3 (got kv_cache_dtype={self.kv_cache_dtype}); " + "use --dsa-prefill-backend flashmla_sparse for the bf16 path." + ) + if self.device_sm_major != 9: + raise ValueError( + "--dsa-prefill-backend flashmla_sparse_q8 is SM90-only; got compute " + f"capability sm_{self.device_sm_major}x." + ) + + # `flashmla_sparse_q8` is prefill-only (FP8 decode goes through + # `flashmla_kv`); reject it as a decode backend, since argparse accepts it + # via the shared DSA_CHOICES list. + if self.dsa_decode_impl == "flashmla_sparse_q8": + raise ValueError( + "--dsa-decode-backend flashmla_sparse_q8 is not supported: " + "flashmla_sparse_q8 is a prefill-only backend. For FP8, use " + "--dsa-prefill-backend flashmla_sparse_q8 together with " + "--dsa-decode-backend flashmla_kv." + ) + + # Q8KV8 per-call device-tensor caches, populated lazily on the first + # Q8KV8 dispatch (no-ops for other backends). + self._q8kv8_identity_scale: Optional[torch.Tensor] = None + self._q8kv8_qpad_buf: Optional[torch.Tensor] = None + # Allocate global workspace buffer for TRT-LLM kernels (ragged attention on SM100/B200, or trtllm decode) if self.device_sm_major >= 10 or self.dsa_decode_impl == "trtllm": self.workspace_buffer = get_buffer( @@ -1981,12 +2019,46 @@ class DeepseekSparseAttnBackend( sm_scale=layer.scaling, v_head_dim=layer.v_head_dim, ) - elif dsa_impl == "flashmla_sparse": - if q_rope is not None: - q_all = concat_mla_absorb_q_general(q_nope, q_rope) - + elif dsa_impl in ("flashmla_sparse", "flashmla_sparse_q8"): if topk_transform_method == TopkTransformMethod.RAGGED: - if any(forward_batch.extend_prefix_lens_cpu): + _has_prefix = any(forward_batch.extend_prefix_lens_cpu) + page_table_1 = topk_indices + + # `flashmla_sparse_q8` = native FP8 sparse prefill (constructor + # guarantees fp8_e4m3 KV + SM90). The helper consumes q_nope/q_rope + # directly (fusing the concat with the bf16->fp8 cast), so no bf16 + # q_all is materialized on this path. The prefix path hands over the + # paged fp8 KV as-is; the non-prefix path passes the gathered bf16 KV. + if dsa_impl == "flashmla_sparse_q8": + if _has_prefix: + page_table_1_flattened = ( + self.forward_metadata.page_table_1_flattened + ) + assert page_table_1_flattened is not None + return self._forward_flashmla_sparse_q8kv8( + q_nope=q_nope, + q_rope=q_rope, + kv_bf16=None, + paged_kv_cache=kv_cache, + page_table_1_flattened=page_table_1_flattened, + page_table_1=page_table_1, + sm_scale=layer.scaling, + v_head_dim=layer.v_head_dim, + ) + kv_cache = _cat([k, k_rope], dim=-1) + return self._forward_flashmla_sparse_q8kv8( + q_nope=q_nope, + q_rope=q_rope, + kv_bf16=kv_cache, + paged_kv_cache=None, + page_table_1_flattened=None, + page_table_1=page_table_1, + sm_scale=layer.scaling, + v_head_dim=layer.v_head_dim, + ) + + # bf16 path (dsa_impl == "flashmla_sparse"). + if _has_prefix: page_table_1_flattened = ( self.forward_metadata.page_table_1_flattened ) @@ -1996,8 +2068,9 @@ class DeepseekSparseAttnBackend( ) else: kv_cache = _cat([k, k_rope], dim=-1) - page_table_1 = topk_indices + if q_rope is not None: + q_all = concat_mla_absorb_q_general(q_nope, q_rope) return self._forward_flashmla_sparse( q_all=q_all, kv_cache=kv_cache, @@ -2292,6 +2365,131 @@ class DeepseekSparseAttnBackend( return o + def _forward_flashmla_sparse_q8kv8( + self, + q_nope: torch.Tensor, + q_rope: torch.Tensor, + kv_bf16: Optional[torch.Tensor], + v_head_dim: int, + page_table_1: torch.Tensor, + sm_scale: float, + paged_kv_cache: Optional[torch.Tensor] = None, + page_table_1_flattened: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Native FP8 (q8 x kv8) sparse-prefill attention (SM90 JIT kernel). + + Same contract as ``_forward_flashmla_sparse`` but executed through the + FP8 ``sparse_mla_q8kv8_prefill_fwd`` kernel. Identity per-tensor + scales (scalar 1.0) are used: a raw bf16->fp8 cast of q/kv is accurate + on real DeepSeek-V3 magnitudes, so no dynamic rescaling is applied. + The kernel runs via its fixed full-topk entry (``attn_sink`` / + ``topk_length`` left None), keeping control flow identical across DP + ranks; -1 topk sentinels are clamped to distinct zero pad rows inside + the kernel. + + Two KV paths: + * non-prefix extend: ``kv_bf16`` (the gathered bf16 KV) is cast into + a zero-padded fp8 buffer. + * prefix extend: ``paged_kv_cache`` (fp8, 656 B/token: nope_fp8 + + per-group scales + rope_bf16) is gathered, dequantized per group, + and requantized to per-tensor fp8 in one fused Triton kernel + (``gather_dequant_requant_fp8_paged``) — no intermediate bf16 + materialization. + """ + from sglang.jit_kernel.sparse_mla_q8kv8_prefill_sm90 import ( + sparse_mla_q8kv8_prefill_fwd, + ) + + num_tokens, num_heads, d_nope = q_nope.shape + head_dim = d_nope + q_rope.shape[-1] + dev = q_nope.device + + # The SM90 kernel requires num_heads % 64 == 0; smaller head counts + # (high-TP splits) are zero-padded up to 64. + required_padding = 64 + need_padding = num_heads % required_padding != 0 + + # Build the fp8 q. concat_and_cast_q_fp8_pad fuses the nope/rope + # concat with the bf16->fp8 cast in one Triton kernel (bit-exact vs + # concat + .to(fp8)); it requires power-of-two head/dim counts (a + # tl.arange constraint), so non-power-of-two head counts fall back to + # the generic concat + cast. + if need_padding: + if required_padding % num_heads != 0: + raise ValueError( + f"num_heads={num_heads} cannot be padded to {required_padding}; " + "this TP size is incompatible with flashmla_sparse_q8." + ) + # Cached zero-padded fp8 q buffer: the pad rows [num_heads:64] are + # zero on first alloc and only ever read by the kernel; the active + # slice is overwritten each forward. Eager-mode DSA runs layers + # sequentially on one stream, so single-buffer reuse is safe. + # Grown on demand. + buf = self._q8kv8_qpad_buf + if buf is None or buf.shape[0] < num_tokens: + buf = torch.zeros( + (num_tokens, required_padding, head_dim), + dtype=torch.float8_e4m3fn, + device=dev, + ) + self._q8kv8_qpad_buf = buf + q_fp8 = buf[:num_tokens] + # head counts that divide 64 are powers of two, so the fused + # concat-cast is always applicable here. + concat_and_cast_q_fp8_pad(q_fp8, q_nope, q_rope, num_heads) + elif (num_heads & (num_heads - 1)) == 0: + q_fp8 = q_nope.new_empty( + (num_tokens, num_heads, head_dim), dtype=torch.float8_e4m3fn + ) + concat_and_cast_q_fp8_pad(q_fp8, q_nope, q_rope, num_heads) + else: + # Generic fallback for non-power-of-two head counts. + q_fp8 = concat_mla_absorb_q_general(q_nope, q_rope).to(torch.float8_e4m3fn) + + # Identity per-tensor scale, cached: creating it per call is a + # host->device copy that synchronizes the stream. + identity_scale = self._q8kv8_identity_scale + if identity_scale is None: + identity_scale = torch.tensor([1.0], dtype=torch.float32, device=dev) + self._q8kv8_identity_scale = identity_scale + + # KV: append `topk` trailing zero rows so the kernel's -1-sentinel + # clamp can map every padded topk slot to a DISTINCT zero row. + # Mapping many slots onto one shared row would serialize the kernel's + # KV gather; distinct zero rows are value-identical (zero KV + # contributes nothing to the softmax-weighted sum) at full speed. + topk = page_table_1.shape[-1] + if paged_kv_cache is not None: + kv_padded = gather_dequant_requant_fp8_paged( + paged_kv_cache, + page_table_1_flattened, + extra_rows=topk, + ).view(-1, 1, head_dim) + else: + kv_padded = kv_bf16.new_zeros( + (kv_bf16.shape[0] + topk, *kv_bf16.shape[1:]), + dtype=torch.float8_e4m3fn, + ) + kv_padded[: kv_bf16.shape[0]].copy_(kv_bf16) + kv_padded = kv_padded.view(-1, 1, head_dim) + + o, _, _ = sparse_mla_q8kv8_prefill_fwd( + q=q_fp8, + kv=kv_padded, + indices=page_table_1.unsqueeze(1), + sm_scale=sm_scale, + q_scale=identity_scale, + kv_scale=identity_scale, + d_v=v_head_dim, + attn_sink=None, + topk_length=None, + ) + + # Trim the output back to the original head count if we padded. + if need_padding: + o = o[:, :num_heads, :] + return o + def _forward_flashmla_kv( self, q_all: torch.Tensor, @@ -2847,7 +3045,11 @@ class DeepseekSparseAttnBackend( if ( # disable for MTP self.dsa_kv_cache_store_fp8 - and self.dsa_prefill_impl == "flashmla_sparse" + # flashmla_sparse_q8 shares flashmla_sparse's RAGGED prefill routing — the q8 + # dispatch lives inside the RAGGED branch of forward_extend; without this the + # transform is PAGED, the q8 path is skipped, and the bf16 kernel crashes on + # fp8 KV ("kv must have dtype kBFloat16"). + and self.dsa_prefill_impl in ("flashmla_sparse", "flashmla_sparse_q8") and forward_mode == ForwardMode.EXTEND ): topk_transform_method = TopkTransformMethod.RAGGED diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index a260605de..637541265 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -323,6 +323,7 @@ DEFAULT_LORA_EVICTION_POLICY = "lru" DSA_CHOICES = [ "flashmla_sparse", + "flashmla_sparse_q8", "flashmla_kv", "flashmla_auto", "fa3", diff --git a/test/registered/jit/test_sparse_mla_q8kv8_prefill_sm90.py b/test/registered/jit/test_sparse_mla_q8kv8_prefill_sm90.py index 13c4ec36e..d86ee4387 100644 --- a/test/registered/jit/test_sparse_mla_q8kv8_prefill_sm90.py +++ b/test/registered/jit/test_sparse_mla_q8kv8_prefill_sm90.py @@ -9,7 +9,7 @@ import torch from sglang.srt.utils import is_sm90_supported from sglang.test.ci.ci_register import register_cuda_ci -register_cuda_ci(est_time=120, stage="base-b-kernel-unit", runner_config="1-gpu-large") +register_cuda_ci(est_time=240, stage="base-b-kernel-unit", runner_config="1-gpu-large") DTYPE_FP8 = torch.float8_e4m3fn @@ -417,5 +417,190 @@ def test_sparse_mla_q8kv8_prefill_rejects_bad_buffers(): _call(d_v=256) +# --------------------------------------------------------------------------- +# End-to-end-discovered corner-case gates. +# +# Everything ABOVE (matches_reference / corner_cases / precision / +# no_alias_between_calls / caller_owned_buffers / rejects_bad_buffers) is the +# original unit suite: small, all-valid (or topk_length-bounded) shapes checked +# against a reference that REPRODUCES the kernel's own clamp semantics. That +# suite is blind to three bug classes that only surface under real +# DeepSeek-V3.2 serving; the gates below reproduce them as standalone kernel +# tests: +# +# 1. masked -1-sentinel SEMANTICS on few-valid rows (ctx << topk): pad slots +# must contribute NOTHING to the softmax denominator. This needs a MASKED +# (-inf) fp32 reference -- a reference that mimics the kernel's own clamp +# is blind to the bug. +# 2. s_q ENVELOPE to 6144: first-band NaNs from an is_kv_valid data race that +# only appears past s_q=2048 (never exercised above). +# 3. LARGE S_KV (65536) / large index values: gathered multi-request buffers +# reach tens of thousands of rows in e2e; the suite above used s_kv<=1024. +# +# These use h=128 (the real DeepSeek head count) and large s_q/s_kv, so they are +# heavier than the suite above; same SM90 skipif. They also DOCUMENT that the +# kernel is run-to-run nondeterministic at the fp8 noise floor, so they compare +# against an fp32 reference (never bitwise / self-consistency). +# --------------------------------------------------------------------------- + +_D_FULL = 576 # nope(512) + rope(64): the real DeepSeek MLA absorbed q/kv width + + +def _ref_masked_blocked(q, kv, indices, sm_scale, d_v, row_start, row_end): + """fp32 reference with PROPER -1 masking (pad slots -> -inf), computed over a + block of query rows [row_start, row_end) to bound peak memory. Unlike + ``_torch_sparse_attention_ref`` (which bounds validity via topk_length and so + reproduces the kernel's clamp), this masks every -1 index out of the softmax, + making it sensitive to the denominator-pollution bug.""" + q_f = q.float() + kv_f = kv.float()[:, 0, :] + idx_block = indices[row_start:row_end, 0, :].long() + gathered = kv_f[idx_block.clamp(min=0)] + scores = torch.einsum("qhd,qkd->qhk", q_f[row_start:row_end], gathered) * sm_scale + scores = scores.masked_fill((idx_block < 0)[:, None, :], float("-inf")) + probs = torch.softmax(scores, dim=-1) + return torch.einsum("qhk,qkd->qhd", probs, gathered[:, :, :d_v]) + + +@pytest.mark.skipif( + not _sm90_available(), reason="Q8KV8 sparse prefill requires SM90 CUDA" +) +@pytest.mark.parametrize("s_q", [2048, 4096]) +def test_sparse_mla_q8kv8_prefill_masked_sentinels(s_q: int): + """NEW gate (bug class 1): causal -1 structure (row i has min(1+i, topk) + valid slots, the rest -1). The kernel must mask pad slots out of the softmax + denominator. Checked against a MASKED (-inf) fp32 reference; a reference + that reproduced the kernel's clamp would be blind to this. + Gate: per-band cos > 0.97 AND magnitude ratio > 0.9 + (the denominator-pollution bug crushes magnitude 50-2000x, unmistakable even + under fp8 noise).""" + from sglang.jit_kernel.sparse_mla_q8kv8_prefill_sm90 import ( + sparse_mla_q8kv8_prefill_fwd, + ) + + h, topk, band, n = 128, 2048, 512, 4608 + s_kv = n + topk + g = torch.Generator(device="cuda").manual_seed(11) + q = torch.randn((s_q, h, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + kv = torch.zeros((s_kv, H_KV, _D_FULL), dtype=DTYPE_FP8, device="cuda") + kv[:n] = torch.randn((n, H_KV, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + idx = torch.full((s_q, H_KV, topk), -1, dtype=torch.int32, device="cuda") + slot = torch.arange(topk, device="cuda") + valid = torch.clamp(1 + torch.arange(s_q, device="cuda"), max=topk) + rnd = torch.randint( + 0, n, (s_q, topk), dtype=torch.int32, device="cuda", generator=g + ) + idx[:, 0, :] = torch.where( + slot[None, :] < valid[:, None], rnd, torch.full_like(rnd, -1) + ) + one = torch.ones(1, dtype=torch.float32, device="cuda") + sm_scale = 1.0 / math.sqrt(_D_FULL) + + out, _, _ = sparse_mla_q8kv8_prefill_fwd( + q=q, kv=kv, indices=idx, sm_scale=sm_scale, q_scale=one, kv_scale=one, d_v=D_V + ) + torch.cuda.synchronize() + + worst_cos, worst_mag = 1.0, 1.0 + for s in range(0, s_q, band): + e = min(s + band, s_q) + ref = _ref_masked_blocked(q, kv, idx, sm_scale, D_V, s, e) + ob = out[s:e].float() + cos = torch.nn.functional.cosine_similarity( + ob.reshape(-1), ref.reshape(-1), dim=0 + ).item() + mag = (ob.norm() / ref.norm().clamp(min=1e-9)).item() + worst_cos = min(worst_cos, cos) + if mag < 1.0: + worst_mag = min(worst_mag, mag) + del ref, ob + torch.cuda.empty_cache() + + print( + f"\n masked-sentinels s_q={s_q}: worst cos={worst_cos:.4f} " + f"worst |out|/|ref|={worst_mag:.3f}" + ) + assert worst_cos > 0.97, f"cos {worst_cos:.4f} <= 0.97 (denominator pollution?)" + assert worst_mag > 0.9, f"mag {worst_mag:.3f} <= 0.9 (denominator pollution?)" + + +@pytest.mark.skipif( + not _sm90_available(), reason="Q8KV8 sparse prefill requires SM90 CUDA" +) +@pytest.mark.parametrize("s_q", [2048, 4096, 6144]) +def test_sparse_mla_q8kv8_prefill_sq_envelope(s_q: int): + """NEW gate (bug class 2): all-valid correctness across the s_q envelope. + s_q=6144 previously produced first-band NaNs (an is_kv_valid data race that + only appears past s_q=2048).""" + from sglang.jit_kernel.sparse_mla_q8kv8_prefill_sm90 import ( + sparse_mla_q8kv8_prefill_fwd, + ) + + h, topk, s_kv, band = 128, 2048, 8192, 1024 + g = torch.Generator(device="cuda").manual_seed(7) + q = torch.randn((s_q, h, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + kv = torch.randn((s_kv, H_KV, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + idx = torch.randint( + 0, s_kv, (s_q, H_KV, topk), dtype=torch.int32, device="cuda", generator=g + ) + one = torch.ones(1, dtype=torch.float32, device="cuda") + sm_scale = 1.0 / math.sqrt(_D_FULL) + + out, _, _ = sparse_mla_q8kv8_prefill_fwd( + q=q, kv=kv, indices=idx, sm_scale=sm_scale, q_scale=one, kv_scale=one, d_v=D_V + ) + torch.cuda.synchronize() + + has_nan = torch.isnan(out.float()).any().item() + worst_cos = 1.0 + for s in range(0, s_q, band): + e = min(s + band, s_q) + ref = _ref_masked_blocked(q, kv, idx, sm_scale, D_V, s, e) + cos = torch.nn.functional.cosine_similarity( + out[s:e].float().reshape(-1), ref.reshape(-1), dim=0 + ).item() + worst_cos = min(worst_cos, cos) + del ref + torch.cuda.empty_cache() + + print(f"\n s_q-envelope s_q={s_q}: nan={has_nan} worst cos={worst_cos:.4f}") + assert not has_nan, f"NaN in output at s_q={s_q} (is_kv_valid race)" + assert worst_cos > 0.99, f"cos {worst_cos:.4f} <= 0.99" + + +@pytest.mark.skipif( + not _sm90_available(), reason="Q8KV8 sparse prefill requires SM90 CUDA" +) +def test_sparse_mla_q8kv8_prefill_large_skv(): + """NEW gate (bug class 3): large gathered buffers / large index values + (s_kv=65536, indices in [33000, 65536)). E2E multi-request gather buffers + reach tens of thousands of rows; the suite above used s_kv<=1024.""" + from sglang.jit_kernel.sparse_mla_q8kv8_prefill_sm90 import ( + sparse_mla_q8kv8_prefill_fwd, + ) + + h, topk, s_kv, s_q = 128, 2048, 65536, 2048 + g = torch.Generator(device="cuda").manual_seed(13) + q = torch.randn((s_q, h, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + kv = torch.randn((s_kv, H_KV, _D_FULL), device="cuda", generator=g).to(DTYPE_FP8) + idx = torch.randint( + 33000, s_kv, (s_q, H_KV, topk), dtype=torch.int32, device="cuda", generator=g + ) + one = torch.ones(1, dtype=torch.float32, device="cuda") + sm_scale = 1.0 / math.sqrt(_D_FULL) + + out, _, _ = sparse_mla_q8kv8_prefill_fwd( + q=q, kv=kv, indices=idx, sm_scale=sm_scale, q_scale=one, kv_scale=one, d_v=D_V + ) + torch.cuda.synchronize() + + ref = _ref_masked_blocked(q, kv, idx, sm_scale, D_V, 0, 1024) + cos = torch.nn.functional.cosine_similarity( + out[:1024].float().reshape(-1), ref.reshape(-1), dim=0 + ).item() + print(f"\n large-S_KV={s_kv}: band-0 cos={cos:.4f}") + assert cos > 0.99, f"cos {cos:.4f} <= 0.99" + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v", "-s"]))