[Perf][GLM-5.2] Blackwell decode optimizations (#48597)
Signed-off-by: Peiyuan Zhou <peiyuanzhou1994@gmail.com> Signed-off-by: zhou <zhou@zhoudeMacBook-Pro.local> Signed-off-by: Stefan Koncarevic <stefan.koncarevic@amd.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Giancarlo Delfin <32987265+TheEpicDolphin@users.noreply.github.com> Co-authored-by: Thien Tran <gau.nernst@yahoo.com.sg> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: zhou <zhou@zhoudeMacBook-Pro.local> Co-authored-by: stefankoncarevic <Stefan.Koncarevic@amd.com>
xiaozhoupy committed
Jul 24, 2026 at 12:36 UTC
dd72658e7db0c6a674f473f6f9f0f5c2ebe7e523
29 files changed
+2139
-138
CMakeLists.txt
+17
@@ -727,6 +727,23 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
727
"(requires SM90+ and CUDA >= 12.0).")
728
endif()
729
730
+ # BF16 skinny GEMM (M<=32; weight-bandwidth-bound decode shapes).
731
+ # Requires SM90+.
732
+ cuda_archs_sm90plus(BF16_SKINNY_GEMM_ARCHS "${CUDA_ARCHS}")
733
+ if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND BF16_SKINNY_GEMM_ARCHS)
734
+ set(BF16_SKINNY_GEMM_SRCS
735
+ "csrc/libtorch_stable/bf16_skinny_gemm_entry.cu"
736
+ "csrc/libtorch_stable/bf16_skinny_gemm.cu")
737
+ set_gencode_flags_for_srcs(
738
+ SRCS "${BF16_SKINNY_GEMM_SRCS}"
739
+ CUDA_ARCHS "${BF16_SKINNY_GEMM_ARCHS}")
740
+ list(APPEND VLLM_STABLE_EXT_SRC "${BF16_SKINNY_GEMM_SRCS}")
741
+ message(STATUS "Building bf16_skinny_gemm for archs: ${BF16_SKINNY_GEMM_ARCHS}")
742
+ else()
743
+ message(STATUS "Not building bf16_skinny_gemm as no compatible archs found "
744
+ "(requires SM90+ and CUDA >= 12.0).")
745
+ endif()
746
+
747
# Only build AllSpark kernels if we are building for at least some compatible archs.
748
cuda_archs_loose_intersection(ALLSPARK_ARCHS "8.0;8.6;8.7;8.9" "${CUDA_ARCHS}")
749
if (ALLSPARK_ARCHS)
csrc/libtorch_stable/bf16_skinny_gemm.cu
new
+262
@@ -0,0 +1,262 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+//
4
+// Skinny GEMM: activation(bf16) x weight(bf16)^T -> bf16, for decode-time
5
+// M <= 32 with a large reduction dim. Replaces cuBLAS splitK (GEMM +
6
+// splitKreduce) with a single block-per-output-column kernel; these shapes
7
+// are weight-bandwidth-bound, so one coalesced pass over the weight at
8
+// fp32 accumulation is optimal. Adapted from fp32_router_gemm.cu.
9
+//
10
+// First user: the DeepSeek-V32/GLM-5.2 MTP eh_proj (K=2*hidden=12288,
11
+// N=hidden/TP), whose cuBLAS splitK pick costs ~34.6us vs the ~19us
12
+// bandwidth floor per replicated read (and ~4us once column-parallel).
13
+
14
+#include <cuda_bf16.h>
15
+#include <cuda_runtime.h>
16
+
17
+// ---------------------------------------------------------------------------
18
+// Load helpers (8 x bf16 = one uint4 load, converted to fp32)
19
+// ---------------------------------------------------------------------------
20
+
21
+namespace skinny {
22
+
23
+constexpr int VPT = 8; // bf16 values per thread per load
24
+
25
+__device__ __forceinline__ void load_bf16x8(__nv_bfloat16 const* ptr,
26
+ float* dst) {
27
+ uint4 v = *reinterpret_cast<uint4 const*>(ptr);
28
+ __nv_bfloat16 const* p = reinterpret_cast<__nv_bfloat16 const*>(&v);
29
+#pragma unroll
30
+ for (int i = 0; i < VPT; i++) dst[i] = __bfloat162float(p[i]);
31
+}
32
+
33
+// Streaming variant for the weight: each row is read exactly once across the
34
+// whole grid, so bypass L2 residency (evict-first). Measured -1.4us at M=1.
35
+__device__ __forceinline__ void load_bf16x8_cs(__nv_bfloat16 const* ptr,
36
+ float* dst) {
37
+ uint4 v = __ldcs(reinterpret_cast<uint4 const*>(ptr));
38
+ __nv_bfloat16 const* p = reinterpret_cast<__nv_bfloat16 const*>(&v);
39
+#pragma unroll
40
+ for (int i = 0; i < VPT; i++) dst[i] = __bfloat162float(p[i]);
41
+}
42
+
43
+// ---------------------------------------------------------------------------
44
+// Kernel: each block computes kNPB output columns for all kNumTokens rows.
45
+// grid = kN / kNPB, block = kBlockSize threads. K is reduced VPT elements
46
+// per thread per iteration; fp32 accumulation, warp butterfly + smem
47
+// finalize, bf16 store.
48
+// ---------------------------------------------------------------------------
49
+
50
+template <int kBlockSize, int kNumTokens, int kNPB, int kPF, int kN, int kK>
51
+__global__ __launch_bounds__(kBlockSize, 1) void bf16_skinny_gemm_kernel(
52
+ __nv_bfloat16* out, __nv_bfloat16 const* mat_a, __nv_bfloat16 const* mat_b,
53
+ int64_t out_stride) {
54
+ constexpr int k_elems_per_iter = VPT * kBlockSize;
55
+ constexpr int k_iterations = kK / k_elems_per_iter;
56
+ static_assert(kK % k_elems_per_iter == 0);
57
+ constexpr int kWarpSize = 32;
58
+ constexpr int kNumWarps = kBlockSize / kWarpSize;
59
+
60
+ int const n_base = blockIdx.x * kNPB;
61
+ int const tid = threadIdx.x;
62
+ int const warpId = tid / kWarpSize;
63
+ int const laneId = tid % kWarpSize;
64
+
65
+ float acc[kNumTokens][kNPB] = {};
66
+ __shared__ float sm_reduction[kNumTokens][kNPB][kNumWarps];
67
+
68
+ // Register prefetch (kPF > 0): W does not depend on the predecessor, so
69
+ // the first kPF iterations' weight chunks are loaded raw BEFORE the
70
+ // dependency sync; with a PDL-releasing producer (fused_eh_norm fires
71
+ // gdc_launch_dependents early) these DRAM round trips overlap the norm.
72
+ // Pair-measured on B300 (norm+gemm in one graph, full 6144x12288):
73
+ // M=1 pf2 28.43us vs pf0 29.00us; deeper prefetch or M >= 2 regresses
74
+ // (register pressure), hence the per-M selection in the launcher.
75
+ uint4 w_pre[kPF > 0 ? kPF : 1][kNPB];
76
+#pragma unroll
77
+ for (int pf = 0; pf < kPF; pf++) {
78
+#pragma unroll
79
+ for (int n = 0; n < kNPB; n++) {
80
+ w_pre[pf][n] =
81
+ *reinterpret_cast<uint4 const*>(mat_b + (size_t)(n_base + n) * kK +
82
+ pf * k_elems_per_iter + tid * VPT);
83
+ }
84
+ }
85
+
86
+#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
87
+ cudaGridDependencySynchronize();
88
+#endif
89
+
90
+#pragma unroll
91
+ for (int ki = 0; ki < k_iterations; ki++) {
92
+ int const k_base = ki * k_elems_per_iter + tid * VPT;
93
+
94
+ float b_float[kNPB][VPT];
95
+ if (ki < kPF) {
96
+#pragma unroll
97
+ for (int n = 0; n < kNPB; n++) {
98
+ __nv_bfloat16 const* p =
99
+ reinterpret_cast<__nv_bfloat16 const*>(&w_pre[ki][n]);
100
+#pragma unroll
101
+ for (int v = 0; v < VPT; v++) b_float[n][v] = __bfloat162float(p[v]);
102
+ }
103
+ } else {
104
+#pragma unroll
105
+ for (int n = 0; n < kNPB; n++) {
106
+ load_bf16x8_cs(mat_b + (size_t)(n_base + n) * kK + k_base, b_float[n]);
107
+ }
108
+ }
109
+
110
+#pragma unroll
111
+ for (int m = 0; m < kNumTokens; m++) {
112
+ float a_float[VPT];
113
+ load_bf16x8(mat_a + (size_t)m * kK + k_base, a_float);
114
+#pragma unroll
115
+ for (int n = 0; n < kNPB; n++) {
116
+#pragma unroll
117
+ for (int k = 0; k < VPT; k++) {
118
+ acc[m][n] += a_float[k] * b_float[n][k];
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Warp-level butterfly reduction
125
+#pragma unroll
126
+ for (int m = 0; m < kNumTokens; m++) {
127
+#pragma unroll
128
+ for (int n = 0; n < kNPB; n++) {
129
+ float sum = acc[m][n];
130
+ sum += __shfl_xor_sync(0xffffffff, sum, 16);
131
+ sum += __shfl_xor_sync(0xffffffff, sum, 8);
132
+ sum += __shfl_xor_sync(0xffffffff, sum, 4);
133
+ sum += __shfl_xor_sync(0xffffffff, sum, 2);
134
+ sum += __shfl_xor_sync(0xffffffff, sum, 1);
135
+ if (laneId == 0) sm_reduction[m][n][warpId] = sum;
136
+ }
137
+ }
138
+
139
+ __syncthreads();
140
+
141
+ // Parallel finalize: one thread per (m, n) output.
142
+ for (int idx = tid; idx < kNumTokens * kNPB; idx += kBlockSize) {
143
+ int const m = idx / kNPB;
144
+ int const n = idx % kNPB;
145
+ float final_sum = 0.0f;
146
+#pragma unroll
147
+ for (int w = 0; w < kNumWarps; w++) final_sum += sm_reduction[m][n][w];
148
+ out[(size_t)m * out_stride + n_base + n] = __float2bfloat16(final_sum);
149
+ }
150
+
151
+ // Trigger after our stores: harmless hardening, not a guarantee — the
152
+ // trigger only permits dependent-launch scheduling and carries no memory
153
+ // visibility semantics, so a PDL consumer must still gridsync before
154
+ // reading our output. Every current consumer is a plain launch (full
155
+ // stream order); firing late just avoids an unnecessarily early launch
156
+ // window and matches fp32_router_gemm.cu.
157
+#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
158
+ cudaTriggerProgrammaticLaunchCompletion();
159
+#endif
160
+}
161
+
162
+} // namespace skinny
163
+
164
+// ---------------------------------------------------------------------------
165
+// Launcher
166
+// ---------------------------------------------------------------------------
167
+
168
+template <int kBlockSize, int kNPB, int kNumTokens, int kN, int kK>
169
+void invokeBf16SkinnyGemm(__nv_bfloat16* output, __nv_bfloat16 const* mat_a,
170
+ __nv_bfloat16 const* mat_b, int64_t out_stride,
171
+ cudaStream_t stream) {
172
+ static_assert(kN % kNPB == 0);
173
+ // Weight prefetch depth: only M=1 measured a win (see kernel comment).
174
+ constexpr int kPF = (kNumTokens == 1) ? 2 : 0;
175
+ cudaLaunchConfig_t config;
176
+ config.gridDim = kN / kNPB;
177
+ config.blockDim = kBlockSize;
178
+ config.dynamicSmemBytes = 0;
179
+ config.stream = stream;
180
+ cudaLaunchAttribute attrs[1];
181
+ attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
182
+ attrs[0].val.programmaticStreamSerializationAllowed = 1;
183
+ config.numAttrs = 1;
184
+ config.attrs = attrs;
185
+ cudaLaunchKernelEx(&config,
186
+ skinny::bf16_skinny_gemm_kernel<kBlockSize, kNumTokens,
187
+ kNPB, kPF, kN, kK>,
188
+ output, mat_a, mat_b, out_stride);
189
+}
190
+
191
+// ---------------------------------------------------------------------------
192
+// Explicit instantiations. M = 1..32; (N, K) pairs:
193
+// (768, 12288) eh_proj shard, TP8
194
+// (1536, 12288) eh_proj shard, TP4
195
+// (6144, 12288) eh_proj unsharded
196
+// kNPB (B300 sweep, M=1): 6144 -> 2 (3072 blocks, 23.2us vs 25.3 at kNPB=8;
197
+// narrow blocks minimize wave quantization); shards 768/1536 keep 4.
198
+// ---------------------------------------------------------------------------
199
+
200
+#define INSTANTIATE(M, NPB, N, K) \
201
+ template void invokeBf16SkinnyGemm<128, NPB, M, N, K>( \
202
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int64_t, \
203
+ cudaStream_t);
204
+
205
+#define INSTANTIATE_ALL_M(NPB, N, K) \
206
+ INSTANTIATE(1, NPB, N, K) \
207
+ INSTANTIATE(2, NPB, N, K) \
208
+ INSTANTIATE(3, NPB, N, K) \
209
+ INSTANTIATE(4, NPB, N, K) \
210
+ INSTANTIATE(5, NPB, N, K) \
211
+ INSTANTIATE(6, NPB, N, K) \
212
+ INSTANTIATE(7, NPB, N, K) \
213
+ INSTANTIATE(8, NPB, N, K) \
214
+ INSTANTIATE(9, NPB, N, K) \
215
+ INSTANTIATE(10, NPB, N, K) \
216
+ INSTANTIATE(11, NPB, N, K) \
217
+ INSTANTIATE(12, NPB, N, K) \
218
+ INSTANTIATE(13, NPB, N, K) \
219
+ INSTANTIATE(14, NPB, N, K) \
220
+ INSTANTIATE(15, NPB, N, K) \
221
+ INSTANTIATE(16, NPB, N, K) \
222
+ INSTANTIATE(17, NPB, N, K) \
223
+ INSTANTIATE(18, NPB, N, K) \
224
+ INSTANTIATE(19, NPB, N, K) \
225
+ INSTANTIATE(20, NPB, N, K) \
226
+ INSTANTIATE(21, NPB, N, K) \
227
+ INSTANTIATE(22, NPB, N, K) \
228
+ INSTANTIATE(23, NPB, N, K) \
229
+ INSTANTIATE(24, NPB, N, K) \
230
+ INSTANTIATE(25, NPB, N, K) \
231
+ INSTANTIATE(26, NPB, N, K) \
232
+ INSTANTIATE(27, NPB, N, K) \
233
+ INSTANTIATE(28, NPB, N, K) \
234
+ INSTANTIATE(29, NPB, N, K) \
235
+ INSTANTIATE(30, NPB, N, K) \
236
+ INSTANTIATE(31, NPB, N, K) \
237
+ INSTANTIATE(32, NPB, N, K)
238
+
239
+INSTANTIATE_ALL_M(4, 768, 12288)
240
+INSTANTIATE_ALL_M(4, 1536, 12288)
241
+INSTANTIATE_ALL_M(2, 6144, 12288)
242
+// LL-mode (M<=8 wiring guard) backbone shapes, B300 sweep vs cuBLAS:
243
+// q_b_proj (2048, 2048): 1.67x/1.29x/1.15x at M=4/6/8 (NPB=4 within
244
+// 0.1us of per-M best)
245
+// shared-expert gate_up (512, 6144): 1.95x/1.58x/1.40x at M=4/6/8
246
+// cuBLAS keeps qkv_a (2624,6144) and o_proj (6144,2048) — already at
247
+// 3.4-3.8 TB/s there; the GEMV loses on activation re-reads.
248
+INSTANTIATE_ALL_M(4, 2048, 2048)
249
+INSTANTIATE_ALL_M(4, 512, 6144)
250
+// fused_qkv_a (2624, 6144), 32MB: skinny wins ONLY at M<=2 (B300: M=1
251
+// 6.99us vs cuBLAS 9.21 = 1.32x, M=2 1.24x; M>=4 cuBLAS holds at 3.5TB/s
252
+// and every alternative loses — cublasLt top-8 3.6TB/s wall, DeepGEMM
253
+// 0.71x, wmma+cp.async custom 0.30x pending a TMA rewrite).
254
+INSTANTIATE_ALL_M(4, 2624, 6144)
255
+// DSv3.2 (TP8) siblings of the GLM shapes above, same dual-chip matrix:
256
+// fused_qkv_a (2112, 7168), 30MB: wins M<=2 (M=1 1.30-1.34x)
257
+// MTP eh_proj (7168, 14336), 205MB: wins M<=2 (M=1 1.12-1.16x)
258
+INSTANTIATE_ALL_M(4, 2112, 7168)
259
+INSTANTIATE_ALL_M(2, 7168, 14336)
260
+
261
+#undef INSTANTIATE_ALL_M
262
+#undef INSTANTIATE
csrc/libtorch_stable/bf16_skinny_gemm_entry.cu
new
+170
@@ -0,0 +1,170 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+#include <torch/csrc/stable/library.h>
5
+#include <torch/csrc/stable/tensor.h>
6
+#include <torch/headeronly/core/ScalarType.h>
7
+
8
+#include "core/registration.h"
9
+#include "libtorch_stable/torch_utils.h"
10
+
11
+#include <cuda_bf16.h>
12
+#include <cuda_runtime.h>
13
+
14
+#include <stdexcept>
15
+
16
+namespace {
17
+
18
+inline int getSMVersion() {
19
+ auto* props = get_device_prop();
20
+ return props->major * 10 + props->minor;
21
+}
22
+
23
+} // namespace
24
+
25
+static constexpr int SKINNY_MAX_TOKENS = 32;
26
+
27
+// Supported (N, K) pairs (must match the instantiations in
28
+// bf16_skinny_gemm.cu): eh_proj shard TP8 / TP4 / unsharded.
29
+static inline bool bf16_skinny_gemm_supported(int n, int k) {
30
+ if (k == 12288 && (n == 768 || n == 1536 || n == 6144)) return true;
31
+ // LL-mode backbone shapes (wire callers with an M <= 8 guard; the GEMV
32
+ // family loses to cuBLAS at larger M).
33
+ if (k == 2048 && n == 2048) return true; // q_b_proj (TP8)
34
+ if (k == 6144 && n == 2624) return true; // fused_qkv_a (wire M <= 2 only)
35
+ if (k == 7168 && n == 2112) return true; // DSv3.2 fused_qkv_a (M <= 2)
36
+ if (k == 14336 && n == 7168) return true; // DSv3.2 eh_proj (M <= 2)
37
+ if (k == 6144 && n == 512) return true; // shared-expert gate_up (TP8)
38
+ return false;
39
+}
40
+
41
+// Forward declarations - template params must match bf16_skinny_gemm.cu
42
+template <int kBlockSize, int kNPB, int kNumTokens, int kN, int kK>
43
+void invokeBf16SkinnyGemm(__nv_bfloat16* output, __nv_bfloat16 const* mat_a,
44
+ __nv_bfloat16 const* mat_b, int64_t out_stride,
45
+ cudaStream_t stream);
46
+
47
+template <int kNPB, int kN, int kK, int kBegin, int kEnd>
48
+struct SkinnyLoopUnroller {
49
+ static void unroll(int num_tokens, __nv_bfloat16* output,
50
+ __nv_bfloat16 const* mat_a, __nv_bfloat16 const* mat_b,
51
+ int64_t out_stride, cudaStream_t stream) {
52
+ if (num_tokens == kBegin) {
53
+ invokeBf16SkinnyGemm<128, kNPB, kBegin, kN, kK>(output, mat_a, mat_b,
54
+ out_stride, stream);
55
+ } else {
56
+ SkinnyLoopUnroller<kNPB, kN, kK, kBegin + 1, kEnd>::unroll(
57
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
58
+ }
59
+ }
60
+};
61
+
62
+template <int kNPB, int kN, int kK, int kEnd>
63
+struct SkinnyLoopUnroller<kNPB, kN, kK, kEnd, kEnd> {
64
+ static void unroll(int num_tokens, __nv_bfloat16* output,
65
+ __nv_bfloat16 const* mat_a, __nv_bfloat16 const* mat_b,
66
+ int64_t out_stride, cudaStream_t stream) {
67
+ if (num_tokens == kEnd) {
68
+ invokeBf16SkinnyGemm<128, kNPB, kEnd, kN, kK>(output, mat_a, mat_b,
69
+ out_stride, stream);
70
+ } else {
71
+ throw std::invalid_argument(
72
+ "bf16_skinny_gemm: num_tokens must be in [1, 32]");
73
+ }
74
+ }
75
+};
76
+
77
+static void dispatchBf16SkinnyGemm(int n, int k, int num_tokens,
78
+ __nv_bfloat16* output,
79
+ __nv_bfloat16 const* mat_a,
80
+ __nv_bfloat16 const* mat_b,
81
+ int64_t out_stride, cudaStream_t stream) {
82
+ if (n == 768 && k == 12288) {
83
+ SkinnyLoopUnroller<4, 768, 12288, 1, SKINNY_MAX_TOKENS>::unroll(
84
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
85
+ } else if (n == 1536 && k == 12288) {
86
+ SkinnyLoopUnroller<4, 1536, 12288, 1, SKINNY_MAX_TOKENS>::unroll(
87
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
88
+ } else if (n == 6144 && k == 12288) {
89
+ SkinnyLoopUnroller<2, 6144, 12288, 1, SKINNY_MAX_TOKENS>::unroll(
90
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
91
+ } else if (n == 2048 && k == 2048) {
92
+ SkinnyLoopUnroller<4, 2048, 2048, 1, SKINNY_MAX_TOKENS>::unroll(
93
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
94
+ } else if (n == 2624 && k == 6144) {
95
+ SkinnyLoopUnroller<4, 2624, 6144, 1, SKINNY_MAX_TOKENS>::unroll(
96
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
97
+ } else if (n == 2112 && k == 7168) {
98
+ SkinnyLoopUnroller<4, 2112, 7168, 1, SKINNY_MAX_TOKENS>::unroll(
99
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
100
+ } else if (n == 7168 && k == 14336) {
101
+ SkinnyLoopUnroller<2, 7168, 14336, 1, SKINNY_MAX_TOKENS>::unroll(
102
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
103
+ } else if (n == 512 && k == 6144) {
104
+ SkinnyLoopUnroller<4, 512, 6144, 1, SKINNY_MAX_TOKENS>::unroll(
105
+ num_tokens, output, mat_a, mat_b, out_stride, stream);
106
+ } else {
107
+ throw std::invalid_argument("bf16_skinny_gemm: unsupported (N, K) pair");
108
+ }
109
+}
110
+
111
+void bf16_skinny_gemm(
112
+ torch::stable::Tensor& output, // [num_tokens, N] bf16
113
+ torch::stable::Tensor const& mat_a, // [num_tokens, K] bf16
114
+ torch::stable::Tensor const& mat_b // [N, K] bf16
115
+) {
116
+ STD_TORCH_CHECK(output.dim() == 2 && mat_a.dim() == 2 && mat_b.dim() == 2);
117
+ STD_TORCH_CHECK(output.is_cuda() && mat_a.is_cuda() && mat_b.is_cuda(),
118
+ "bf16_skinny_gemm: all tensors must be CUDA tensors");
119
+ STD_TORCH_CHECK(output.get_device_index() == mat_a.get_device_index() &&
120
+ output.get_device_index() == mat_b.get_device_index(),
121
+ "bf16_skinny_gemm: all tensors must be on the same device");
122
+ STD_TORCH_CHECK(mat_a.is_contiguous() && mat_b.is_contiguous(),
123
+ "bf16_skinny_gemm: inputs must be contiguous");
124
+ // Output may be a column-slice view of a wider padded buffer: unit column
125
+ // stride, row stride >= N (rows must not overlap).
126
+ STD_TORCH_CHECK(output.stride(1) == 1,
127
+ "bf16_skinny_gemm: output columns must be contiguous");
128
+
129
+ const int num_tokens = mat_a.size(0);
130
+ const int n = mat_b.size(0);
131
+ const int k = mat_a.size(1);
132
+
133
+ STD_TORCH_CHECK(output.size(0) == num_tokens && output.size(1) == n,
134
+ "bf16_skinny_gemm: output must be [num_tokens, N]");
135
+ STD_TORCH_CHECK(mat_b.size(1) == k,
136
+ "bf16_skinny_gemm: mat_a and mat_b must share K");
137
+ STD_TORCH_CHECK(bf16_skinny_gemm_supported(n, k),
138
+ "bf16_skinny_gemm: unsupported (N, K) pair");
139
+ const int64_t out_stride = output.stride(0);
140
+ STD_TORCH_CHECK(num_tokens <= 1 || out_stride >= n,
141
+ "bf16_skinny_gemm: output rows overlap");
142
+ STD_TORCH_CHECK(num_tokens >= 0 && num_tokens <= SKINNY_MAX_TOKENS,
143
+ "bf16_skinny_gemm: num_tokens must be in [0, 32]");
144
+ STD_TORCH_CHECK(
145
+ mat_a.scalar_type() == torch::headeronly::ScalarType::BFloat16 &&
146
+ mat_b.scalar_type() == torch::headeronly::ScalarType::BFloat16 &&
147
+ output.scalar_type() == torch::headeronly::ScalarType::BFloat16,
148
+ "bf16_skinny_gemm: all tensors must be bfloat16");
149
+
150
+ // Empty batch (e.g. an empty rank at a DP/PP boundary): nothing to compute.
151
+ if (num_tokens == 0) {
152
+ return;
153
+ }
154
+
155
+ const torch::stable::accelerator::DeviceGuard device_guard(
156
+ mat_a.get_device_index());
157
+ STD_TORCH_CHECK(getSMVersion() >= 90, "bf16_skinny_gemm: requires SM90+");
158
+
159
+ auto stream = get_current_cuda_stream(mat_a.get_device_index());
160
+ dispatchBf16SkinnyGemm(
161
+ n, k, num_tokens,
162
+ reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()),
163
+ reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()),
164
+ reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), out_stride,
165
+ stream);
166
+}
167
+
168
+STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) {
169
+ m.impl("bf16_skinny_gemm", TORCH_BOX(&bf16_skinny_gemm));
170
+}
csrc/libtorch_stable/dsv3_fused_a_gemm.cu
+112
-36
@@ -391,7 +391,7 @@ struct MmaComputer {
391
static constexpr int n_iter_cnt =
392
(tile_n + 7) /
393
8; // Possible to have non-1 n_iter_cnt for ab_swap m16 case.
394
- static_assert(m_iter_cnt == 1);
394
+ static_assert(m_iter_cnt == 1 || m_iter_cnt == 2);
395
static_assert(n_iter_cnt == 1 || n_iter_cnt == 2);
396
397
__device__ MmaComputer(bf16_t* gmem_c_local_, bf16_t* smem_a_,
@@ -416,13 +416,18 @@ struct MmaComputer {
416
public:
417
__device__ void prepare() {
418
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
419
+ // Fragment addressing is per 16-row ldmatrix tile; m_iter selects the
420
+ // 16-row half within tile_m.
421
#pragma unroll
420
- for (int i = 0; i < k_phase_cnt; i++) {
421
- int linear_idx = (lane_idx % 16) + (lane_idx / 16) * 128 + i * 256;
422
- int m_idx = linear_idx % tile_m;
423
- int k_idx = linear_idx / tile_m + warp_k_offset_in_tile_k;
424
- k_idx = apply_swizzle_343_on_elem_row_col<bf16_t>(m_idx, k_idx);
425
- a_smem_offsets[0][i] = m_idx * tile_k + k_idx;
422
+ for (int m = 0; m < m_iter_cnt; m++) {
423
+ #pragma unroll
424
+ for (int i = 0; i < k_phase_cnt; i++) {
425
+ int linear_idx = (lane_idx % 16) + (lane_idx / 16) * 128 + i * 256;
426
+ int m_idx = linear_idx % 16 + m * 16;
427
+ int k_idx = linear_idx / 16 + warp_k_offset_in_tile_k;
428
+ k_idx = apply_swizzle_343_on_elem_row_col<bf16_t>(m_idx, k_idx);
429
+ a_smem_offsets[m][i] = m_idx * tile_k + k_idx;
430
+ }
431
}
432
#pragma unroll
433
for (int n_iter_idx = 0; n_iter_idx < n_iter_cnt; n_iter_idx++) {
@@ -446,11 +451,14 @@ struct MmaComputer {
451
wait_barrier(smem_barrier + 0 + stage_idx * 2, phase_bit);
452
453
#pragma unroll
449
- for (int i = 0; i < k_phase_cnt; i++) {
450
- int smem_offset = a_smem_offsets[0][i];
451
- bf16_t* smem_ptr_this_iter =
452
- smem_a + stage_idx * tile_m * tile_k + smem_offset;
453
- ldsm_x4(smem_ptr_this_iter, reinterpret_cast<uint32_t*>(a_reg[0][i]));
454
+ for (int m = 0; m < m_iter_cnt; m++) {
455
+ #pragma unroll
456
+ for (int i = 0; i < k_phase_cnt; i++) {
457
+ int smem_offset = a_smem_offsets[m][i];
458
+ bf16_t* smem_ptr_this_iter =
459
+ smem_a + stage_idx * tile_m * tile_k + smem_offset;
460
+ ldsm_x4(smem_ptr_this_iter, reinterpret_cast<uint32_t*>(a_reg[m][i]));
461
+ }
462
}
463
464
#pragma unroll
@@ -469,9 +477,12 @@ struct MmaComputer {
477
for (int k_iter_idx = 0; k_iter_idx < k_phase_cnt; k_iter_idx++) {
478
#pragma unroll
479
for (int n_iter_idx = 0; n_iter_idx < n_iter_cnt; n_iter_idx++) {
472
- hmma_16_8_16_f32acc_bf16ab(
473
- acc_reg[0][n_iter_idx], a_reg[0][k_iter_idx],
474
- b_reg[n_iter_idx][k_iter_idx], acc_reg[0][n_iter_idx]);
480
+ #pragma unroll
481
+ for (int m = 0; m < m_iter_cnt; m++) {
482
+ hmma_16_8_16_f32acc_bf16ab(
483
+ acc_reg[m][n_iter_idx], a_reg[m][k_iter_idx],
484
+ b_reg[n_iter_idx][k_iter_idx], acc_reg[m][n_iter_idx]);
485
+ }
486
}
487
}
488
::arrive_barrier(smem_barrier + 1 + stage_idx * 2);
@@ -486,14 +497,14 @@ struct MmaComputer {
497
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
498
asm volatile("bar.sync %0, %1;" : : "r"(1), "r"(thread_cnt));
499
// reorganize the acc_reg
489
- constexpr int thread_m = 2;
500
+ constexpr int thread_m = 2 * m_iter_cnt;
501
constexpr int thread_n = 2 * n_iter_cnt;
502
constexpr int cta_mma_n = n_iter_cnt * 8;
503
float acc_reg_reorg[thread_m][thread_n];
504
505
for (int i = 0; i < thread_m; i++) {
506
for (int j = 0; j < thread_n; j++) {
496
- acc_reg_reorg[i][j] = acc_reg[0][j / 2][(j % 2) + (i * 2)];
507
+ acc_reg_reorg[i][j] = acc_reg[i / 2][j / 2][(j % 2) + (i % 2) * 2];
508
}
509
}
510
@@ -514,7 +525,8 @@ struct MmaComputer {
525
for (int m_idx_thread = 0; m_idx_thread < thread_m; m_idx_thread++) {
526
#pragma unroll
527
for (int n_idx_thread = 0; n_idx_thread < thread_n; n_idx_thread++) {
517
- int m_idx = (lane_idx / 4) + m_idx_thread * 8;
528
+ int m_idx =
529
+ (lane_idx / 4) + (m_idx_thread % 2) * 8 + (m_idx_thread / 2) * 16;
530
int n_idx =
531
((lane_idx % 4) * 2) + (n_idx_thread % 2) + (n_idx_thread / 2) * 8;
532
smem_c[cosize_smem_c * warp_idx + smem_c_index_func(m_idx, n_idx)] =
@@ -587,7 +599,7 @@ __global__ __launch_bounds__(256, 1) void fused_a_gemm_kernel(
599
static_assert(
600
tile_k == 128 || tile_k == 256 || tile_k == 512 ||
601
tile_k == 1024); // tile_k must be larger than 64 since 4 warp splitK.
590
- static_assert(tile_m == 16);
602
+ static_assert(tile_m == 16 || tile_m == 32);
603
constexpr int g2s_vec_bytes = 16;
604
constexpr int a_elem_bytes = 2;
605
constexpr int b_elem_bytes = 2;
@@ -647,7 +659,7 @@ __global__ __launch_bounds__(256, 1) void fused_a_gemm_kernel(
659
#endif
660
}
661
650
-template <typename T, int kHdIn, int kHdOut, int kTileN>
662
+template <typename T, int kHdIn, int kHdOut, int kTileN, int kTileM = 16>
663
void invokeFusedAGemm(T* output, T const* mat_a, T const* mat_b, int num_tokens,
664
cudaStream_t const stream) {
665
constexpr int gemm_m = kHdOut; // 2112
@@ -655,7 +667,7 @@ void invokeFusedAGemm(T* output, T const* mat_a, T const* mat_b, int num_tokens,
667
constexpr int gemm_k = kHdIn; // 7168
668
constexpr int batch_size = 1;
669
std::swap(mat_a, mat_b);
658
- constexpr int tile_m = 16;
670
+ constexpr int tile_m = kTileM;
671
constexpr int tile_n = kTileN; // 8 or 16
672
constexpr int tile_k = std::max(256, 1024 / tile_n); // 256
673
constexpr int max_stage_cnt =
@@ -702,6 +714,44 @@ template void invokeFusedAGemm<__nv_bfloat16, 7168, 2112, 16>(
714
__nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
715
cudaStream_t);
716
717
+// GLM-5.2 fused_qkv_a (K=6144 -> N=2624). tile_m=32 so the grid is
718
+// 2624/32 = 82 CTAs, a single wave on B300 (148 SMs); tile_m=16's 164 CTAs
719
+// straddle two waves and drop to 2.9 TB/s vs 3.9-4.0 here. Beats cuBLAS
720
+// at every decode M: 1.11-1.15x for M<=8 (tile_n=8), 1.12x at M=16
721
+// (tile_n=16). The M<=2 dispatch still belongs to bf16_skinny_gemm
722
+// (4.5 TB/s).
723
+template void invokeFusedAGemm<__nv_bfloat16, 6144, 2624, 8, 32>(
724
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
725
+ cudaStream_t);
726
+
727
+template void invokeFusedAGemm<__nv_bfloat16, 6144, 2624, 16, 32>(
728
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
729
+ cudaStream_t);
730
+
731
+// GLM-5.2 q_b_proj TP8 (K=2048 -> N=2048). tile_m=16 keeps 128 CTAs (already
732
+// a single wave) and measures ahead of tile_m=32 here. Beats cuBLAS
733
+// 1.79-1.87x at M=3..8 (tile_n=8) and 1.19-1.30x at M=9..16 (tile_n=16);
734
+// M<=2 belongs to bf16_skinny_gemm, M>=20 to cuBLAS.
735
+template void invokeFusedAGemm<__nv_bfloat16, 2048, 2048, 8>(
736
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
737
+ cudaStream_t);
738
+
739
+template void invokeFusedAGemm<__nv_bfloat16, 2048, 2048, 16>(
740
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
741
+ cudaStream_t);
742
+
743
+// DSv3.2 q_b_proj TP8 (K=1536 -> N=3072). tile_m=32 keeps 96 CTAs (single
744
+// wave; tile_m=16's 192 straddle two). Beats cuBLAS 1.6-1.8x at M=1..8 and
745
+// 1.05-1.18x at M=9..16 on B300/B200; the whole 1..16 range dispatches here
746
+// (no skinny tier: K=1536 does not fit the GEMV's 128x8 K-step).
747
+template void invokeFusedAGemm<__nv_bfloat16, 1536, 3072, 8, 32>(
748
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
749
+ cudaStream_t);
750
+
751
+template void invokeFusedAGemm<__nv_bfloat16, 1536, 3072, 16, 32>(
752
+ __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, int num_tokens,
753
+ cudaStream_t);
754
+
755
void dsv3_fused_a_gemm(torch::stable::Tensor& output,
756
torch::stable::Tensor const& mat_a,
757
torch::stable::Tensor const& mat_b) {
@@ -710,12 +760,15 @@ void dsv3_fused_a_gemm(torch::stable::Tensor& output,
760
int const hd_in = mat_a.size(1);
761
int const hd_out = mat_b.size(1);
762
713
- constexpr int kHdIn = 7168;
714
- constexpr int kHdOut = 2112;
763
+ bool const is_dsv3 = hd_in == 7168 && hd_out == 2112;
764
+ bool const is_glm = hd_in == 6144 && hd_out == 2624;
765
+ bool const is_glm_qb = hd_in == 2048 && hd_out == 2048;
766
+ bool const is_ds_qb = hd_in == 1536 && hd_out == 3072;
767
STD_TORCH_CHECK(num_tokens >= 1 && num_tokens <= 16,
768
"required 1 <= mat_a.shape[0] <= 16");
717
- STD_TORCH_CHECK(hd_in == kHdIn, "required mat_a.shape[1] == 7168");
718
- STD_TORCH_CHECK(hd_out == kHdOut, "required mat_b.shape[1] == 2112");
769
+ STD_TORCH_CHECK(is_dsv3 || is_glm || is_glm_qb || is_ds_qb,
770
+ "supported (hd_in, hd_out): (7168, 2112), (6144, 2624), "
771
+ "(2048, 2048), (1536, 3072)");
772
STD_TORCH_CHECK(output.size(0) == num_tokens,
773
"required output.shape[0] == mat_a.shape[0]");
774
STD_TORCH_CHECK(output.size(1) == hd_out,
@@ -738,18 +791,41 @@ void dsv3_fused_a_gemm(torch::stable::Tensor& output,
791
STD_TORCH_CHECK(getSMVersion() >= 90, "required CUDA ARCH >= SM_90");
792
793
auto stream = get_current_cuda_stream(mat_a.get_device_index());
741
- if (num_tokens <= 8) {
742
- invokeFusedAGemm<__nv_bfloat16, kHdIn, kHdOut, 8>(
743
- reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()),
744
- reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()),
745
- reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), num_tokens,
746
- stream);
794
+ auto* out = reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr());
795
+ auto* a = reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr());
796
+ auto* b = reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr());
797
+ if (is_dsv3) {
798
+ if (num_tokens <= 8) {
799
+ invokeFusedAGemm<__nv_bfloat16, 7168, 2112, 8>(out, a, b, num_tokens,
800
+ stream);
801
+ } else {
802
+ invokeFusedAGemm<__nv_bfloat16, 7168, 2112, 16>(out, a, b, num_tokens,
803
+ stream);
804
+ }
805
+ } else if (is_glm) {
806
+ if (num_tokens <= 8) {
807
+ invokeFusedAGemm<__nv_bfloat16, 6144, 2624, 8, 32>(out, a, b, num_tokens,
808
+ stream);
809
+ } else {
810
+ invokeFusedAGemm<__nv_bfloat16, 6144, 2624, 16, 32>(out, a, b, num_tokens,
811
+ stream);
812
+ }
813
+ } else if (is_glm_qb) {
814
+ if (num_tokens <= 8) {
815
+ invokeFusedAGemm<__nv_bfloat16, 2048, 2048, 8>(out, a, b, num_tokens,
816
+ stream);
817
+ } else {
818
+ invokeFusedAGemm<__nv_bfloat16, 2048, 2048, 16>(out, a, b, num_tokens,
819
+ stream);
820
+ }
821
} else {
748
- invokeFusedAGemm<__nv_bfloat16, kHdIn, kHdOut, 16>(
749
- reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()),
750
- reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()),
751
- reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), num_tokens,
752
- stream);
822
+ if (num_tokens <= 8) {
823
+ invokeFusedAGemm<__nv_bfloat16, 1536, 3072, 8, 32>(out, a, b, num_tokens,
824
+ stream);
825
+ } else {
826
+ invokeFusedAGemm<__nv_bfloat16, 1536, 3072, 16, 32>(out, a, b, num_tokens,
827
+ stream);
828
+ }
829
}
830
}
831
csrc/libtorch_stable/quantization/fp4/nvfp4_quant_kernels.cu
+42
-10
@@ -49,6 +49,11 @@ __global__ void __launch_bounds__(512, VLLM_BLOCKS_PER_SM(512))
49
static_assert(sizeof(PackedVec) == sizeof(Type) * CVT_FP4_ELTS_PER_THREAD,
50
"Vec size is not matched.");
51
52
+#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900))
53
+ cudaGridDependencySynchronize();
54
+ cudaTriggerProgrammaticLaunchCompletion();
55
+#endif
56
+
57
// Precompute SF layout parameter (constant for entire kernel).
58
int32_t const numKTiles = (outputCols + 63) / 64;
59
@@ -123,6 +128,11 @@ __global__ void __launch_bounds__(512, VLLM_BLOCKS_PER_SM(512))
128
static_assert(sizeof(PackedVec) == sizeof(Type) * CVT_FP4_ELTS_PER_THREAD,
129
"Vec size is not matched.");
130
131
+#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900))
132
+ cudaGridDependencySynchronize();
133
+ cudaTriggerProgrammaticLaunchCompletion();
134
+#endif
135
+
136
int32_t const colIdx = blockDim.x * blockIdx.y + threadIdx.x;
137
int elem_idx = colIdx * CVT_FP4_ELTS_PER_THREAD;
138
@@ -201,6 +211,8 @@ void scaled_fp4_quant_sm1xxa(torch::stable::Tensor const& output,
211
const torch::stable::accelerator::DeviceGuard device_guard(
212
input.get_device_index());
213
auto stream = get_current_cuda_stream(input.get_device_index());
214
+ auto* device_props = get_device_prop();
215
+ int const sm_version = device_props->major * 10 + device_props->minor;
216
217
int output_sf_n_unpadded = int(output_n / CVT_FP4_SF_VEC_SIZE);
218
@@ -224,10 +236,21 @@ void scaled_fp4_quant_sm1xxa(torch::stable::Tensor const& output,
236
input.scalar_type(), "nvfp4_quant_kernel", [&] {
237
using cuda_type = vllm::CUDATypeConverter<scalar_t>::Type;
238
auto input_ptr = static_cast<cuda_type const*>(input.data_ptr());
227
- vllm::cvt_fp16_to_fp4<cuda_type, false><<<grid, block, 0, stream>>>(
228
- m, n, output_n, num_padded_cols, input_ptr, input_sf_ptr,
229
- reinterpret_cast<uint32_t*>(output_ptr),
230
- reinterpret_cast<uint32_t*>(sf_out));
239
+ cudaLaunchConfig_t config = {};
240
+ config.gridDim = grid;
241
+ config.blockDim = block;
242
+ config.dynamicSmemBytes = 0;
243
+ config.stream = stream;
244
+ cudaLaunchAttribute attrs[1];
245
+ attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
246
+ attrs[0].val.programmaticStreamSerializationAllowed = 1;
247
+ config.numAttrs = (sm_version >= 90) ? 1 : 0;
248
+ config.attrs = attrs;
249
+ cudaLaunchKernelEx(&config, vllm::cvt_fp16_to_fp4<cuda_type, false>,
250
+ m, n, output_n, num_padded_cols, input_ptr,
251
+ input_sf_ptr,
252
+ reinterpret_cast<uint32_t*>(output_ptr),
253
+ reinterpret_cast<uint32_t*>(sf_out));
254
});
255
} else {
256
int num_packed_cols = output_n / CVT_FP4_ELTS_PER_THREAD;
@@ -240,12 +263,21 @@ void scaled_fp4_quant_sm1xxa(torch::stable::Tensor const& output,
263
input.scalar_type(), "nvfp4_quant_kernel", [&] {
264
using cuda_type = vllm::CUDATypeConverter<scalar_t>::Type;
265
auto input_ptr = static_cast<cuda_type const*>(input.data_ptr());
243
- vllm::cvt_fp16_to_fp4_sf_major<cuda_type, false>
244
- <<<grid, block, 0, stream>>>(
245
- m, n, output_n, output_sf_n_unpadded, num_packed_cols,
246
- input_ptr, input_sf_ptr,
247
- reinterpret_cast<uint32_t*>(output_ptr),
248
- reinterpret_cast<uint32_t*>(sf_out));
266
+ cudaLaunchConfig_t config = {};
267
+ config.gridDim = grid;
268
+ config.blockDim = block;
269
+ config.dynamicSmemBytes = 0;
270
+ config.stream = stream;
271
+ cudaLaunchAttribute attrs[1];
272
+ attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
273
+ attrs[0].val.programmaticStreamSerializationAllowed = 1;
274
+ config.numAttrs = (sm_version >= 90) ? 1 : 0;
275
+ config.attrs = attrs;
276
+ cudaLaunchKernelEx(
277
+ &config, vllm::cvt_fp16_to_fp4_sf_major<cuda_type, false>, m, n,
278
+ output_n, output_sf_n_unpadded, num_packed_cols, input_ptr,
279
+ input_sf_ptr, reinterpret_cast<uint32_t*>(output_ptr),
280
+ reinterpret_cast<uint32_t*>(sf_out));
281
});
282
}
283
}
csrc/libtorch_stable/torch_bindings.cpp
+4
@@ -330,6 +330,10 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
330
// conditionally compiled so impl registration is in source file
331
ops.def("fp32_router_gemm(Tensor! output, Tensor mat_a, Tensor mat_b) -> ()");
332
333
+ // BF16 skinny GEMM (M<=32, weight-BW-bound shapes, e.g. MTP eh_proj).
334
+ // conditionally compiled so impl registration is in source file
335
+ ops.def("bf16_skinny_gemm(Tensor! output, Tensor mat_a, Tensor mat_b) -> ()");
336
+
337
// reorder weight for AllSpark Ampere W8A16 Fused Gemm kernel
338
ops.def(
339
"rearrange_kn_weight_as_n32k16_order(Tensor b_qweight, Tensor b_scales, "
recipes/glm5.2-ll-b300-tp8-mtp5.md
new
+41
@@ -0,0 +1,41 @@
1
+# GLM-5.2-NVFP4 — Low-Latency (LL) serving recipe · 8×B300 · TP8 · MTP=5
2
+
3
+Single-node 8×B300 (sm_100/103), pure tensor parallel, NVFP4 weights, fp8 KV cache,
4
+MTP=5 speculative decode, model runner v2.
5
+
6
+Validated on `glm5.2-LL` @ `8d407aee1`.
7
+
8
+## Versions
9
+
10
+| Component | Version |
11
+| --- | --- |
12
+| vLLM | `glm5.2-LL` @ `8d407aee1` |
13
+| flashinfer-python | `0.6.15` |
14
+| Model | `nvidia/GLM-5.2-NVFP4` (`modelopt_fp4`) |
15
+
16
+## Build (once)
17
+
18
+```bash
19
+pip install 'flashinfer-python==0.6.15'
20
+
21
+CUDA_HOME=/usr/local/cuda-13.0 TORCH_CUDA_ARCH_LIST="10.0 10.3" MAX_JOBS=96 \
22
+VLLM_USE_PRECOMPILED=0 VLLM_USE_PRECOMPILED_RUST=0 \
23
+pip install -e . --no-deps --no-build-isolation
24
+```
25
+
26
+## Server
27
+
28
+```bash
29
+export VLLM_USE_V2_MODEL_RUNNER=1 # model runner v2
30
+export VLLM_DEEP_GEMM_WARMUP=skip
31
+
32
+vllm serve nvidia/GLM-5.2-NVFP4 \
33
+ --trust-remote-code \
34
+ --tensor-parallel-size 8 \
35
+ --quantization modelopt_fp4 --kv-cache-dtype fp8_e4m3 \
36
+ --max-model-len 32768 --max-num-batched-tokens 16384 --max-num-seqs 256 \
37
+ --no-enable-prefix-caching --gpu-memory-utilization 0.85 \
38
+ --speculative-config '{"method":"mtp","num_speculative_tokens":5}' \
39
+ --kernel-config '{"ir_op_priority":{"rms_norm":["vllm_c","native"],"fused_add_rms_norm":["vllm_c","native"]}}' \
40
+ --host 0.0.0.0 --port 8000
41
+```
tests/kernels/test_bf16_skinny_gemm.py
new
+70
@@ -0,0 +1,70 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Numerical correctness for the bf16 skinny GEMM (decode-M GEMV) kernel.
4
+
5
+Covers every supported (N, K) shape across M = 0/1/2/8/16/32 (M=32 exercises
6
+the tile_m=32 MMA path), plus strided (column-slice) output. M=0 must return
7
+an empty result without launching the kernel (empty ranks at DP/PP
8
+boundaries)."""
9
+
10
+import pytest
11
+import torch
12
+
13
+from vllm import _custom_ops as ops
14
+from vllm.platforms import current_platform
15
+
16
+# (N, K) pairs must match bf16_skinny_gemm_supported() in the entry .cu.
17
+SHAPES = [
18
+ (768, 12288),
19
+ (1536, 12288),
20
+ (6144, 12288),
21
+ (2048, 2048),
22
+ (2624, 6144),
23
+ (2112, 7168),
24
+ (7168, 14336),
25
+ (512, 6144),
26
+]
27
+MS = [0, 1, 2, 8, 16, 32]
28
+
29
+pytestmark = pytest.mark.skipif(
30
+ not current_platform.is_cuda() or not current_platform.has_device_capability(90),
31
+ reason="bf16_skinny_gemm requires CUDA SM90+",
32
+)
33
+
34
+
35
+def _rel_err(out: torch.Tensor, ref: torch.Tensor) -> float:
36
+ return (out.float() - ref).norm().item() / ref.norm().clamp_min(1e-6).item()
37
+
38
+
39
+@pytest.mark.parametrize("n,k", SHAPES)
40
+@pytest.mark.parametrize("m", MS)
41
+def test_bf16_skinny_gemm_matches_reference(n: int, k: int, m: int):
42
+ torch.manual_seed(0)
43
+ x = torch.randn(m, k, dtype=torch.bfloat16, device="cuda")
44
+ w = torch.randn(n, k, dtype=torch.bfloat16, device="cuda")
45
+
46
+ out = ops.bf16_skinny_gemm(x, w)
47
+ assert out.shape == (m, n)
48
+ assert out.dtype == torch.bfloat16
49
+
50
+ if m == 0: # empty batch: nothing to check beyond the empty shape
51
+ return
52
+ ref = x.float() @ w.float().t()
53
+ rel = _rel_err(out, ref)
54
+ assert rel < 2e-2, f"rel err {rel:.4f} for (m,n,k)=({m},{n},{k})"
55
+
56
+
57
+@pytest.mark.parametrize("n,k", [(2048, 2048), (512, 6144)])
58
+def test_bf16_skinny_gemm_strided_output(n: int, k: int):
59
+ """Output is a column-slice of a wider padded buffer (row stride > N)."""
60
+ torch.manual_seed(0)
61
+ m = 8
62
+ x = torch.randn(m, k, dtype=torch.bfloat16, device="cuda")
63
+ w = torch.randn(n, k, dtype=torch.bfloat16, device="cuda")
64
+
65
+ wide = torch.empty(m, n + 64, dtype=torch.bfloat16, device="cuda")
66
+ view = wide[:, :n]
67
+ torch.ops._C.bf16_skinny_gemm(view, x, w)
68
+
69
+ ref = x.float() @ w.float().t()
70
+ assert _rel_err(view, ref) < 2e-2
tests/kernels/test_fused_deepseek_v32_norm_rope.py
+77
@@ -49,6 +49,32 @@ pytestmark = pytest.mark.skipif(
49
)
50
51
52
+def test_platform_capability_queries_are_constant_during_compile(monkeypatch):
53
+ monkeypatch.setattr(
54
+ K.current_platform, "has_device_capability", lambda capability: True
55
+ )
56
+ monkeypatch.setattr(K, "has_cutedsl", lambda: True)
57
+ monkeypatch.setattr(K.current_platform, "is_arch_support_pdl", lambda: True)
58
+
59
+ def capability_branches(x: torch.Tensor) -> torch.Tensor:
60
+ if K._can_use_fused_q_cutedsl() and K._is_arch_support_pdl():
61
+ return x + 1
62
+ return x - 1
63
+
64
+ compiled = torch.compile(capability_branches, backend="eager", fullgraph=True)
65
+ x = torch.zeros(1, device="cuda")
66
+ torch.testing.assert_close(compiled(x), torch.ones_like(x))
67
+
68
+
69
+def test_pdl_is_disabled_before_blackwell(monkeypatch):
70
+ monkeypatch.setattr(
71
+ K.current_platform, "has_device_capability", lambda capability: False
72
+ )
73
+ monkeypatch.setattr(K.current_platform, "is_arch_support_pdl", lambda: True)
74
+
75
+ assert not K._is_arch_support_pdl()
76
+
77
+
78
# ── reference helpers ────────────────────────────────────────────────────────
79
80
@@ -292,6 +318,57 @@ def test_fused_norm_rope_no_indexer(num_tokens: int):
318
assert (topk == 7).all(), "topk buffer should be untouched on shared layer"
319
320
321
+def test_fused_norm_rope_profile_without_cache_compiles():
322
+ """The cache-free profiling path must still compile and produce Q."""
323
+ torch.manual_seed(2)
324
+ dev = "cuda"
325
+ num_tokens = 4
326
+ pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
327
+ q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16)
328
+ kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16)
329
+ k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16)
330
+ qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16)
331
+ kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16)
332
+ index_k = torch.randn(num_tokens, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16)
333
+ index_w = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
334
+ index_b = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
335
+ cos_sin = make_cos_sin(64, ROPE_DIM, dev)
336
+ topk = torch.empty(num_tokens, 2048, device=dev, dtype=torch.int32)
337
+
338
+ def profile_run(
339
+ q_c: torch.Tensor,
340
+ kv_c: torch.Tensor,
341
+ k_pe: torch.Tensor,
342
+ index_k: torch.Tensor,
343
+ ) -> torch.Tensor:
344
+ return K.fused_norm_rope(
345
+ pos,
346
+ q_c,
347
+ qw,
348
+ EPS,
349
+ kv_c,
350
+ kvw,
351
+ EPS,
352
+ k_pe,
353
+ cos_sin,
354
+ index_k,
355
+ index_w,
356
+ index_b,
357
+ EPS,
358
+ cos_sin,
359
+ topk,
360
+ slot_mapping=None,
361
+ indexer_k_cache=None,
362
+ mla_kv_cache=None,
363
+ has_indexer=True,
364
+ index_rope_interleave=False,
365
+ )
366
+
367
+ compiled = torch.compile(profile_run, fullgraph=True)
368
+ q_out = compiled(q_c, kv_c, k_pe, index_k)
369
+ assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm (profiling)")
370
+
371
+
372
@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512])
373
def test_fused_norm_rope_ds_mla(num_tokens: int):
374
"""fp8_ds_mla MLA cache layout (FlashMLA sparse, bf16-query path; SM90/SM100).
tests/models/registry.py
+4
@@ -1604,6 +1604,10 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1604
speculative_model="luccafong/deepseek_mtp_draft_random",
1605
trust_remote_code=True,
1606
),
1607
+ "DeepseekV32MTPModel": _HfExamplesInfo(
1608
+ "deepseek-ai/DeepSeek-V3.2-Exp",
1609
+ speculative_model="deepseek-ai/DeepSeek-V3.2-Exp",
1610
+ ),
1611
"DeepSeekV4MTPModel": _HfExamplesInfo(
1612
"deepseek-ai/DeepSeek-V4-Flash",
1613
speculative_model="deepseek-ai/DeepSeek-V4-Flash",
vllm/_custom_ops.py
+25
@@ -2371,6 +2371,20 @@ def fp32_router_gemm(
2371
return output
2372
2373
2374
+def bf16_skinny_gemm(
2375
+ x: torch.Tensor,
2376
+ weight: torch.Tensor,
2377
+) -> torch.Tensor:
2378
+ """Skinny bf16 GEMM (M<=32): x [M, K] @ weight [N, K]^T -> [M, N].
2379
+
2380
+ Single block-per-column kernel with fp32 accumulation; replaces cuBLAS
2381
+ splitK for weight-bandwidth-bound decode shapes (e.g. MTP eh_proj).
2382
+ """
2383
+ output = torch.empty((x.shape[0], weight.shape[0]), dtype=x.dtype, device=x.device)
2384
+ torch.ops._C.bf16_skinny_gemm(output, x, weight)
2385
+ return output
2386
+
2387
+
2388
if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "fp32_router_gemm"):
2389
2390
@register_fake("_C::fp32_router_gemm")
@@ -2382,6 +2396,17 @@ if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "fp32_router_gemm"):
2396
return
2397
2398
2399
+if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "bf16_skinny_gemm"):
2400
+
2401
+ @register_fake("_C::bf16_skinny_gemm")
2402
+ def bf16_skinny_gemm_fake(
2403
+ output: torch.Tensor,
2404
+ mat_a: torch.Tensor,
2405
+ mat_b: torch.Tensor,
2406
+ ) -> None:
2407
+ return
2408
+
2409
+
2410
def topk_softmax(
2411
topk_weights: torch.Tensor,
2412
topk_ids: torch.Tensor,
vllm/compilation/passes/fusion/allreduce_rms_fusion.py
+111
-35
@@ -202,11 +202,7 @@ if flashinfer_comm is not None:
202
element_size = allreduce_in.element_size()
203
current_tensor_size = num_tokens * hidden_size * element_size
204
max_tensor_size = max_token_num * hidden_size * element_size
205
- assert current_tensor_size <= max_tensor_size, (
206
- f"Current tensor size {current_tensor_size} is larger than "
207
- f"max token num {max_token_num} * hidden size {hidden_size} * "
208
- f"element size {element_size}"
209
- )
205
+ oversize = current_tensor_size > max_tensor_size
206
curr_device = current_platform.get_device_capability()
207
device_capability = curr_device.to_int() if curr_device is not None else None
208
@@ -219,6 +215,47 @@ if flashinfer_comm is not None:
215
get_workspace_fn = (
216
get_fi_ar_quant_workspace if is_quant_pattern else get_fi_ar_workspace
217
)
218
+
219
+ orig_norm_out = norm_out
220
+
221
+ def unfused_fallback() -> None:
222
+ # Fused-path equivalent honoring the op's output-buffer contract:
223
+ # with norm_out given, normed -> norm_out and the new residual ->
224
+ # the allreduce_in buffer; otherwise normed -> allreduce_in and
225
+ # the new residual -> the residual buffer. Runs fine during CUDA
226
+ # graph capture (the branch is resolved per captured shape).
227
+ #
228
+ # weight_bias mirrors the fused kernel's gamma offset (Gemma
229
+ # patterns pass 1.0 for rms_norm(x, weight + 1)); fold it into
230
+ # the gamma in fp32 like the reference before the plain rms_norm.
231
+ gamma = (
232
+ rms_gamma
233
+ if weight_bias == 0.0
234
+ else (rms_gamma.float() + weight_bias).to(rms_gamma.dtype)
235
+ )
236
+ reduced = tensor_model_parallel_all_reduce(allreduce_in)
237
+ new_residual = reduced + residual
238
+ if orig_norm_out is None:
239
+ torch.ops._C.rms_norm(allreduce_in, new_residual, gamma, rms_eps)
240
+ residual.copy_(new_residual)
241
+ else:
242
+ torch.ops._C.rms_norm(orig_norm_out, new_residual, gamma, rms_eps)
243
+ allreduce_in.copy_(new_residual)
244
+
245
+ if oversize:
246
+ assert not is_quant_pattern, (
247
+ "flashinfer fused allreduce: input exceeds the workspace budget "
248
+ "and the unfused fallback does not cover quant patterns"
249
+ )
250
+ logger.warning_once(
251
+ "fused allreduce+rmsnorm: %d tokens exceeds the workspace "
252
+ "budget (max_token_num=%d); using the unfused fallback for "
253
+ "this shape.",
254
+ num_tokens,
255
+ max_token_num,
256
+ )
257
+ unfused_fallback()
258
+ return
259
workspace = get_workspace_fn(
260
world_size=world_size,
261
rank=get_tensor_model_parallel_rank(),
@@ -252,36 +289,75 @@ if flashinfer_comm is not None:
289
if workspace.backend in ("trtllm", "mnnvl"):
290
layout_code = flashinfer_comm.QuantizationSFLayout.SWIZZLED_128x4
291
255
- flashinfer_comm.allreduce_fusion(
256
- input=allreduce_in,
257
- workspace=workspace,
258
- pattern=pattern_code,
259
- launch_with_pdl=launch_with_pdl,
260
- output=None,
261
- residual_out=residual_out,
262
- norm_out=norm_out,
263
- quant_out=quant_out,
264
- scale_out=scale_out,
265
- residual_in=residual,
266
- rms_gamma=rms_gamma,
267
- rms_eps=rms_eps,
268
- scale_factor=scale_factor,
269
- layout_code=layout_code,
270
- use_oneshot=use_oneshot,
271
- fp32_acc=fp32_acc,
272
- weight_bias=weight_bias,
273
- # The one-shot Lamport all-reduce signals PDL completion before its
274
- # output buffer is committed when trigger_completion_at_end is
275
- # False, so the next PDL-launched kernel can read the uninitialized
276
- # Lamport buffer and produce NaN. This only fires for
277
- # num_tokens <= PDL_ADVANCE_LAUNCH_TOKENS (the batch=1 / spec-decode
278
- # shapes, where the one-shot path is always selected). Complete at
279
- # the end for the one-shot path; the two-shot path is synchronized
280
- # and keeps the early completion. Related one-shot instability in
281
- # the same kernel: flashinfer-ai/flashinfer#1223.
282
- trigger_completion_at_end=(use_oneshot is True)
283
- or num_tokens > PDL_ADVANCE_LAUNCH_TOKENS,
284
- )
292
+ # vllm's one-shot size table is tuned for the trtllm backend. For
293
+ # mnnvl, flashinfer caps the workspace's one-shot region at its own
294
+ # MNNVL_ONE_SHOT_THRESHOLD (~4MB, e.g. 44 tokens at hidden 6144 bf16
295
+ # TP8); forcing use_oneshot=True past that raises ValueError instead
296
+ # of using two-shot, so every decode capture between the one-shot cap
297
+ # and the fusion threshold silently lost fusion. Let flashinfer's
298
+ # AUTO heuristic pick the strategy (it matches its own workspace
299
+ # sizing), and keep trigger_completion_at_end=True since AUTO may
300
+ # select one-shot (see the Lamport NaN note below).
301
+ mnnvl_auto = workspace.backend == "mnnvl"
302
+ if mnnvl_auto:
303
+ use_oneshot = None
304
+
305
+ try:
306
+ flashinfer_comm.allreduce_fusion(
307
+ input=allreduce_in,
308
+ workspace=workspace,
309
+ pattern=pattern_code,
310
+ launch_with_pdl=launch_with_pdl,
311
+ output=None,
312
+ residual_out=residual_out,
313
+ norm_out=norm_out,
314
+ quant_out=quant_out,
315
+ scale_out=scale_out,
316
+ residual_in=residual,
317
+ rms_gamma=rms_gamma,
318
+ rms_eps=rms_eps,
319
+ scale_factor=scale_factor,
320
+ layout_code=layout_code,
321
+ use_oneshot=use_oneshot,
322
+ fp32_acc=fp32_acc,
323
+ weight_bias=weight_bias,
324
+ # The one-shot Lamport all-reduce signals PDL completion before
325
+ # its output buffer is committed when trigger_completion_at_end
326
+ # is False, so the next PDL-launched kernel can read the
327
+ # uninitialized Lamport buffer and produce NaN. This only fires
328
+ # for num_tokens <= PDL_ADVANCE_LAUNCH_TOKENS (the batch=1 /
329
+ # spec-decode shapes, where the one-shot path is always
330
+ # selected). Complete at the end for the one-shot path; the
331
+ # two-shot path is synchronized and keeps the early completion.
332
+ # Related one-shot instability in the same kernel:
333
+ # flashinfer-ai/flashinfer#1223.
334
+ trigger_completion_at_end=True
335
+ if mnnvl_auto
336
+ else (use_oneshot or num_tokens > PDL_ADVANCE_LAUNCH_TOKENS),
337
+ )
338
+ except ValueError as e:
339
+ # The globally cached workspace can be smaller than this call's
340
+ # budget (it is created once by whichever caller runs first, and
341
+ # the mnnvl workspace's is_buffer_size_sufficient() checks trtllm
342
+ # metadata, so vllm-side accounting cannot see the real capacity).
343
+ # flashinfer raises before launching anything, so falling back
344
+ # mid-capture is safe. Observed with spec-decode draft graphs
345
+ # whose token count (max_num_reqs * (1 + num_spec)) exceeds the
346
+ # workspace sized for the fusion threshold.
347
+ assert not is_quant_pattern, (
348
+ "flashinfer fused allreduce failed and the unfused fallback "
349
+ f"does not cover quant patterns: {e}"
350
+ )
351
+ logger.warning_once(
352
+ "flashinfer fused allreduce+rmsnorm unavailable for this "
353
+ "shape (num_tokens=%d, max_token_num=%d, world_size=%d): %s. "
354
+ "Using the unfused fallback.",
355
+ num_tokens,
356
+ max_token_num,
357
+ world_size,
358
+ e,
359
+ )
360
+ unfused_fallback()
361
362
def call_trtllm_fused_allreduce_norm_fake(
363
allreduce_in: torch.Tensor,
vllm/config/speculative.py
+7
-1
@@ -330,6 +330,7 @@ class SpeculativeConfig:
330
@staticmethod
331
def hf_config_override(hf_config: PretrainedConfig) -> PretrainedConfig:
332
initial_architecture = hf_config.architectures[0]
333
+ use_sparse_mtp = hf_config.model_type == "glm_moe_dsa"
334
if hf_config.model_type in (
335
"deepseek_v3",
336
"deepseek_v32",
@@ -339,7 +340,12 @@ class SpeculativeConfig:
340
if hf_config.model_type == "deepseek_mtp":
341
n_predict = getattr(hf_config, "num_nextn_predict_layers", None)
342
hf_config.update(
342
- {"n_predict": n_predict, "architectures": ["DeepSeekMTPModel"]}
343
+ {
344
+ "n_predict": n_predict,
345
+ "architectures": [
346
+ "DeepseekV32MTPModel" if use_sparse_mtp else "DeepSeekMTPModel"
347
+ ],
348
+ }
349
)
350
if hf_config.model_type == "deepseek_v4":
351
hf_config.model_type = "deepseek_mtp"
vllm/cute_utils/__init__.py
+5
@@ -33,6 +33,11 @@ EVICT_NORMAL = Int64(0x1000000000000000)
33
EVICT_FIRST = Int64(0x12F0000000000000)
34
EVICT_LAST = Int64(0x14F0000000000000)
35
36
+TORCH_TO_CUTE_DTYPE = {
37
+ torch.bfloat16: BFloat16,
38
+ torch.float32: Float32,
39
+}
40
+
41
42
@dsl_user_op
43
def recast_val(x, dtype, *, loc=None, ip=None):
vllm/cute_utils/cvt.py
+22
-1
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from cutlass import Constexpr, Float32, Uint32, cute
3
+from cutlass import BFloat16, Constexpr, Float32, Uint16, Uint32, cute
4
from cutlass._mlir import ir
5
from cutlass._mlir.dialects import llvm, vector
6
from cutlass.cutlass_dsl import T, dsl_user_op
@@ -38,6 +38,11 @@ def bf16x2_to_fp32x2(data, *, loc=None, ip=None) -> tuple[Float32, Float32]:
38
)
39
40
elif isinstance(data, (cute.Tensor, cute.TensorSSA)):
41
+ # recast to 32-bit registers if needed
42
+ if data.element_type == BFloat16:
43
+ data = cute.recast_tensor(data, Uint32)
44
+ assert data.element_type == Uint32
45
+
46
# NOTE: the output is always 1D
47
size = cute.size(data.shape)
48
out = cute.make_rmem_tensor(size * 2, Float32)
@@ -84,6 +89,22 @@ def fp8x4_to_bf16x4(x: Uint32, *, loc=None, ip=None) -> cute.TensorSSA:
89
return cute.TensorSSA(vec, 2, Uint32)
90
91
92
+@dsl_user_op
93
+def fp32x2_to_fp8x2(a0: Float32, a1: Float32, *, loc=None, ip=None) -> Uint16:
94
+ out = llvm.inline_asm(
95
+ T.i16(),
96
+ [
97
+ a0.ir_value(loc=loc, ip=ip),
98
+ a1.ir_value(loc=loc, ip=ip),
99
+ ],
100
+ "cvt.rn.satfinite.e4m3x2.f32 $0, $2, $1;",
101
+ "=h,f,f",
102
+ has_side_effects=False,
103
+ is_align_stack=False,
104
+ )
105
+ return Uint16(out)
106
+
107
+
108
@dsl_user_op
109
def fp8x4_to_fp16x4(x: Uint32, *, loc=None, ip=None) -> cute.TensorSSA:
110
out = llvm.inline_asm(
vllm/model_executor/layers/fused_allreduce_gemma_rms_norm.py
+15
-4
@@ -43,7 +43,7 @@ try:
43
if flashinfer_comm is not None
44
else None
45
)
46
-except ImportError:
46
+except (ImportError, AttributeError):
47
flashinfer_trtllm_fused_allreduce_norm = None # type: ignore[assignment]
48
get_fi_ar_workspace = None # type: ignore[assignment]
49
_AR_RESIDUAL_RMS_NORM = None
@@ -52,12 +52,23 @@ except ImportError:
52
_FI_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16)
53
54
55
+@torch.compiler.assume_constant_result
56
+def _fi_ar_max_size_mb() -> dict[int, float]:
57
+ """Flashinfer all-reduce fusion size table for the current device.
58
+
59
+ Device capability is constant; marking the result constant keeps this out
60
+ of the traced graph. Otherwise the per-forward call reaches
61
+ ``current_platform.get_device_capability()`` inside ``torch.compile`` and
62
+ graph-breaks ("can't handle functions not implemented in python")."""
63
+ from vllm.config.compilation import PassConfig
64
+
65
+ return PassConfig.default_fi_allreduce_fusion_max_size_mb()
66
+
67
+
68
def _max_token_num(tp_size: int, hidden_size: int, dtype: torch.dtype) -> int | None:
69
"""Workspace token budget for flashinfer fused all-reduce, or None if the
70
current world size / device is unsupported. Mirrors ``FlashInferAllReduce``."""
58
- from vllm.config.compilation import PassConfig
59
-
60
- max_size_mb = PassConfig.default_fi_allreduce_fusion_max_size_mb().get(tp_size)
71
+ max_size_mb = _fi_ar_max_size_mb().get(tp_size)
72
if not max_size_mb:
73
return None
74
element_size = torch.tensor([], dtype=dtype).element_size()
vllm/model_executor/models/registry.py
+2
-1
@@ -113,7 +113,7 @@ _TEXT_GENERATION_MODELS = {
113
"Glm4ForCausalLM": ("glm4", "Glm4ForCausalLM"),
114
"Glm4MoeForCausalLM": ("glm4_moe", "Glm4MoeForCausalLM"),
115
"Glm4MoeLiteForCausalLM": ("glm4_moe_lite", "Glm4MoeLiteForCausalLM"),
116
- "GlmMoeDsaForCausalLM": ("deepseek_v2", "GlmMoeDsaForCausalLM"),
116
+ "GlmMoeDsaForCausalLM": ("vllm.models.deepseek_v32", "GlmMoeDsaForCausalLM"),
117
"GptOssForCausalLM": ("gpt_oss", "GptOssForCausalLM"),
118
"GPT2LMHeadModel": ("gpt2", "GPT2LMHeadModel"),
119
"GPTJForCausalLM": ("gpt_j", "GPTJForCausalLM"),
@@ -628,6 +628,7 @@ _SPECULATIVE_DECODING_MODELS = {
628
"Eagle3DeepseekV3ForCausalLM": ("deepseek_eagle3", "Eagle3DeepseekV2ForCausalLM"),
629
"EagleDeepSeekMTPModel": ("deepseek_eagle", "EagleDeepseekV3ForCausalLM"),
630
"DeepSeekMTPModel": ("deepseek_mtp", "DeepSeekMTP"),
631
+ "DeepseekV32MTPModel": ("vllm.models.deepseek_v32", "DeepseekV32MTP"),
632
"DeepSeekV4MTPModel": ("vllm.models.deepseek_v4", "DeepSeekV4MTP"),
633
"MiniMaxM3MTP": ("vllm.models.minimax_m3", "MiniMaxM3MTP"),
634
"BailingMoeV25MTPModel": ("bailing_moe_mtp", "BailingMoeV25MTPModel"),
vllm/models/deepseek_v32/__init__.py
+18
-4
@@ -6,17 +6,31 @@ DeepSeek V3.2 introduced the DeepSeek Sparse Attention (DSA) architecture:
6
MLA + a "lightning indexer" that selects the top-k tokens for a sparse MLA
7
attend. The same model code serves any DSA checkpoint, including GLM-5.2
8
(``glm_moe_dsa``), which reuses this architecture.
9
+
10
+The optimized kernels under ``nvidia/`` target the Blackwell (SM100) family.
11
+Every other platform — ROCm, XPU, pre-SM100 CUDA (e.g. H100), CPU — falls back
12
+to the generic ``deepseek_v2`` implementation, which already handles the DSA
13
+(index_topk) architecture and is ``torch.compile``-friendly there. This matches
14
+main's behavior on those platforms (no hard failure).
15
"""
16
17
from vllm.platforms import current_platform
18
13
-if current_platform.is_rocm() or current_platform.is_xpu():
14
- raise NotImplementedError("deepseek_v32 currently supports NVIDIA SM100 only.")
19
+if current_platform.is_cuda() and current_platform.is_device_capability_family(100):
20
+ from .nvidia.model import DeepseekV32ForCausalLM
21
+ from .nvidia.mtp import DeepseekV32MTP
22
16
-from .nvidia.model import DeepseekV32ForCausalLM
17
-from .nvidia.mtp import DeepseekV32MTP
23
+ # GLM-5.2 (glm_moe_dsa) reuses the same optimized DSA module on SM100.
24
+ GlmMoeDsaForCausalLM = DeepseekV32ForCausalLM
25
+else:
26
+ from vllm.model_executor.models.deepseek_mtp import DeepSeekMTP as DeepseekV32MTP
27
+ from vllm.model_executor.models.deepseek_v2 import (
28
+ DeepseekV3ForCausalLM as DeepseekV32ForCausalLM,
29
+ )
30
+ from vllm.model_executor.models.deepseek_v2 import GlmMoeDsaForCausalLM
31
32
__all__ = [
33
"DeepseekV32ForCausalLM",
34
"DeepseekV32MTP",
35
+ "GlmMoeDsaForCausalLM",
36
]
vllm/models/deepseek_v32/nvidia/attention.py
+72
-12
@@ -4,6 +4,7 @@ import torch
4
import torch.nn as nn
5
from transformers import DeepseekV2Config, DeepseekV3Config
6
7
+import vllm._custom_ops as ops
8
from vllm.compilation.breakable_cudagraph import eager_break_during_capture
9
from vllm.config import CacheConfig, VllmConfig
10
from vllm.distributed import get_tensor_model_parallel_world_size
@@ -21,17 +22,15 @@ from vllm.model_executor.layers.quantization.utils.fp8_utils import (
22
per_token_group_quant_fp8,
23
)
24
from vllm.model_executor.layers.rotary_embedding import get_rope
24
-from vllm.model_executor.layers.sparse_attn_indexer import (
25
- SparseAttnIndexer,
26
- sparse_attn_indexer,
27
-)
25
+from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
26
from vllm.model_executor.models.deepseek_v2 import (
27
DeepSeekV2FusedQkvAProjLinear,
28
DeepseekV32IndexerCache,
29
yarn_get_mscale,
30
)
31
from vllm.model_executor.models.utils import extract_layer_index
34
-from vllm.utils.torch_utils import is_quantized_kv_cache
32
+from vllm.platforms import current_platform
33
+from vllm.utils.torch_utils import _encode_layer_name, is_quantized_kv_cache
34
35
from .kernels import fused_norm_rope, fused_q
36
@@ -171,6 +170,11 @@ class DeepseekV32Attention(MLAAttention):
170
) -> None:
171
quant_config = vllm_config.quant_config
172
cache_config = vllm_config.cache_config
173
+ if cache_config is not None and cache_config.cache_dtype == "auto":
174
+ # This implementation requires an FP8 sparse cache. Start with the
175
+ # generic FP8 format; MLAAttention will canonicalize it for the
176
+ # selected sparse backend.
177
+ cache_config.cache_dtype = "fp8"
178
179
hidden_size = config.hidden_size
180
qk_nope_head_dim = config.qk_nope_head_dim
@@ -331,6 +335,37 @@ class DeepseekV32Attention(MLAAttention):
335
rope_parameters=config.rope_parameters,
336
is_neox_style=False,
337
)
338
+ # Decode-M GEMM dispatch for the bf16 A/B projections, measured on
339
+ # B300 and B200 (graph replay + rotating weights vs fp64):
340
+ # bf16_skinny_gemm for M <= skinny_max, dsv3_fused_a_gemm up to
341
+ # M = 16, cuBLAS above. Blackwell-only: the boundaries and the
342
+ # fused_a tile choices are tied to the 148/152-SM single-wave
343
+ # geometry. DSv3.2 q_b has no skinny tier (K=1536 does not fit the
344
+ # GEMV's 128x8 K-step; fused_a still wins 1.6-1.8x there).
345
+ # Quantized linear methods may not register .weight until
346
+ # process_weights_after_loading (e.g. compressed-tensors NVFP4
347
+ # registers weight_packed at init) — probe with getattr.
348
+ # (N, K) -> skinny_max
349
+ qkv_a_dispatch = {(2624, 6144): 2, (2112, 7168): 2}
350
+ q_b_dispatch = {(2048, 2048): 2, (3072, 1536): 0}
351
+ ok = (
352
+ current_platform.is_device_capability_family(100)
353
+ and hasattr(torch.ops._C, "bf16_skinny_gemm")
354
+ and hasattr(torch.ops._C, "dsv3_fused_a_gemm")
355
+ )
356
+ qkv_a_weight = getattr(self.fused_qkv_a_proj, "weight", None)
357
+ q_b_weight = getattr(self.q_b_proj, "weight", None)
358
+ self._qkv_a_skinny_max = (
359
+ qkv_a_dispatch.get(tuple(qkv_a_weight.shape))
360
+ if ok and qkv_a_weight is not None and qkv_a_weight.dtype == torch.bfloat16
361
+ else None
362
+ )
363
+ self._q_b_skinny_max = (
364
+ q_b_dispatch.get(tuple(q_b_weight.shape))
365
+ if ok and q_b_weight is not None and q_b_weight.dtype == torch.bfloat16
366
+ else None
367
+ )
368
+
369
# Lightning indexer uses its own RoPE; interleave maps to non-NeoX.
370
self.indexer_rope_emb = get_rope(
371
qk_rope_head_dim,
@@ -339,13 +374,32 @@ class DeepseekV32Attention(MLAAttention):
374
is_neox_style=not getattr(config, "indexer_rope_interleave", False),
375
)
376
377
+ def _decode_m_gemm(
378
+ self, x: torch.Tensor, weight: torch.Tensor, skinny_max: int
379
+ ) -> torch.Tensor:
380
+ """x @ weight.T for decode M <= 16."""
381
+ if x.shape[0] == 0:
382
+ return torch.empty((0, weight.shape[0]), dtype=x.dtype, device=x.device)
383
+ if x.shape[0] <= skinny_max:
384
+ return ops.bf16_skinny_gemm(x, weight)
385
+ out = torch.empty((x.shape[0], weight.shape[0]), dtype=x.dtype, device=x.device)
386
+ ops.dsv3_fused_a_gemm(out, x, weight.t())
387
+ return out
388
+
389
def forward( # type: ignore[override]
390
self,
391
positions: torch.Tensor,
392
hidden_states: torch.Tensor,
393
) -> torch.Tensor:
394
# Captured: A-projections (+ indexer A-GEMM on indexer layers).
348
- qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
395
+ if self._qkv_a_skinny_max is not None and hidden_states.shape[0] <= 16:
396
+ qkv_lora = self._decode_m_gemm(
397
+ hidden_states,
398
+ self.fused_qkv_a_proj.weight,
399
+ self._qkv_a_skinny_max,
400
+ )
401
+ else:
402
+ qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
403
q_c, kv_c, k_pe = qkv_lora.split(
404
[self.q_lora_rank, self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
405
)
@@ -398,7 +452,9 @@ class DeepseekV32Attention(MLAAttention):
452
assert isinstance(slot_mapping, dict)
453
mla_slot = slot_mapping.get(self.layer_name)
454
401
- if self.indexer is not None:
455
+ # skip_topk (index_share_for_mtp_iteration reuse mode): don't run the
456
+ # indexer this step; keep the top-k already in the shared buffer.
457
+ if self.indexer is not None and not self.skip_topk:
458
has_indexer = True
459
indexer_k_norm_w = self.indexer.k_norm.weight
460
indexer_k_norm_bias = self.indexer.k_norm.bias
@@ -451,12 +507,16 @@ class DeepseekV32Attention(MLAAttention):
507
index_rope_interleave=self._index_rope_interleave,
508
)
509
454
- q = self.q_b_proj(q_c)[0].view(-1, self.num_local_heads, self.qk_head_dim)
510
+ if self._q_b_skinny_max is not None and q_c.shape[0] <= 16:
511
+ q = self._decode_m_gemm(q_c, self.q_b_proj.weight, self._q_b_skinny_max)
512
+ else:
513
+ q = self.q_b_proj(q_c)[0]
514
+ q = q.view(-1, self.num_local_heads, self.qk_head_dim)
515
q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
516
q_nope = q_nope.transpose(0, 1)
517
ql_nope = torch.bmm(q_nope, self.W_UK_T).transpose(0, 1)
518
459
- if self.indexer is not None:
519
+ if self.indexer is not None and not self.skip_topk:
520
index_q = self.indexer.wq_b(q_c)[0]
521
index_q = index_q.view(-1, self.indexer.n_head, self.indexer.head_dim)
522
else:
@@ -478,10 +538,10 @@ class DeepseekV32Attention(MLAAttention):
538
quantize_mqa=self._fp8_query,
539
)
540
481
- if self.indexer is not None:
482
- sparse_attn_indexer(
541
+ if self.indexer is not None and not self.skip_topk:
542
+ torch.ops.vllm.sparse_attn_indexer(
543
q_c,
484
- self.indexer.k_cache.prefix,
544
+ _encode_layer_name(self.indexer.k_cache.prefix),
545
self.indexer.k_cache.kv_cache,
546
index_q_fp8,
547
None, # q_scale folded into weights on the fp8 path
vllm/models/deepseek_v32/nvidia/kernels.py
+284
-16
@@ -2,13 +2,80 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import torch
4
5
+from vllm.platforms import current_platform
6
from vllm.triton_utils import tl, triton
7
+from vllm.utils.import_utils import has_cutedsl
8
+from vllm.utils.torch_utils import direct_register_custom_op
9
10
# Cache of tiny 1-element dummy tensors (per device, dtype) reused by the
11
# has_indexer=False path so the indexer args don't allocate every call.
12
_DUMMY_CACHE: dict[tuple, torch.Tensor] = {}
13
14
15
+@torch.compiler.assume_constant_result
16
+def _can_use_fused_q_cutedsl() -> bool:
17
+ return current_platform.has_device_capability(100) and has_cutedsl()
18
+
19
+
20
+@torch.compiler.assume_constant_result
21
+def _is_arch_support_pdl() -> bool:
22
+ return (
23
+ current_platform.has_device_capability(100)
24
+ and current_platform.is_arch_support_pdl()
25
+ )
26
+
27
+
28
+def _fused_q_cutedsl_impl(
29
+ positions: torch.Tensor,
30
+ q_pe: torch.Tensor,
31
+ rope_cache: torch.Tensor,
32
+ ql_nope: torch.Tensor,
33
+ q_scale: torch.Tensor,
34
+ mqa_output: torch.Tensor,
35
+ idx_q: torch.Tensor,
36
+ idx_rope_cache: torch.Tensor,
37
+ idx_weights: torch.Tensor,
38
+ idx_weights_softmax_scale: float,
39
+ idx_weights_head_scale: float,
40
+ idx_q_fp8: torch.Tensor,
41
+ idx_weights_out: torch.Tensor,
42
+ has_indexer: bool,
43
+ index_rope_interleave: bool,
44
+) -> None:
45
+ from .ops.fused_q_cutedsl import fused_q_cutedsl
46
+
47
+ fused_q_cutedsl(
48
+ positions,
49
+ q_pe,
50
+ rope_cache,
51
+ ql_nope,
52
+ q_scale,
53
+ mqa_output,
54
+ idx_q,
55
+ idx_rope_cache,
56
+ idx_weights,
57
+ idx_weights_softmax_scale,
58
+ idx_weights_head_scale,
59
+ idx_q_fp8,
60
+ idx_weights_out,
61
+ has_indexer=has_indexer,
62
+ index_rope_interleave=index_rope_interleave,
63
+ )
64
+
65
+
66
+def _fused_q_cutedsl_fake(*args, **kwargs) -> None:
67
+ pass
68
+
69
+
70
+direct_register_custom_op(
71
+ op_name="fused_q_cutedsl",
72
+ op_func=_fused_q_cutedsl_impl,
73
+ mutates_args=["mqa_output", "idx_q_fp8", "idx_weights_out"],
74
+ fake_impl=_fused_q_cutedsl_fake,
75
+ dispatch_key="CUDA",
76
+)
77
+
78
+
79
def _dummy(shape: tuple, dtype: torch.dtype, device: torch.device) -> torch.Tensor:
80
key = (shape, dtype, device)
81
t = _DUMMY_CACHE.get(key)
@@ -147,9 +214,14 @@ def _fused_norm_rope_kernel(
214
TOPK_BLOCK_SIZE: tl.constexpr,
215
HAS_INDEXER: tl.constexpr,
216
INDEX_ROPE_INTERLEAVE: tl.constexpr,
217
+ USE_PDL: tl.constexpr,
218
):
219
pid = tl.program_id(0)
220
tok_idx = tl.program_id(1)
221
+ if USE_PDL:
222
+ tl.extra.cuda.gdc_wait()
223
+ tl.extra.cuda.gdc_launch_dependents()
224
+
225
if pid == 3:
226
if not HAS_INDEXER:
227
# Shared layer: reuse the previous indexer layer's top-k; do not
@@ -166,6 +238,17 @@ def _fused_norm_rope_kernel(
238
)
239
return
240
241
+ if pid == 2:
242
+ # Q RMS norm does not depend on cache availability. In particular,
243
+ # profiling uses a negative dummy slot mapping but still consumes Q.
244
+ q_block = tl.arange(0, Q_BLOCK_SIZE)
245
+ q_mask = q_block < Q_DIM
246
+ q_c = tl.load(q_c_ptr + tok_idx * q_c_stride + q_block, mask=q_mask, other=0.0)
247
+ q_c_rms_w = tl.load(q_rms_norm_w_ptr + q_block, mask=q_mask)
248
+ q_c = _rms_norm(q_c, q_c_rms_w, q_rms_eps, Q_DIM)
249
+ tl.store(q_c_out_ptr + tok_idx * q_c_out_stride + q_block, q_c, mask=q_mask)
250
+ return
251
+
252
if slot_mapping_ptr is None:
253
# Memory profiling run.
254
return
@@ -174,15 +257,7 @@ def _fused_norm_rope_kernel(
257
# Padding
258
return
259
177
- if pid == 2:
178
- # Q RMS norm
179
- q_block = tl.arange(0, Q_BLOCK_SIZE)
180
- q_mask = q_block < Q_DIM
181
- q_c = tl.load(q_c_ptr + tok_idx * q_c_stride + q_block, mask=q_mask, other=0.0)
182
- q_c_rms_w = tl.load(q_rms_norm_w_ptr + q_block, mask=q_mask)
183
- q_c = _rms_norm(q_c, q_c_rms_w, q_rms_eps, Q_DIM)
184
- tl.store(q_c_out_ptr + tok_idx * q_c_out_stride + q_block, q_c, mask=q_mask)
185
- elif pid == 1:
260
+ if pid == 1:
261
# KV RMS Norm + KV RoPE + MLA concat_and_cache.
262
# Merged so the normed kv_c and RoPE'd k_pe can be written
263
# to the MLA KV cache directly without a separate kernel.
@@ -211,9 +286,6 @@ def _fused_norm_rope_kernel(
286
r2 = x2 * cos + x1 * sin
287
288
# MLA concat_and_cache: write [kv_c_normed, k_pe_roped] to cache.
214
- if mla_cache_entry_stride == 0:
215
- return
216
-
289
mla_block_size = mla_cache_block_stride // mla_cache_entry_stride
290
mla_block_idx = slot_idx // mla_block_size
291
mla_block_off = slot_idx % mla_block_size
@@ -363,7 +435,7 @@ def _fused_norm_rope_kernel(
435
)
436
437
366
-def fused_norm_rope(
438
+def _fused_norm_rope_impl(
439
positions: torch.Tensor,
440
q_c: torch.Tensor,
441
q_rms_norm_w: torch.Tensor,
@@ -465,12 +537,17 @@ def fused_norm_rope(
537
# Dummy values — pid 2 will skip the MLA cache write because
538
# slot_mapping is all -1.
539
mla_kv_cache = torch.empty(0, dtype=torch.bfloat16, device=device)
468
- mla_block_stride = 0
469
- mla_entry_stride = 0
540
+ # Torch's Triton HOP mutation analysis compiles every program-id
541
+ # branch even though the dummy slot mapping returns before cache
542
+ # access. Keep the unused strides nonzero so that analysis can lower
543
+ # the block-size calculation safely.
544
+ mla_block_stride = 1
545
+ mla_entry_stride = 1
546
mla_k_scale = torch.ones(1, dtype=torch.float32, device=device)
547
548
if q_c_out is None:
549
q_c_out = torch.empty_like(q_c)
550
+ use_pdl = _is_arch_support_pdl()
551
_fused_norm_rope_kernel[(4, num_tokens)](
552
positions,
553
# Q RMS norm
@@ -529,6 +606,157 @@ def fused_norm_rope(
606
TOPK_BLOCK_SIZE=1024,
607
HAS_INDEXER=has_indexer,
608
INDEX_ROPE_INTERLEAVE=index_rope_interleave,
609
+ USE_PDL=use_pdl,
610
+ launch_pdl=use_pdl,
611
+ )
612
+ return q_c_out
613
+
614
+
615
+def _fused_norm_rope_op(
616
+ positions: torch.Tensor,
617
+ q_c: torch.Tensor,
618
+ q_rms_norm_w: torch.Tensor,
619
+ q_rms_eps: float,
620
+ kv_c: torch.Tensor,
621
+ kv_rms_norm_w: torch.Tensor,
622
+ kv_rms_eps: float,
623
+ k_pe: torch.Tensor,
624
+ k_rope_cos_sin_cache: torch.Tensor,
625
+ index_k: torch.Tensor | None,
626
+ index_k_layer_norm_w: torch.Tensor | None,
627
+ index_k_layer_norm_bias: torch.Tensor | None,
628
+ index_k_layer_norm_eps: float,
629
+ index_k_rope_cos_sin_cache: torch.Tensor | None,
630
+ topk_indices_buffer: torch.Tensor,
631
+ slot_mapping: torch.Tensor | None,
632
+ indexer_k_cache: torch.Tensor | None,
633
+ mla_kv_cache: torch.Tensor | None,
634
+ mla_kv_cache_dtype: str,
635
+ mla_k_scale: torch.Tensor | None,
636
+ has_indexer: bool,
637
+ index_rope_interleave: bool,
638
+ q_c_out: torch.Tensor,
639
+) -> None:
640
+ _fused_norm_rope_impl(
641
+ positions,
642
+ q_c,
643
+ q_rms_norm_w,
644
+ q_rms_eps,
645
+ kv_c,
646
+ kv_rms_norm_w,
647
+ kv_rms_eps,
648
+ k_pe,
649
+ k_rope_cos_sin_cache,
650
+ index_k,
651
+ index_k_layer_norm_w,
652
+ index_k_layer_norm_bias,
653
+ index_k_layer_norm_eps,
654
+ index_k_rope_cos_sin_cache,
655
+ topk_indices_buffer,
656
+ slot_mapping=slot_mapping,
657
+ indexer_k_cache=indexer_k_cache,
658
+ mla_kv_cache=mla_kv_cache,
659
+ mla_kv_cache_dtype=mla_kv_cache_dtype,
660
+ mla_k_scale=mla_k_scale,
661
+ has_indexer=has_indexer,
662
+ index_rope_interleave=index_rope_interleave,
663
+ q_c_out=q_c_out,
664
+ )
665
+
666
+
667
+def _fused_norm_rope_fake(
668
+ positions: torch.Tensor,
669
+ q_c: torch.Tensor,
670
+ q_rms_norm_w: torch.Tensor,
671
+ q_rms_eps: float,
672
+ kv_c: torch.Tensor,
673
+ kv_rms_norm_w: torch.Tensor,
674
+ kv_rms_eps: float,
675
+ k_pe: torch.Tensor,
676
+ k_rope_cos_sin_cache: torch.Tensor,
677
+ index_k: torch.Tensor | None,
678
+ index_k_layer_norm_w: torch.Tensor | None,
679
+ index_k_layer_norm_bias: torch.Tensor | None,
680
+ index_k_layer_norm_eps: float,
681
+ index_k_rope_cos_sin_cache: torch.Tensor | None,
682
+ topk_indices_buffer: torch.Tensor,
683
+ slot_mapping: torch.Tensor | None,
684
+ indexer_k_cache: torch.Tensor | None,
685
+ mla_kv_cache: torch.Tensor | None,
686
+ mla_kv_cache_dtype: str,
687
+ mla_k_scale: torch.Tensor | None,
688
+ has_indexer: bool,
689
+ index_rope_interleave: bool,
690
+ q_c_out: torch.Tensor,
691
+) -> None:
692
+ return
693
+
694
+
695
+direct_register_custom_op(
696
+ op_name="fused_norm_rope_deepseek_v32",
697
+ op_func=_fused_norm_rope_op,
698
+ mutates_args=[
699
+ "topk_indices_buffer",
700
+ "indexer_k_cache",
701
+ "mla_kv_cache",
702
+ "q_c_out",
703
+ ],
704
+ fake_impl=_fused_norm_rope_fake,
705
+ dispatch_key="CUDA",
706
+)
707
+
708
+
709
+def fused_norm_rope(
710
+ positions: torch.Tensor,
711
+ q_c: torch.Tensor,
712
+ q_rms_norm_w: torch.Tensor,
713
+ q_rms_eps: float,
714
+ kv_c: torch.Tensor,
715
+ kv_rms_norm_w: torch.Tensor,
716
+ kv_rms_eps: float,
717
+ k_pe: torch.Tensor,
718
+ k_rope_cos_sin_cache: torch.Tensor,
719
+ index_k: torch.Tensor | None,
720
+ index_k_layer_norm_w: torch.Tensor | None,
721
+ index_k_layer_norm_bias: torch.Tensor | None,
722
+ index_k_layer_norm_eps: float,
723
+ index_k_rope_cos_sin_cache: torch.Tensor | None,
724
+ topk_indices_buffer: torch.Tensor,
725
+ slot_mapping: torch.Tensor | None = None,
726
+ indexer_k_cache: torch.Tensor | None = None,
727
+ mla_kv_cache: torch.Tensor | None = None,
728
+ mla_kv_cache_dtype: str = "auto",
729
+ mla_k_scale: torch.Tensor | None = None,
730
+ has_indexer: bool = True,
731
+ index_rope_interleave: bool = False,
732
+ q_c_out: torch.Tensor | None = None,
733
+) -> torch.Tensor:
734
+ if q_c_out is None:
735
+ q_c_out = torch.empty_like(q_c)
736
+ torch.ops.vllm.fused_norm_rope_deepseek_v32(
737
+ positions,
738
+ q_c,
739
+ q_rms_norm_w,
740
+ q_rms_eps,
741
+ kv_c,
742
+ kv_rms_norm_w,
743
+ kv_rms_eps,
744
+ k_pe,
745
+ k_rope_cos_sin_cache,
746
+ index_k,
747
+ index_k_layer_norm_w,
748
+ index_k_layer_norm_bias,
749
+ index_k_layer_norm_eps,
750
+ index_k_rope_cos_sin_cache,
751
+ topk_indices_buffer,
752
+ slot_mapping,
753
+ indexer_k_cache,
754
+ mla_kv_cache,
755
+ mla_kv_cache_dtype,
756
+ mla_k_scale,
757
+ has_indexer,
758
+ index_rope_interleave,
759
+ q_c_out,
760
)
761
return q_c_out
762
@@ -582,10 +810,14 @@ def _fused_q_kernel(
810
HAS_INDEXER: tl.constexpr,
811
INDEX_ROPE_INTERLEAVE: tl.constexpr,
812
QUANTIZE_MQA: tl.constexpr,
813
+ USE_PDL: tl.constexpr,
814
):
815
pid = tl.program_id(0)
816
tok_idx = tl.program_id(1)
817
head_idx = tl.program_id(2)
818
+ if USE_PDL:
819
+ tl.extra.cuda.gdc_wait()
820
+ tl.extra.cuda.gdc_launch_dependents()
821
822
if pid == 2:
823
# ql_nope quantize + pack into the front of mqa_q_fp8. On the bf16
@@ -773,10 +1005,12 @@ def fused_q(
1005
``(ql_nope, q_pe)`` tuple the backend expects.
1006
"""
1007
assert positions.ndim == 1
1008
+ assert positions.dtype == torch.int64
1009
assert q_pe.ndim == 3
1010
assert q_pe_cos_sin_cache.ndim == 2
1011
assert ql_nope.ndim == 3
1012
assert ql_nope.shape[:2] == q_pe.shape[:2]
1013
+ assert q_scale.dtype == torch.float32 and q_scale.numel() == 1
1014
1015
num_tokens = positions.shape[0]
1016
num_q_heads = q_pe.shape[1]
@@ -794,7 +1028,18 @@ def fused_q(
1028
assert index_weights is not None
1029
num_index_q_heads = index_q.shape[1]
1030
index_q_head_dim = index_q.shape[2]
797
- grid_heads = max(mqa_grid_heads, num_index_q_heads)
1031
+ use_cutedsl = False
1032
+ if _can_use_fused_q_cutedsl():
1033
+ from .ops.fused_q_cutedsl import is_fused_q_cutedsl_supported
1034
+
1035
+ use_cutedsl = is_fused_q_cutedsl_supported(
1036
+ q_pe,
1037
+ index_q,
1038
+ ql_nope,
1039
+ has_indexer=has_indexer,
1040
+ quantize_mqa=quantize_mqa,
1041
+ )
1042
+ grid_heads = max(mqa_grid_heads, num_index_q_heads if has_indexer else 1)
1043
if quantize_mqa:
1044
# fp8 path: pack [ql_nope; q_pe] into a single fp8 tensor.
1045
mqa_q_fp8 = torch.empty(
@@ -815,6 +1060,27 @@ def fused_q(
1060
1061
index_q_fp8 = torch.empty_like(index_q, dtype=torch.float8_e4m3fn)
1062
index_weights_out = torch.empty_like(index_weights, dtype=torch.float32)
1063
+ if use_cutedsl:
1064
+ torch.ops.vllm.fused_q_cutedsl(
1065
+ positions,
1066
+ q_pe,
1067
+ q_pe_cos_sin_cache,
1068
+ ql_nope,
1069
+ q_scale,
1070
+ mqa_q,
1071
+ index_q,
1072
+ index_q_cos_sin_cache,
1073
+ index_weights,
1074
+ index_weights_softmax_scale,
1075
+ index_weights_head_scale,
1076
+ index_q_fp8,
1077
+ index_weights_out,
1078
+ has_indexer=has_indexer,
1079
+ index_rope_interleave=index_rope_interleave,
1080
+ )
1081
+ return index_q_fp8, index_weights_out, mqa_q
1082
+
1083
+ use_pdl = _is_arch_support_pdl()
1084
_fused_q_kernel[(3, num_tokens, grid_heads)](
1085
positions,
1086
q_pe,
@@ -856,6 +1122,8 @@ def fused_q(
1122
HAS_INDEXER=has_indexer,
1123
INDEX_ROPE_INTERLEAVE=index_rope_interleave,
1124
QUANTIZE_MQA=quantize_mqa,
1125
+ USE_PDL=use_pdl,
1126
+ launch_pdl=use_pdl,
1127
# num_warps=1 is optimal here: each program is a single 128-element
1128
# rope+quant, so the kernel is program-count/occupancy bound, not
1129
# per-program compute bound (swept 1/2/4/8 — 1 wins or ties everywhere).
vllm/models/deepseek_v32/nvidia/model.py
+4
@@ -6,6 +6,7 @@ from itertools import islice
6
7
import torch
8
9
+from vllm.compilation.decorators import support_torch_compile
10
from vllm.config import VllmConfig
11
from vllm.distributed import (
12
get_pp_group,
@@ -39,6 +40,7 @@ from vllm.model_executor.models.utils import (
40
sequence_parallel_chunk,
41
)
42
from vllm.sequence import IntermediateTensors
43
+from vllm.v1.attention.backends.mla.sparse_utils import register_phys_shadow
44
45
from .attention import DeepseekV32Attention
46
from .fused_ops import fused_allreduce_rms_norm
@@ -177,6 +179,7 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
179
return hidden_states, residual
180
181
182
+@support_torch_compile
183
class DeepseekV32Model(torch.nn.Module):
184
fall_back_to_pt_during_load = False
185
@@ -200,6 +203,7 @@ class DeepseekV32Model(torch.nn.Module):
203
dtype=torch.int32,
204
device=self.device,
205
)
206
+ register_phys_shadow(topk_indices_buffer)
207
208
if get_pp_group().is_first_rank:
209
self.embed_tokens = VocabParallelEmbedding(
vllm/models/deepseek_v32/nvidia/mtp.py
+76
-6
@@ -6,9 +6,9 @@ from collections.abc import Callable, Iterable
6
import torch
7
import torch.nn as nn
8
9
+import vllm._custom_ops as ops
10
from vllm._aiter_ops import rocm_aiter_ops
11
from vllm.config import VllmConfig
11
-from vllm.distributed import tensor_model_parallel_all_reduce
12
from vllm.model_executor.layers.fused_moe import (
13
fused_moe_make_expert_params_mapping,
14
)
@@ -40,7 +40,12 @@ from vllm.model_executor.models.utils import (
40
)
41
from vllm.platforms import current_platform
42
from vllm.sequence import IntermediateTensors
43
+from vllm.v1.attention.backends.mla.sparse_utils import (
44
+ phys_shadow,
45
+ register_phys_shadow,
46
+)
47
48
+from .fused_ops import fused_allreduce_rms_norm
49
from .kernels import fused_eh_norm
50
from .model import DeepseekV32DecoderLayer
51
@@ -56,6 +61,19 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
61
self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
62
self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
63
self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False)
64
+ # bf16 skinny GEMM for the eh_proj, B300+B200 measured: GLM-5.2
65
+ # (6144, 12288) wins at M <= 3, DSv3.2 (7168, 14336) at M <= 2;
66
+ # cuBLAS holds above (the 151/205MB weights stream at ~5.7TB/s).
67
+ eh_dispatch = {(6144, 12288): 3, (7168, 14336): 2}
68
+ eh_weight = getattr(self.eh_proj, "weight", None)
69
+ self._eh_skinny_max = (
70
+ eh_dispatch.get(tuple(eh_weight.shape), 0)
71
+ if current_platform.is_device_capability_family(100)
72
+ and hasattr(torch.ops._C, "bf16_skinny_gemm")
73
+ and eh_weight is not None
74
+ and eh_weight.dtype == torch.bfloat16
75
+ else 0
76
+ )
77
78
topk_indices_buffer = torch.empty(
79
vllm_config.scheduler_config.max_num_batched_tokens,
@@ -63,6 +81,7 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
81
dtype=torch.int32,
82
device=current_platform.device_type,
83
)
84
+ register_phys_shadow(topk_indices_buffer)
85
self.shared_head = SharedHead(
86
config=config, prefix=prefix, quant_config=quant_config
87
)
@@ -91,7 +110,13 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
110
self.hnorm.weight,
111
self.enorm.variance_epsilon,
112
)
94
- hidden_states = self.eh_proj(eh_input)
113
+ if (
114
+ eh_input.shape[0] <= self._eh_skinny_max
115
+ and eh_input.dtype == torch.bfloat16
116
+ ):
117
+ hidden_states = ops.bf16_skinny_gemm(eh_input, self.eh_proj.weight)
118
+ else:
119
+ hidden_states = self.eh_proj(eh_input)
120
hidden_states, residual = self.mtp_block(
121
positions=positions, hidden_states=hidden_states, residual=None
122
)
@@ -101,9 +126,6 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
126
positions.shape[0],
127
is_sequence_parallel=self.mtp_block.use_sequence_parallel_moe,
128
)
104
- if not self.mtp_block.use_sequence_parallel_moe:
105
- # Without sequence parallelism, the MoE output is left un-reduced.
106
- hidden_states = tensor_model_parallel_all_reduce(hidden_states)
129
# Recycle the POST-final-norm hidden into the next draft step. The
130
# residual-add is fused into the final RMSNorm so it is computed
131
# exactly once, and the result is returned for both tuple positions:
@@ -114,7 +136,14 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
136
# is understood by both the V2 speculator (isinstance-tuple check) and
137
# the legacy proposer (model_returns_tuple is True for the
138
# DeepSeekMTPModel architecture).
117
- hidden_states, _ = self.shared_head.norm(hidden_states, residual)
139
+ if self.mtp_block.use_sequence_parallel_moe:
140
+ hidden_states, _ = self.shared_head.norm(hidden_states, residual)
141
+ else:
142
+ # The MoE output is left un-reduced; fuse its all-reduce into the
143
+ # final norm, as the main model does at layer boundaries.
144
+ hidden_states, _ = fused_allreduce_rms_norm(
145
+ hidden_states, residual, self.shared_head.norm
146
+ )
147
return hidden_states, hidden_states
148
149
@@ -157,6 +186,15 @@ class DeepseekV32MultiTokenPredictor(nn.Module):
186
if self_attn is not None and hasattr(self_attn, "topk_indices_buffer"):
187
topk_indices_buffer = self_attn.topk_indices_buffer
188
topk_indices_buffer[:num_slots] = topk_indices_buffer[slot_ids]
189
+ # The physical-index shadow mirrors this buffer row-wise
190
+ # (shadow[j] == convert(logical[j])); re-arrange it the same
191
+ # way or skip_topk draft steps read the step-0 multi-token
192
+ # layout's rows for the wrong tokens/requests.
193
+ shadow = phys_shadow(topk_indices_buffer)
194
+ if shadow is not None:
195
+ phys, seq = shadow
196
+ phys[:num_slots] = phys[slot_ids]
197
+ seq[:num_slots] = seq[slot_ids]
198
199
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
200
return self.embed_tokens(input_ids)
@@ -192,6 +230,19 @@ class DeepseekV32MultiTokenPredictor(nn.Module):
230
# second RMSNorm.
231
return self.logits_processor(mtp_layer.shared_head.head, hidden_states)
232
233
+ def get_top_tokens(
234
+ self,
235
+ hidden_states: torch.Tensor,
236
+ spec_step_idx: int = 0,
237
+ ) -> torch.Tensor:
238
+ # Greedy draft token ids via vocab-parallel local argmax: skips the
239
+ # full-vocab logits all-gather (use_local_argmax_reduction).
240
+ current_step_idx = spec_step_idx % self.num_mtp_layers
241
+ mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
242
+ return self.logits_processor.get_top_tokens(
243
+ mtp_layer.shared_head.head, hidden_states
244
+ )
245
+
246
247
class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
248
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
@@ -240,6 +291,25 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
291
) -> torch.Tensor | None:
292
return self.model.compute_logits(hidden_states, spec_step_idx)
293
294
+ def get_top_tokens(
295
+ self,
296
+ hidden_states: torch.Tensor,
297
+ spec_step_idx: int | None = None,
298
+ ) -> torch.Tensor:
299
+ # The V2 speculator calls this without spec_step_idx; that is only
300
+ # correct while a single MTP layer is cycled for every draft step
301
+ # (num_nextn_predict_layers == 1, as in GLM-5.2/DSV3.2). Guard the
302
+ # assumption so a future multi-layer MTP fails loudly instead of
303
+ # silently using step 0's head for every step.
304
+ if spec_step_idx is None:
305
+ assert self.model.num_mtp_layers == 1, (
306
+ "get_top_tokens called without spec_step_idx on a "
307
+ "multi-layer MTP; thread the draft step index through "
308
+ "the speculator."
309
+ )
310
+ spec_step_idx = 0
311
+ return self.model.get_top_tokens(hidden_states, spec_step_idx)
312
+
313
def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str:
314
spec_layer_weight_names = [
315
"embed_tokens",
vllm/models/deepseek_v32/nvidia/ops/__init__.py
new
+2
@@ -0,0 +1,2 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/models/deepseek_v32/nvidia/ops/fused_q_cutedsl.py
new
+532
@@ -0,0 +1,532 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from functools import cache
4
+
5
+import cutlass
6
+import cutlass.cute as cute
7
+import torch
8
+from cuda.bindings.driver import CUstream
9
+from cutlass import BFloat16, Float8E4M3FN, Float32, Int64, Uint8, Uint16, Uint32
10
+
11
+from vllm.cute_utils import TORCH_TO_CUTE_DTYPE, cvt
12
+
13
+
14
+def _make_fake_tensor(dtype, shape, divisibility):
15
+ stride = tuple(
16
+ 1 if i == len(shape) - 1 else cute.sym_int64(divisibility=divisibility)
17
+ for i in range(len(shape))
18
+ )
19
+ return cute.runtime.make_fake_tensor(
20
+ dtype,
21
+ shape,
22
+ stride,
23
+ assumed_align=divisibility * dtype.width // 8,
24
+ )
25
+
26
+
27
+def is_fused_q_cutedsl_supported(
28
+ q_pe: torch.Tensor,
29
+ index_q: torch.Tensor | None,
30
+ ql_nope: torch.Tensor,
31
+ *,
32
+ has_indexer: bool,
33
+ quantize_mqa: bool,
34
+) -> bool:
35
+ if not (
36
+ quantize_mqa
37
+ and q_pe.dtype == ql_nope.dtype == torch.bfloat16
38
+ and q_pe.shape[-1] == 64
39
+ and ql_nope.shape[-1] == 512
40
+ ):
41
+ return False
42
+ return not has_indexer or (
43
+ index_q is not None
44
+ and index_q.dtype == torch.bfloat16
45
+ and index_q.shape[1] % 16 == 0
46
+ and index_q.shape[-1] == 128
47
+ )
48
+
49
+
50
+def fused_q_cutedsl(
51
+ positions: torch.Tensor,
52
+ q_pe: torch.Tensor,
53
+ rope_cache: torch.Tensor,
54
+ ql_nope: torch.Tensor,
55
+ q_scale: torch.Tensor,
56
+ mqa_output: torch.Tensor,
57
+ idx_q: torch.Tensor,
58
+ idx_rope_cache: torch.Tensor,
59
+ idx_weights: torch.Tensor,
60
+ idx_weights_softmax_scale: float,
61
+ idx_weights_head_scale: float,
62
+ idx_q_fp8: torch.Tensor,
63
+ idx_weights_out: torch.Tensor,
64
+ has_indexer: bool = True,
65
+ index_rope_interleave: bool = True,
66
+) -> None:
67
+ _, num_heads, rope_dim = q_pe.shape
68
+ _, _, nope_dim = ql_nope.shape
69
+ _, num_idx_heads, idx_dim = idx_q.shape
70
+
71
+ if has_indexer:
72
+ idx_rope_type = TORCH_TO_CUTE_DTYPE[idx_rope_cache.dtype]
73
+ idx_weights_type = TORCH_TO_CUTE_DTYPE[idx_weights.dtype]
74
+ else:
75
+ idx_dim = num_idx_heads = 0
76
+ idx_q = idx_rope_cache = idx_q_fp8 = None
77
+ idx_weights = idx_weights_out = None
78
+ idx_rope_type = idx_weights_type = None
79
+
80
+ rope_type = TORCH_TO_CUTE_DTYPE[rope_cache.dtype]
81
+ compiled = FusedQKernel.compile(
82
+ rope_dim,
83
+ nope_dim,
84
+ num_heads,
85
+ rope_type,
86
+ idx_dim,
87
+ num_idx_heads,
88
+ idx_rope_type,
89
+ idx_weights_type,
90
+ index_rope_interleave,
91
+ )
92
+ compiled(
93
+ positions,
94
+ q_pe,
95
+ rope_cache,
96
+ ql_nope,
97
+ q_scale.view(1),
98
+ mqa_output,
99
+ idx_q,
100
+ idx_rope_cache,
101
+ idx_weights,
102
+ idx_q_fp8,
103
+ idx_weights_out,
104
+ float(idx_weights_softmax_scale * idx_weights_head_scale),
105
+ )
106
+
107
+
108
+class FusedQKernel:
109
+ def __init__(
110
+ self,
111
+ rope_dim: int,
112
+ nope_dim: int,
113
+ num_heads: int,
114
+ idx_dim: int,
115
+ num_idx_heads: int,
116
+ index_rope_interleave: bool,
117
+ ) -> None:
118
+ assert rope_dim == 64
119
+ assert nope_dim == 512
120
+ assert idx_dim in (128, 0)
121
+
122
+ self.rope_dim = rope_dim
123
+ self.nope_dim = nope_dim
124
+ self.num_heads = num_heads
125
+ self.idx_dim = idx_dim
126
+ self.num_idx_heads = num_idx_heads
127
+ self.index_rope_interleave = index_rope_interleave
128
+
129
+ # mqa: rope_dim=64, nope_dim=512, num_heads=64/TP
130
+ # indexer: rope_dim=64, nope_dim=64, num_heads=32
131
+
132
+ self.num_warps = 4
133
+ assert num_heads % self.num_warps == 0
134
+ assert num_idx_heads % (4 * self.num_warps) == 0
135
+ self.num_ctas_per_tok = num_heads // self.num_warps
136
+ self.num_ctas_per_idx_tok = num_idx_heads // (4 * self.num_warps)
137
+
138
+ @cute.jit
139
+ def __call__(
140
+ self,
141
+ positions: cute.Tensor,
142
+ q_pe: cute.Tensor,
143
+ rope_cache: cute.Tensor,
144
+ ql_nope: cute.Tensor,
145
+ q_scale: cute.Tensor,
146
+ mqa_output: cute.Tensor,
147
+ idx_q: cute.Tensor,
148
+ idx_rope_cache: cute.Tensor,
149
+ idx_weights: cute.Tensor,
150
+ idx_q_fp8: cute.Tensor,
151
+ idx_weights_out: cute.Tensor,
152
+ weight_scale: Float32,
153
+ stream: CUstream,
154
+ ):
155
+ num_tokens = positions.shape[0]
156
+ if cutlass.const_expr(self.idx_dim == 0):
157
+ grid = (num_tokens, self.num_ctas_per_tok, 1)
158
+ else:
159
+ num_mqa_ctas = num_tokens * self.num_ctas_per_tok
160
+ num_idx_ctas = num_tokens * self.num_ctas_per_idx_tok
161
+ grid = (num_mqa_ctas + num_idx_ctas, 1, 1)
162
+
163
+ self.kernel(
164
+ positions,
165
+ q_pe,
166
+ rope_cache,
167
+ ql_nope,
168
+ q_scale,
169
+ mqa_output,
170
+ idx_q,
171
+ idx_rope_cache,
172
+ idx_weights,
173
+ idx_q_fp8,
174
+ idx_weights_out,
175
+ weight_scale,
176
+ ).launch(
177
+ grid=grid,
178
+ block=(self.num_warps * 32, 1, 1),
179
+ stream=stream,
180
+ use_pdl=True,
181
+ )
182
+
183
+ @cute.kernel
184
+ def kernel(
185
+ self,
186
+ positions: cute.Tensor,
187
+ q_pe: cute.Tensor,
188
+ q_pe_rope_cache: cute.Tensor,
189
+ ql_nope: cute.Tensor,
190
+ q_scale: cute.Tensor,
191
+ mqa_output: cute.Tensor,
192
+ idx_q: cute.Tensor,
193
+ idx_q_rope_cache: cute.Tensor,
194
+ idx_weights: cute.Tensor,
195
+ idx_q_fp8: cute.Tensor,
196
+ idx_weights_out: cute.Tensor,
197
+ weight_scale: Float32,
198
+ ):
199
+ if cutlass.const_expr(self.idx_dim == 0):
200
+ token_id, group_id, _ = cute.arch.block_idx()
201
+ self.mqa(
202
+ positions,
203
+ q_pe,
204
+ q_pe_rope_cache,
205
+ ql_nope,
206
+ q_scale,
207
+ mqa_output,
208
+ token_id,
209
+ group_id,
210
+ )
211
+ else:
212
+ # CTA-specialization
213
+ bid, _, _ = cute.arch.block_idx()
214
+ num_mqa_ctas = positions.shape[0] * self.num_ctas_per_tok
215
+ if bid < num_mqa_ctas:
216
+ self.mqa(
217
+ positions,
218
+ q_pe,
219
+ q_pe_rope_cache,
220
+ ql_nope,
221
+ q_scale,
222
+ mqa_output,
223
+ bid // self.num_ctas_per_tok,
224
+ bid % self.num_ctas_per_tok,
225
+ )
226
+ else:
227
+ bid -= num_mqa_ctas
228
+ self.indexer(
229
+ positions,
230
+ idx_q,
231
+ idx_q_rope_cache,
232
+ idx_weights,
233
+ idx_q_fp8,
234
+ idx_weights_out,
235
+ weight_scale,
236
+ bid // self.num_ctas_per_idx_tok,
237
+ bid % self.num_ctas_per_idx_tok,
238
+ )
239
+
240
+ @cute.jit
241
+ def mqa(
242
+ self,
243
+ positions: cute.Tensor,
244
+ q_pe: cute.Tensor,
245
+ q_pe_rope_cache: cute.Tensor,
246
+ ql_nope: cute.Tensor,
247
+ q_scale: cute.Tensor,
248
+ mqa_output: cute.Tensor,
249
+ token_id,
250
+ group_id,
251
+ ):
252
+ tid, _, _ = cute.arch.thread_idx()
253
+ warp_id = cute.arch.make_warp_uniform(tid // 32)
254
+ lane_id = tid % 32
255
+ head_id = group_id * self.num_warps + warp_id
256
+
257
+ cute.arch.griddepcontrol_wait()
258
+
259
+ pos = positions[token_id]
260
+ inv_scale = 1.0 / q_scale[0]
261
+
262
+ cp_op = cute.nvgpu.CopyUniversalOp()
263
+ cp_32B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=256)
264
+ cp_16B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=128)
265
+ cp_4B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=32)
266
+ cp_2B = cute.make_copy_atom(cp_op, Uint8, num_bits_per_copy=16)
267
+
268
+ ##### issue all loads asap #####
269
+ rQ_nope_bf16 = cute.make_rmem_tensor(16, BFloat16)
270
+ rQ_rope_bf16 = cute.make_rmem_tensor(2, BFloat16)
271
+
272
+ src_ql_nope = cute.local_tile(
273
+ ql_nope[token_id, head_id, None], (16,), (lane_id,)
274
+ )
275
+ src_q_rope = cute.local_tile(q_pe[token_id, head_id, None], (2,), (lane_id,))
276
+ cute.copy(cp_32B, src_ql_nope, rQ_nope_bf16)
277
+ cute.copy(cp_4B, src_q_rope, rQ_rope_bf16)
278
+
279
+ rCos_raw = q_pe_rope_cache[pos, 0 + lane_id]
280
+ rSin_raw = q_pe_rope_cache[pos, 32 + lane_id]
281
+
282
+ ##### process NoPE #####
283
+ rQ_nope_f32 = cvt.bf16x2_to_fp32x2(rQ_nope_bf16).load() * inv_scale
284
+ rQ_nope_f8 = cute.make_rmem_tensor(16, Float8E4M3FN)
285
+ rQ_nope_f8.store(rQ_nope_f32.to(Float8E4M3FN))
286
+ dst_Q_nope = cute.local_tile(
287
+ mqa_output[token_id, head_id, None], (16,), (lane_id,)
288
+ )
289
+ cute.copy(cp_16B, rQ_nope_f8, dst_Q_nope)
290
+
291
+ ##### process RoPE ######
292
+ rQ_rope_f32 = cvt.bf16x2_to_fp32x2(rQ_rope_bf16)
293
+ rCos = rCos_raw.to(Float32)
294
+ rSin = rSin_raw.to(Float32)
295
+ r0 = (rQ_rope_f32[0] * rCos - rQ_rope_f32[1] * rSin) * inv_scale
296
+ r1 = (rQ_rope_f32[1] * rCos + rQ_rope_f32[0] * rSin) * inv_scale
297
+
298
+ cute.arch.griddepcontrol_launch_dependents()
299
+
300
+ # TensorSSA fp32->fp8 cvt has a bug. rely on direct PTX
301
+ rQ_rope_f8 = cute.make_rmem_tensor(2, Float8E4M3FN)
302
+ cute.recast_tensor(rQ_rope_f8, Uint16)[0] = cvt.fp32x2_to_fp8x2(r0, r1)
303
+ dst_Q_rope = cute.local_tile(
304
+ mqa_output[token_id, head_id, None], (2,), (256 + lane_id,)
305
+ )
306
+ cute.copy(cp_2B, rQ_rope_f8, dst_Q_rope)
307
+
308
+ @cute.jit
309
+ def indexer(
310
+ self,
311
+ positions: cute.Tensor,
312
+ idx_q: cute.Tensor,
313
+ idx_q_rope_cache: cute.Tensor,
314
+ idx_weights: cute.Tensor,
315
+ idx_q_fp8: cute.Tensor,
316
+ idx_weights_out: cute.Tensor,
317
+ weight_scale: Float32,
318
+ token_id,
319
+ group_id,
320
+ ):
321
+ tid, _, _ = cute.arch.thread_idx()
322
+ subwarp_id = tid // 8
323
+ sublane_id = tid % 8
324
+ head_id = group_id * (4 * self.num_warps) + subwarp_id
325
+ cute.arch.griddepcontrol_wait()
326
+
327
+ pos = positions[token_id]
328
+
329
+ cp_op = cute.nvgpu.CopyUniversalOp()
330
+ cp_16B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=128)
331
+ cp_8B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=64)
332
+ cp_4B = cute.make_copy_atom(cp_op, Uint32, num_bits_per_copy=32)
333
+
334
+ ##### issue all loads first #####
335
+ rQ_rope_bf16 = cute.make_rmem_tensor(8, BFloat16)
336
+ if cutlass.const_expr(self.index_rope_interleave):
337
+ src_idx_q_rope = cute.local_tile(
338
+ idx_q[token_id, head_id, None], (8,), (sublane_id,)
339
+ )
340
+ cute.copy(cp_16B, src_idx_q_rope, rQ_rope_bf16)
341
+ else:
342
+ src_idx_q_rope = cute.zipped_divide(
343
+ idx_q[token_id, head_id, None], (4,)
344
+ ) # (4,32)
345
+ cute.copy(
346
+ cp_8B,
347
+ src_idx_q_rope[None, 0 + sublane_id],
348
+ cute.local_tile(rQ_rope_bf16, (4,), (0,)),
349
+ )
350
+ cute.copy(
351
+ cp_8B,
352
+ src_idx_q_rope[None, 8 + sublane_id],
353
+ cute.local_tile(rQ_rope_bf16, (4,), (1,)),
354
+ )
355
+
356
+ rQ_nope_bf16 = cute.make_rmem_tensor(8, BFloat16)
357
+ src_idx_q_nope = cute.local_tile(
358
+ idx_q[token_id, head_id, None], (8,), (8 + sublane_id,)
359
+ )
360
+ cute.copy(cp_16B, src_idx_q_nope, rQ_nope_bf16)
361
+
362
+ rCos_raw = cute.make_rmem_tensor(4, idx_q_rope_cache.element_type)
363
+ rSin_raw = cute.make_rmem_tensor(4, idx_q_rope_cache.element_type)
364
+ rope_cache_view = cute.zipped_divide(idx_q_rope_cache[pos, None], (4,))
365
+
366
+ if cutlass.const_expr(idx_q_rope_cache.element_type == Float32):
367
+ cute.copy(cp_16B, rope_cache_view[None, sublane_id], rCos_raw)
368
+ cute.copy(cp_16B, rope_cache_view[None, 8 + sublane_id], rSin_raw)
369
+ elif cutlass.const_expr(idx_q_rope_cache.element_type == BFloat16):
370
+ cute.copy(cp_8B, rope_cache_view[None, sublane_id], rCos_raw)
371
+ cute.copy(cp_8B, rope_cache_view[None, 8 + sublane_id], rSin_raw)
372
+
373
+ # unpack to FP32
374
+ rQ_rope_f32 = cvt.bf16x2_to_fp32x2(rQ_rope_bf16)
375
+ rQ_nope_f32 = cvt.bf16x2_to_fp32x2(rQ_nope_bf16)
376
+ if cutlass.const_expr(idx_q_rope_cache.element_type == Float32):
377
+ rCos = rCos_raw
378
+ rSin = rSin_raw
379
+ elif cutlass.const_expr(idx_q_rope_cache.element_type == BFloat16):
380
+ rCos = cvt.bf16x2_to_fp32x2(rCos_raw)
381
+ rSin = cvt.bf16x2_to_fp32x2(rSin_raw)
382
+
383
+ # apply rope
384
+ for i in cutlass.range_constexpr(4):
385
+ if cutlass.const_expr(self.index_rope_interleave):
386
+ r0 = rQ_rope_f32[i * 2 + 0] * rCos[i] - rQ_rope_f32[i * 2 + 1] * rSin[i]
387
+ r1 = rQ_rope_f32[i * 2 + 1] * rCos[i] + rQ_rope_f32[i * 2 + 0] * rSin[i]
388
+ rQ_rope_f32[i * 2 + 0] = r0
389
+ rQ_rope_f32[i * 2 + 1] = r1
390
+ else:
391
+ r0 = rQ_rope_f32[0 + i] * rCos[i] - rQ_rope_f32[4 + i] * rSin[i]
392
+ r1 = rQ_rope_f32[4 + i] * rCos[i] + rQ_rope_f32[0 + i] * rSin[i]
393
+ rQ_rope_f32[0 + i] = r0
394
+ rQ_rope_f32[4 + i] = r1
395
+
396
+ # amax
397
+ amax = Float32(1e-4)
398
+ for i in cutlass.range_constexpr(8):
399
+ amax = cute.arch.fmax(amax, cute.math.absf(rQ_rope_f32[i]))
400
+ amax = cute.arch.fmax(amax, cute.math.absf(rQ_nope_f32[i]))
401
+
402
+ # warp reduction among 8 lanes
403
+ for i in cutlass.range_constexpr(3):
404
+ other = cute.arch.shuffle_sync_bfly(amax, 1 << i)
405
+ amax = cute.arch.fmax(amax, other)
406
+
407
+ # compute scale from amax
408
+ # exp2(ceil(log2(scale))) via bit manipulation
409
+ scale = amax * (1.0 / 448.0)
410
+ bits = scale.bitcast(Uint32)
411
+ exp_bits = (bits + Uint32(0x007FFFFF)) & Uint32(0x7F800000)
412
+ scale = exp_bits.bitcast(Float32)
413
+ inv_scale = (Uint32(0x7F000000) - exp_bits).bitcast(Float32)
414
+
415
+ for i in cutlass.range_constexpr(8):
416
+ rQ_nope_f32[i] *= inv_scale
417
+ rQ_rope_f32[i] *= inv_scale
418
+
419
+ cute.arch.griddepcontrol_launch_dependents()
420
+
421
+ # quantize and store
422
+ rQ_nope_f8 = cute.make_rmem_tensor(8, Float8E4M3FN)
423
+ rQ_nope_f8.store(rQ_nope_f32.load().to(Float8E4M3FN))
424
+ dst_idx_q_nope = cute.local_tile(
425
+ idx_q_fp8[token_id, head_id, None], (8,), (8 + sublane_id,)
426
+ )
427
+ cute.copy(cp_8B, rQ_nope_f8, dst_idx_q_nope)
428
+
429
+ rQ_rope_f8 = cute.make_rmem_tensor(8, Float8E4M3FN)
430
+ rQ_rope_f8.store(rQ_rope_f32.load().to(Float8E4M3FN))
431
+ if cutlass.const_expr(self.index_rope_interleave):
432
+ dst_idx_q_rope = cute.local_tile(
433
+ idx_q_fp8[token_id, head_id, None], (8,), (sublane_id,)
434
+ )
435
+ cute.copy(cp_8B, rQ_rope_f8, dst_idx_q_rope)
436
+ else:
437
+ dst_idx_q_rope = cute.zipped_divide(
438
+ idx_q_fp8[token_id, head_id, None], (4,)
439
+ ) # (4,32)
440
+ cute.copy(
441
+ cp_4B,
442
+ cute.local_tile(rQ_rope_f8, (4,), (0,)),
443
+ dst_idx_q_rope[None, 0 + sublane_id],
444
+ )
445
+ cute.copy(
446
+ cp_4B,
447
+ cute.local_tile(rQ_rope_f8, (4,), (1,)),
448
+ dst_idx_q_rope[None, 8 + sublane_id],
449
+ )
450
+
451
+ # scale indexer weights
452
+ if sublane_id == 0:
453
+ w = idx_weights[token_id, head_id].to(Float32)
454
+ idx_weights_out[token_id, head_id] = w * scale * weight_scale
455
+
456
+ @cache
457
+ @staticmethod
458
+ def compile(
459
+ rope_dim: int,
460
+ nope_dim: int,
461
+ num_heads: int,
462
+ rope_type: type[cutlass.Numeric],
463
+ idx_dim: int,
464
+ num_idx_heads: int,
465
+ idx_rope_type: type[cutlass.Numeric] | None,
466
+ idx_weights_type: type[cutlass.Numeric] | None,
467
+ index_rope_interleave: bool,
468
+ ):
469
+ num_tokens = cute.sym_int()
470
+ max_pos = cute.sym_int()
471
+
472
+ positions = _make_fake_tensor(Int64, (num_tokens,), divisibility=1)
473
+ q_pe = _make_fake_tensor(
474
+ BFloat16, (num_tokens, num_heads, rope_dim), divisibility=16
475
+ )
476
+ rope_cache = _make_fake_tensor(rope_type, (max_pos, rope_dim), divisibility=8)
477
+ ql_nope = _make_fake_tensor(
478
+ BFloat16, (num_tokens, num_heads, nope_dim), divisibility=16
479
+ )
480
+ q_scale = _make_fake_tensor(Float32, (1,), divisibility=4)
481
+ mqa_output = _make_fake_tensor(
482
+ Float8E4M3FN,
483
+ (num_tokens, num_heads, nope_dim + rope_dim),
484
+ divisibility=16,
485
+ )
486
+
487
+ if idx_rope_type is not None:
488
+ index_q = _make_fake_tensor(
489
+ BFloat16, (num_tokens, num_idx_heads, idx_dim), divisibility=16
490
+ )
491
+ index_rope_cache = _make_fake_tensor(
492
+ idx_rope_type, (max_pos, rope_dim), divisibility=8
493
+ )
494
+ index_weights = _make_fake_tensor(
495
+ idx_weights_type, (num_tokens, num_idx_heads), divisibility=8
496
+ )
497
+ index_q_fp8 = _make_fake_tensor(
498
+ Float8E4M3FN, (num_tokens, num_idx_heads, idx_dim), divisibility=16
499
+ )
500
+ index_weights_out = _make_fake_tensor(
501
+ Float32, (num_tokens, num_idx_heads), divisibility=4
502
+ )
503
+ else:
504
+ index_q = index_rope_cache = index_q_fp8 = None
505
+ index_weights = index_weights_out = None
506
+
507
+ kernel = FusedQKernel(
508
+ rope_dim,
509
+ nope_dim,
510
+ num_heads,
511
+ idx_dim,
512
+ num_idx_heads,
513
+ index_rope_interleave,
514
+ )
515
+ stream = cute.runtime.make_fake_stream(use_tvm_ffi_env_stream=True)
516
+ return cute.compile(
517
+ kernel,
518
+ positions,
519
+ q_pe,
520
+ rope_cache,
521
+ ql_nope,
522
+ q_scale,
523
+ mqa_output,
524
+ index_q,
525
+ index_rope_cache,
526
+ index_weights,
527
+ index_q_fp8,
528
+ index_weights_out,
529
+ Float32(0.0),
530
+ stream,
531
+ options="--enable-tvm-ffi",
532
+ )
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
+31
@@ -28,6 +28,7 @@ from vllm.v1.attention.backend import (
28
MultipleOf,
29
)
30
from vllm.v1.attention.backends.mla.sparse_utils import (
31
+ phys_shadow,
32
triton_convert_req_index_to_global_index,
33
triton_filter_and_convert_dcp_index,
34
)
@@ -404,6 +405,36 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
405
NUM_TOPK_TOKENS=topk_indices.shape[1],
406
return_valid_counts=True,
407
)
408
+ elif (shadow := phys_shadow(self.topk_indices_buffer)) is not None:
409
+ # skip_topk layers reuse the previous fresh layer's PHYSICAL
410
+ # indices: the logical top-k indices are shared (one buffer for
411
+ # the whole forward) and block_table/req_id are per-step constant,
412
+ # so re-converting them per layer produces identical output. This
413
+ # covers both the backbone (index_topk_freq > 1) and MTP draft
414
+ # steps 1+ (index_share_for_mtp_iteration sets skip_topk; the
415
+ # draft compact re-arranges the shadow together with the logical
416
+ # buffer). The fresh/skip split is fixed per captured graph, so
417
+ # the branch is cudagraph-safe.
418
+ phys_buf, seq_buf = shadow
419
+ wrote_fresh = getattr(layer, "indexer", None) is not None and not getattr(
420
+ layer, "skip_topk", False
421
+ )
422
+ if wrote_fresh:
423
+ topk_indices_physical, seq_lens = (
424
+ triton_convert_req_index_to_global_index(
425
+ attn_metadata.req_id_per_token[:num_actual_toks],
426
+ attn_metadata.block_table,
427
+ topk_indices,
428
+ BLOCK_SIZE=attn_metadata.block_size,
429
+ NUM_TOPK_TOKENS=topk_indices.shape[1],
430
+ return_valid_counts=True,
431
+ out=phys_buf[:num_actual_toks],
432
+ valid_counts_out=seq_buf[:num_actual_toks],
433
+ )
434
+ )
435
+ else:
436
+ topk_indices_physical = phys_buf[:num_actual_toks]
437
+ seq_lens = seq_buf[:num_actual_toks]
438
else:
439
topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index(
440
attn_metadata.req_id_per_token[:num_actual_toks],
vllm/v1/attention/backends/mla/sparse_utils.py
+56
-4
@@ -128,6 +128,8 @@ def triton_convert_req_index_to_global_index(
128
prefill_workspace_request_ids: torch.Tensor | None = None,
129
prefill_workspace_starts: torch.Tensor | None = None,
130
return_valid_counts: bool = False,
131
+ out: torch.Tensor | None = None,
132
+ valid_counts_out: torch.Tensor | None = None,
133
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
134
"""
135
out[token_id, indice_id] =
@@ -178,14 +180,20 @@ def triton_convert_req_index_to_global_index(
180
req_id_c = req_id.contiguous()
181
block_table_c = block_table.contiguous()
182
token_indices_c = token_indices.contiguous()
181
- out = torch.empty_like(token_indices_c)
183
+ # `out`/`valid_counts_out` allow writing into a stable pre-allocated buffer
184
+ # (used by the shared-physical-index cache to stay cudagraph-safe).
185
+ out = torch.empty_like(token_indices_c) if out is None else out
186
187
# Allocate valid count buffer if needed (must be zero-initialized for atomics)
188
valid_counts: torch.Tensor | None = None
189
if return_valid_counts:
186
- valid_counts = torch.zeros(
187
- num_tokens, dtype=torch.int32, device=token_indices.device
188
- )
190
+ if valid_counts_out is None:
191
+ valid_counts = torch.zeros(
192
+ num_tokens, dtype=torch.int32, device=token_indices.device
193
+ )
194
+ else:
195
+ valid_counts = valid_counts_out
196
+ valid_counts.zero_()
197
198
# Strides in elements
199
bt_stride0, bt_stride1 = block_table_c.stride()
@@ -341,3 +349,47 @@ def triton_filter_and_convert_dcp_index(
349
assert valid_counts is not None
350
return out, valid_counts
351
return out
352
+
353
+
354
+# --- Physical-index shadow (DSA index_topk_freq > 1) -------------------------
355
+# GLM-5.2 uses index_topk_freq=4: only ~1/4 of layers write a fresh top-k into
356
+# the single shared topk_indices_buffer; the rest reuse it. Since block_table
357
+# and req_id_per_token are constant within a decode step, the physical indices
358
+# (block_table lookup of the logical top-k) are identical across a freq-group.
359
+# Fresh layers convert once into a STABLE shadow of the logical buffer and
360
+# skip layers read it -- eliminating ~3/4 of the per-layer convert kernels.
361
+#
362
+# Invariant: shadow[j] == convert(logical[j]) row-wise. Whoever re-arranges
363
+# the logical buffer (e.g. the MTP draft compact between the multi-token
364
+# step-0 layout and the single-token steps-1+ layout) must apply the same
365
+# gather to the shadow.
366
+#
367
+# Shadows are registered ONLY at buffer creation time (model init, never
368
+# inside cudagraph capture) via register_phys_shadow; every other access is
369
+# the read-only phys_shadow lookup, so addresses are stable across graph
370
+# replays and no allocation can happen during capture.
371
+import weakref as _weakref # noqa: E402
372
+
373
+_PHYS_SHADOWS: dict[int, tuple[torch.Tensor, torch.Tensor]] = {}
374
+
375
+
376
+def register_phys_shadow(topk_buf: torch.Tensor) -> None:
377
+ """Allocate the (physical-index, valid-count) shadow for a logical top-k
378
+ buffer. Call once where the buffer is created. The finalizer drops the
379
+ entry with the owning buffer, so a recycled id can never alias a stale
380
+ shadow."""
381
+ key = id(topk_buf)
382
+ if key not in _PHYS_SHADOWS:
383
+ _PHYS_SHADOWS[key] = (
384
+ torch.empty_like(topk_buf),
385
+ torch.empty(topk_buf.shape[0], dtype=torch.int32, device=topk_buf.device),
386
+ )
387
+ _weakref.finalize(topk_buf, _PHYS_SHADOWS.pop, key, None)
388
+
389
+
390
+def phys_shadow(
391
+ topk_buf: torch.Tensor,
392
+) -> tuple[torch.Tensor, torch.Tensor] | None:
393
+ """The registered shadow for this buffer, or None if it was never
394
+ registered. Never allocates; capture-safe."""
395
+ return _PHYS_SHADOWS.get(id(topk_buf))
vllm/v1/spec_decode/llm_base_proposer.py
+3
-2
@@ -1009,8 +1009,9 @@ class SpecDecodeBaseProposer:
1009
# DeepSeek-family MTP (deepseek_mtp.py) recycles the post-final-
1010
# norm hidden, so its forward returns (logit_hidden,
1011
# recycle_hidden). Other MTP families return a single tensor.
1012
- return "DeepSeekMTPModel" in (
1013
- self.draft_model_config.hf_config.architectures or []
1012
+ return bool(
1013
+ {"DeepSeekMTPModel", "DeepseekV32MTPModel"}
1014
+ & set(self.draft_model_config.hf_config.architectures or [])
1015
)
1016
return self.method not in ("mtp", "draft_model", "dflash")
1017
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+35
-5
@@ -48,6 +48,18 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
48
self.prefill_cudagraph_manager: SpeculatorCudaGraphManager | None = None
49
self.decode_cudagraph_manager: SpeculatorCudaGraphManager | None = None
50
51
+ # Lifecycle hooks for model-specific optimizations. Subclasses override
52
+ # the ones they need. These fire in both `capture` and `propose` so that
53
+ # any state they toggle (e.g. attention flags baked into a CUDA graph) is
54
+ # identical at capture time and replay time.
55
+ def on_prefill_begin(self, num_reqs: int, num_tokens: int) -> None: ...
56
+
57
+ def on_prefill_end(self, num_reqs: int, num_tokens: int) -> None: ...
58
+
59
+ def on_multi_step_decode_begin(self, num_reqs: int) -> None: ...
60
+
61
+ def on_multi_step_decode_end(self, num_reqs: int) -> None: ...
62
+
63
@property
64
def advance_draft_positions(self) -> bool:
65
"""
@@ -98,6 +110,9 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
110
assert self.prefill_cudagraph_manager is not None
111
if self.prefill_cudagraph_manager.use_breakable_cg:
112
self.prefill_cudagraph_manager.init_breakable_cg_runner(self.model)
113
+
114
+ self.on_prefill_begin(self.max_num_reqs, self.max_num_tokens)
115
+
116
self.prefill_cudagraph_manager.capture(
117
self._prefill,
118
self.model_state,
@@ -108,9 +123,13 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
123
progress_bar_desc="Capturing prefill CUDA graphs",
124
)
125
126
+ self.on_prefill_end(self.max_num_reqs, self.max_num_tokens)
127
+
128
if self.num_speculative_steps == 1:
129
return
130
131
+ self.on_multi_step_decode_begin(self.max_num_reqs)
132
+
133
# Capture the decode draft generation routine (model forward +
134
# sample + update_draft_inputs) for a single
135
# step.
@@ -125,6 +144,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
144
progress_bar_desc="Capturing decode CUDA graphs",
145
)
146
147
+ self.on_multi_step_decode_end(self.max_num_reqs)
148
+
149
@torch.inference_mode()
150
def propose(
151
self,
@@ -153,7 +174,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
174
mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None,
175
is_profile: bool = False,
176
) -> torch.Tensor:
156
- num_tokens = input_batch.num_tokens_after_padding
177
+ num_tokens = input_batch.num_tokens
178
+ num_tokens_padded = input_batch.num_tokens_after_padding
179
num_reqs = input_batch.num_reqs
180
max_query_len = input_batch.num_scheduled_tokens.max()
181
max_seq_len = input_batch.seq_lens_cpu_upper_bound[:num_reqs].max().item()
@@ -174,7 +196,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
196
)
197
else:
198
hidden_states = last_hidden_states
177
- self.hidden_states[:num_tokens].copy_(hidden_states)
199
+ self.hidden_states[:num_tokens_padded].copy_(hidden_states)
200
201
self._copy_request_inputs(
202
num_reqs,
@@ -202,20 +224,22 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
224
num_reqs,
225
# Use the actual number of tokens without padding added by
226
# the target model during FULL cudagraph.
205
- input_batch.num_tokens,
227
+ num_tokens,
228
max_query_len,
229
)
230
prefill_batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp(
231
self.prefill_cudagraph_manager,
232
num_reqs,
211
- num_tokens,
233
+ num_tokens_padded,
234
uniform_token_count,
235
dp_size=self.dp_size,
236
dp_rank=self.dp_rank,
237
need_eager=is_profile,
238
)
239
218
- self._prepare_eplb_forward(input_batch.num_tokens)
240
+ self._prepare_eplb_forward(num_tokens)
241
+
242
+ self.on_prefill_begin(num_reqs, num_tokens)
243
244
if prefill_batch_desc.cg_mode == CUDAGraphMode.FULL:
245
# Replay the full graph for draft prefill.
@@ -235,6 +259,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
259
mm_inputs=mm_inputs,
260
)
261
262
+ self.on_prefill_end(num_reqs, num_tokens)
263
+
264
if self.num_speculative_steps == 1:
265
# Early exit.
266
return self.draft_tokens[:num_reqs, :1]
@@ -262,6 +288,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
288
need_eager=is_profile,
289
)
290
291
+ self.on_multi_step_decode_begin(num_reqs)
292
+
293
# Generate the remaining num_speculative_steps - 1 draft tokens.
294
self._multi_step_decode(
295
num_reqs,
@@ -271,6 +299,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
299
input_batch.seq_lens_cpu_upper_bound,
300
)
301
302
+ self.on_multi_step_decode_end(num_reqs)
303
+
304
return self.draft_tokens[:num_reqs]
305
306
@torch.inference_mode()
vllm/v1/worker/gpu/spec_decode/mtp/speculator.py
+40
-1
@@ -1,8 +1,10 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import torch
5
import torch.nn as nn
6
7
+from vllm.config import VllmConfig
8
from vllm.v1.worker.gpu.spec_decode.autoregressive.speculator import (
9
AutoRegressiveSpeculator,
10
)
@@ -10,9 +12,46 @@ from vllm.v1.worker.gpu.spec_decode.eagle.utils import load_eagle_model
12
13
14
class MTPSpeculator(AutoRegressiveSpeculator):
15
+ def __init__(self, vllm_config: VllmConfig, device: torch.device):
16
+ super().__init__(vllm_config, device)
17
+
18
+ spec_config = vllm_config.speculative_config
19
+ draft_hf_config = (
20
+ spec_config.draft_model_config.hf_config
21
+ if spec_config is not None
22
+ else None
23
+ )
24
+ # Detect index_share_for_mtp_iteration. When True, the proposer
25
+ # toggles skip_topk so step 0 computes MTP's own indices and
26
+ # steps 1+ reuse them.
27
+ self.share_mtp_topk_indices = getattr(
28
+ draft_hf_config, "index_share_for_mtp_iteration", False
29
+ )
30
+
31
def load_draft_model(
32
self,
33
target_model: nn.Module,
34
target_attn_layer_names: set[str],
35
) -> nn.Module:
18
- return load_eagle_model(target_model, self.vllm_config)
36
+ draft_model = load_eagle_model(target_model, self.vllm_config)
37
+ self.share_mtp_topk_indices = self.share_mtp_topk_indices and hasattr(
38
+ draft_model.model, "set_skip_topk"
39
+ )
40
+ return draft_model
41
+
42
+ def on_prefill_end(self, num_reqs: int, num_tokens: int) -> None:
43
+ # Step 0 (prefill) wrote topk indices for every query token in the
44
+ # multi-token batch. Compact them down to each request's last token so
45
+ # steps 1+ can reuse them from the shared buffer.
46
+ if self.share_mtp_topk_indices and self.num_speculative_steps > 1:
47
+ self.model.model.compact_topk_indices(self.last_token_indices[:num_reqs])
48
+
49
+ def on_multi_step_decode_begin(self, num_reqs: int) -> None:
50
+ # Switch to reuse mode so draft steps 1+ skip the indexer op and read
51
+ # the indices that step 0 wrote into the shared buffer.
52
+ if self.share_mtp_topk_indices:
53
+ self.model.model.set_skip_topk(True)
54
+
55
+ def on_multi_step_decode_end(self, num_reqs: int) -> None:
56
+ if self.share_mtp_topk_indices:
57
+ self.model.model.set_skip_topk(False)