[Kernel] Extend CuTe DSL skinny GEMM to GLM-5.2 (#49791)

Signed-off-by: Peiyuan Zhou <peiyuanzhou1994@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>

xiaozhoupy committed Aug 4, 2026 at 07:49 UTC c8109375733e6b788e73c7cc1ed2004234834392
7 files changed +619 -51
csrc/libtorch_stable/dsv3_fused_a_gemm.cu
+42 -24
@@ -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_,
@@ -417,12 +417,15 @@ struct MmaComputer {
417 __device__ void prepare() {
418 #if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
419 #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;
420 + for (int m = 0; m < m_iter_cnt; m++) {
421 + #pragma unroll
422 + for (int i = 0; i < k_phase_cnt; i++) {
423 + int linear_idx = (lane_idx % 16) + (lane_idx / 16) * 128 + i * 256;
424 + int m_idx = linear_idx % 16 + m * 16;
425 + int k_idx = linear_idx / 16 + warp_k_offset_in_tile_k;
426 + k_idx = apply_swizzle_343_on_elem_row_col<bf16_t>(m_idx, k_idx);
427 + a_smem_offsets[m][i] = m_idx * tile_k + k_idx;
428 + }
429 }
430 #pragma unroll
431 for (int n_iter_idx = 0; n_iter_idx < n_iter_cnt; n_iter_idx++) {
@@ -446,11 +449,14 @@ struct MmaComputer {
449 wait_barrier(smem_barrier + 0 + stage_idx * 2, phase_bit);
450
451 #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]));
452 + for (int m = 0; m < m_iter_cnt; m++) {
453 + #pragma unroll
454 + for (int i = 0; i < k_phase_cnt; i++) {
455 + int smem_offset = a_smem_offsets[m][i];
456 + bf16_t* smem_ptr_this_iter =
457 + smem_a + stage_idx * tile_m * tile_k + smem_offset;
458 + ldsm_x4(smem_ptr_this_iter, reinterpret_cast<uint32_t*>(a_reg[m][i]));
459 + }
460 }
461
462 #pragma unroll
@@ -469,9 +475,12 @@ struct MmaComputer {
475 for (int k_iter_idx = 0; k_iter_idx < k_phase_cnt; k_iter_idx++) {
476 #pragma unroll
477 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]);
478 + #pragma unroll
479 + for (int m = 0; m < m_iter_cnt; m++) {
480 + hmma_16_8_16_f32acc_bf16ab(
481 + acc_reg[m][n_iter_idx], a_reg[m][k_iter_idx],
482 + b_reg[n_iter_idx][k_iter_idx], acc_reg[m][n_iter_idx]);
483 + }
484 }
485 }
486 ::arrive_barrier(smem_barrier + 1 + stage_idx * 2);
@@ -486,14 +495,14 @@ struct MmaComputer {
495 #if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
496 asm volatile("bar.sync %0, %1;" : : "r"(1), "r"(thread_cnt));
497 // reorganize the acc_reg
489 - constexpr int thread_m = 2;
498 + constexpr int thread_m = 2 * m_iter_cnt;
499 constexpr int thread_n = 2 * n_iter_cnt;
500 constexpr int cta_mma_n = n_iter_cnt * 8;
501 float acc_reg_reorg[thread_m][thread_n];
502
503 for (int i = 0; i < thread_m; i++) {
504 for (int j = 0; j < thread_n; j++) {
496 - acc_reg_reorg[i][j] = acc_reg[0][j / 2][(j % 2) + (i * 2)];
505 + acc_reg_reorg[i][j] = acc_reg[i / 2][j / 2][(j % 2) + (i % 2) * 2];
506 }
507 }
508
@@ -514,7 +523,8 @@ struct MmaComputer {
523 for (int m_idx_thread = 0; m_idx_thread < thread_m; m_idx_thread++) {
524 #pragma unroll
525 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;
526 + int m_idx =
527 + (lane_idx / 4) + (m_idx_thread % 2) * 8 + (m_idx_thread / 2) * 16;
528 int n_idx =
529 ((lane_idx % 4) * 2) + (n_idx_thread % 2) + (n_idx_thread / 2) * 8;
530 smem_c[cosize_smem_c * warp_idx + smem_c_index_func(m_idx, n_idx)] =
@@ -587,7 +597,7 @@ __global__ __launch_bounds__(256, 1) void fused_a_gemm_kernel(
597 static_assert(
598 tile_k == 128 || tile_k == 256 || tile_k == 512 ||
599 tile_k == 1024); // tile_k must be larger than 64 since 4 warp splitK.
590 - static_assert(tile_m == 16);
600 + static_assert(tile_m == 16 || tile_m == 32);
601 constexpr int g2s_vec_bytes = 16;
602 constexpr int a_elem_bytes = 2;
603 constexpr int b_elem_bytes = 2;
@@ -647,7 +657,8 @@ __global__ __launch_bounds__(256, 1) void fused_a_gemm_kernel(
657 #endif
658 }
659
650 -template <typename T, int kHdIn, int kHdOut, int kTileN, int kTileK = 256>
660 +template <typename T, int kHdIn, int kHdOut, int kTileN, int kTileK = 256,
661 + int kTileM = 16>
662 void invokeFusedAGemm(T* output, T const* mat_a, T const* mat_b, int num_tokens,
663 cudaStream_t const stream, bool enable_pdl) {
664 constexpr int gemm_m = kHdOut;
@@ -655,7 +666,7 @@ void invokeFusedAGemm(T* output, T const* mat_a, T const* mat_b, int num_tokens,
666 constexpr int gemm_k = kHdIn;
667 constexpr int batch_size = 1;
668 std::swap(mat_a, mat_b);
658 - constexpr int tile_m = 16;
669 + constexpr int tile_m = kTileM;
670 constexpr int tile_n = kTileN;
671 constexpr int tile_k = kTileK;
672 constexpr int max_stage_cnt =
@@ -695,15 +706,15 @@ void invokeFusedAGemm(T* output, T const* mat_a, T const* mat_b, int num_tokens,
706 output, mat_a, mat_b, gemm_n);
707 }
708
698 -template <typename T, int kHdIn, int kHdOut, int kTileK = 256>
709 +template <typename T, int kHdIn, int kHdOut, int kTileK = 256, int kTileM = 16>
710 void invokeFusedAGemmForTokens(T* output, T const* mat_a, T const* mat_b,
711 int num_tokens, cudaStream_t const stream,
712 bool enable_pdl) {
713 if (num_tokens <= 8) {
703 - invokeFusedAGemm<T, kHdIn, kHdOut, 8, kTileK>(
714 + invokeFusedAGemm<T, kHdIn, kHdOut, 8, kTileK, kTileM>(
715 output, mat_a, mat_b, num_tokens, stream, enable_pdl);
716 } else {
706 - invokeFusedAGemm<T, kHdIn, kHdOut, 16, kTileK>(
717 + invokeFusedAGemm<T, kHdIn, kHdOut, 16, kTileK, kTileM>(
718 output, mat_a, mat_b, num_tokens, stream, enable_pdl);
719 }
720 }
@@ -782,6 +793,13 @@ void dsv3_fused_a_gemm(torch::stable::Tensor& output,
793 DISPATCH_DSV3_SHAPE(7168, 3216)
794 DISPATCH_DSV3_SHAPE(7168, 4224)
795
796 + if (hd_in == 6144 && hd_out == 2624) {
797 + invokeFusedAGemmForTokens<__nv_bfloat16, 6144, 2624, 256, 32>(
798 + output_ptr, mat_a_ptr, mat_b_ptr, num_tokens, stream, enable_pdl);
799 + return;
800 + }
801 + DISPATCH_DSV3_SHAPE(2048, 2048)
802 +
803 #ifdef VLLM_K3_BENCH_SHAPES
804 // The selector routes these shapes to CuTe or the default GEMM, so they are
805 // never reached in production. They are compiled only for offline
tests/kernels/test_bf16_skinny_gemm.py
+255 -6
@@ -1,6 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Tests for the Kimi-K3 SM103 decode GEMM selector (shape-only dispatch)."""
3 +"""Tests for BF16 skinny GEMMs and the Kimi-K3 SM103 selector."""
4
5 from pathlib import Path
6 from types import SimpleNamespace
@@ -11,7 +11,10 @@ import regex as re
11 import torch
12 from torch import nn
13
14 -from vllm.model_executor.kernels.linear.cute_dsl.skinny_gemm import SkinnyGemmConfig
14 +from vllm.model_executor.kernels.linear.cute_dsl.skinny_gemm import (
15 + SkinnyGemmConfig,
16 +)
17 +from vllm.models.deepseek_v32.nvidia import glm52_low_latency_gemm as glm52_gemm
18 from vllm.models.kimi_k3.nvidia import low_latency_gemm as k3_gemm
19 from vllm.models.kimi_k3.nvidia.low_latency_gemm import KIMI_K3_PROJECTIONS
20
@@ -60,6 +63,12 @@ RESIDUAL_CUTE_CASES = [
63 for num_tokens, _ in spec.residual_configs
64 ]
65
66 +GLM_CUTE_CASES = [
67 + (spec, config)
68 + for spec in glm52_gemm.GLM52_PROJECTIONS.values()
69 + for _, config in spec.cute_configs
70 +]
71 +
72 EXPECTED_CUTE_CONFIGS = {
73 (3072, 7168, 1): (224, 3, 4, 8),
74 (3072, 7168, 2): (128, 3, 2, 8),
@@ -164,13 +173,21 @@ def test_every_dsv3_routed_shape_is_instantiated() -> None:
173 )
174 } | {
175 (int(hd_in), int(hd_out))
167 - for hd_in, hd_out in re.findall(r"hd_in == (\d+) && hd_out == (\d+)", explicit)
176 + for hd_in, hd_out in re.findall(
177 + r"hd_in == (\d+) && hd_out == (\d+)",
178 + production_macros + explicit,
179 + )
180 }
181 assert compiled, "failed to parse the dispatch list"
182
183 + specs = [
184 + *KIMI_K3_PROJECTIONS.values(),
185 + glm52_gemm.GLM52_QKV_A_PROJECTION,
186 + glm52_gemm.GLM52_Q_B_PROJECTION,
187 + ]
188 missing = sorted(
189 (spec.n, spec.k)
173 - for spec in KIMI_K3_PROJECTIONS.values()
190 + for spec in specs
191 if spec.dsv3_tokens and (spec.k, spec.n) not in compiled
192 )
193 assert not missing, (
@@ -190,12 +207,17 @@ def test_packed_row_major_rejects_single_row_slice() -> None:
207
208
209 def test_cute_configs_match_measured_table() -> None:
193 - actual = {
194 - (spec.n, spec.k, num_tokens): _config_tuple(config)
210 + configs = [
211 + (spec.n, spec.k, num_tokens, config)
212 for spec in k3_gemm.KIMI_K3_PROJECTIONS.values()
213 for num_tokens, config in spec.cute_configs
214 + ]
215 + actual = {
216 + (n, k, num_tokens): _config_tuple(config)
217 + for n, k, num_tokens, config in configs
218 }
219 assert actual == EXPECTED_CUTE_CONFIGS
220 + assert all(config.static_k is None for *_, config in configs)
221
222
223 def test_residual_cute_configs_match_measured_table() -> None:
@@ -207,6 +229,134 @@ def test_residual_cute_configs_match_measured_table() -> None:
229 assert actual == EXPECTED_RESIDUAL_CUTE_CONFIGS
230
231
232 +def test_glm52_projection_plans_are_separate() -> None:
233 + qkv_a = glm52_gemm.GLM52_QKV_A_PROJECTION
234 + q_b = glm52_gemm.GLM52_Q_B_PROJECTION
235 +
236 + qkv_a_plan = qkv_a.build_plan()
237 + q_b_plan = q_b.build_plan()
238 +
239 + assert (qkv_a.n, qkv_a.k, set(qkv_a_plan)) == (
240 + 2624,
241 + 6144,
242 + set(range(1, 17)),
243 + )
244 + assert (q_b.n, q_b.k, set(q_b_plan)) == (
245 + 2048,
246 + 2048,
247 + set(range(1, 17)),
248 + )
249 + assert {
250 + num_tokens
251 + for num_tokens, (backend, _) in qkv_a_plan.items()
252 + if backend == "cute"
253 + } == {1, 2}
254 + assert {
255 + num_tokens
256 + for num_tokens, (backend, _) in qkv_a_plan.items()
257 + if backend == "dsv3_fused_a"
258 + } == set(range(3, 17))
259 + assert {
260 + num_tokens for num_tokens, (backend, _) in q_b_plan.items() if backend == "cute"
261 + } == {1, 2}
262 + assert {
263 + num_tokens
264 + for num_tokens, (backend, _) in q_b_plan.items()
265 + if backend == "dsv3_fused_a"
266 + } == set(range(3, 17))
267 +
268 + eh = glm52_gemm.GLM52_EH_PROJECTION
269 + eh_plan = eh.build_plan()
270 + assert (eh.n, eh.k) == (6144, 12288)
271 + # The MTP eh_proj has no dsv3 winners; M >= 4 falls back to cuBLAS.
272 + assert set(eh_plan) == {1, 2, 3}
273 + assert all(backend == "cute" for backend, _ in eh_plan.values())
274 +
275 +
276 +def test_glm52_layout_rejects_nonpacked_single_row_view() -> None:
277 + single_row = torch.empty(1, 144)[:, :128]
278 + multiple_rows = torch.empty(2, 144)[:, :128]
279 +
280 + assert single_row.stride() == multiple_rows.stride() == (144, 1)
281 + assert not glm52_gemm._is_supported_row_major(single_row)
282 + assert not glm52_gemm._is_supported_row_major(multiple_rows)
283 +
284 +
285 +def test_glm52_installer_maps_only_selected_unquantized_shapes(
286 + monkeypatch: pytest.MonkeyPatch,
287 +) -> None:
288 + class FakeLinearBase(nn.Module):
289 + def __init__(
290 + self,
291 + n: int,
292 + k: int,
293 + quant_method: object,
294 + ) -> None:
295 + super().__init__()
296 + self.weight = nn.Parameter(
297 + torch.empty(
298 + n,
299 + k,
300 + dtype=torch.bfloat16,
301 + device="meta",
302 + )
303 + )
304 + self.quant_method = quant_method
305 +
306 + qkv_a = glm52_gemm.GLM52_QKV_A_PROJECTION
307 + q_b = glm52_gemm.GLM52_Q_B_PROJECTION
308 + root = nn.Module()
309 + root.attn = nn.Module()
310 + root.attn.fused_qkv_a_proj = FakeLinearBase(
311 + qkv_a.n, qkv_a.k, glm52_gemm.UnquantizedLinearMethod()
312 + )
313 + root.attn.q_b_proj = FakeLinearBase(
314 + q_b.n, q_b.k, glm52_gemm.UnquantizedLinearMethod()
315 + )
316 + root.same_shape_other_name = FakeLinearBase(
317 + qkv_a.n, qkv_a.k, glm52_gemm.UnquantizedLinearMethod()
318 + )
319 + root.quantized = nn.Module()
320 + quantized_method = object()
321 + root.quantized.q_b_proj = FakeLinearBase(q_b.n, q_b.k, quantized_method)
322 + root.wrong_shape = nn.Module()
323 + root.wrong_shape.fused_qkv_a_proj = FakeLinearBase(
324 + qkv_a.n + 1, qkv_a.k, glm52_gemm.UnquantizedLinearMethod()
325 + )
326 + monkeypatch.setattr(glm52_gemm, "LinearBase", FakeLinearBase)
327 + monkeypatch.setattr(glm52_gemm, "_is_sm103", lambda: True)
328 + monkeypatch.setattr(
329 + glm52_gemm.shape_dynamic_skinny_gemm,
330 + "is_available",
331 + lambda: False,
332 + )
333 +
334 + glm52_gemm.enable_glm52_low_latency_gemm(
335 + root,
336 + torch.bfloat16,
337 + )
338 +
339 + assert isinstance(
340 + root.attn.fused_qkv_a_proj.quant_method,
341 + glm52_gemm.GLM52LowLatencyLinearMethod,
342 + )
343 + assert isinstance(
344 + root.attn.q_b_proj.quant_method,
345 + glm52_gemm.GLM52LowLatencyLinearMethod,
346 + )
347 + assert root.attn.fused_qkv_a_proj.quant_method._plan == qkv_a.build_plan()
348 + assert root.attn.q_b_proj.quant_method._plan == q_b.build_plan()
349 + assert isinstance(
350 + root.same_shape_other_name.quant_method,
351 + glm52_gemm.GLM52LowLatencyLinearMethod,
352 + )
353 + assert root.quantized.q_b_proj.quant_method is quantized_method
354 + assert (
355 + type(root.wrong_shape.fused_qkv_a_proj.quant_method)
356 + is glm52_gemm.UnquantizedLinearMethod
357 + )
358 +
359 +
360 @pytest.mark.parametrize("key", EXPECTED_SELECTIONS)
361 def test_sm103_selector_table(key: tuple[int, int]) -> None:
362 n, k = key
@@ -361,6 +511,76 @@ def _require_sm103_and_cute() -> None:
511 pytest.skip("CuTe DSL is not available")
512
513
514 +@pytest.mark.parametrize("spec,config", GLM_CUTE_CASES)
515 +def test_glm_cute_selected_shapes(
516 + spec: glm52_gemm.GLM52ProjectionSpec,
517 + config: SkinnyGemmConfig,
518 +) -> None:
519 + _require_sm103_and_cute()
520 + torch.manual_seed(42)
521 + x = torch.randn(
522 + config.num_rows,
523 + spec.k,
524 + dtype=torch.bfloat16,
525 + device="cuda",
526 + )
527 + weight = torch.randn(spec.n, spec.k, dtype=torch.bfloat16, device="cuda")
528 + plan = spec.build_plan()
529 +
530 + output = glm52_gemm.run_glm52_plan(plan, x, weight)
531 +
532 + assert output is not None
533 + reference = x.float() @ weight.float().t()
534 + torch.testing.assert_close(output.float(), reference, rtol=2e-2, atol=2e-1)
535 +
536 +
537 +def test_glm52_q_b_nonpacked_single_row_falls_back() -> None:
538 + _require_sm103_and_cute()
539 + spec = glm52_gemm.GLM52_Q_B_PROJECTION
540 + storage = torch.randn(
541 + 1,
542 + glm52_gemm.GLM52_QKV_A_PROJECTION.n,
543 + dtype=torch.bfloat16,
544 + device="cuda",
545 + )
546 + x = storage[:, : spec.k]
547 + weight = torch.randn(spec.n, spec.k, dtype=torch.bfloat16, device="cuda")
548 +
549 + assert x.stride() == (glm52_gemm.GLM52_QKV_A_PROJECTION.n, 1)
550 + assert not glm52_gemm._runtime_ok(x, weight)
551 + output = glm52_gemm.run_glm52_plan(spec.build_plan(), x, weight)
552 +
553 + assert output is None
554 +
555 +
556 +@pytest.mark.parametrize("spec,config", GLM_CUTE_CASES)
557 +def test_glm_cute_selected_shapes_cuda_graph_capture(
558 + spec: glm52_gemm.GLM52ProjectionSpec,
559 + config: SkinnyGemmConfig,
560 +) -> None:
561 + _require_sm103_and_cute()
562 + x = torch.randn(
563 + config.num_rows,
564 + spec.k,
565 + dtype=torch.bfloat16,
566 + device="cuda",
567 + )
568 + weight = torch.randn(spec.n, spec.k, dtype=torch.bfloat16, device="cuda")
569 + plan = spec.build_plan()
570 + glm52_gemm.run_glm52_plan(plan, x, weight)
571 + torch.accelerator.synchronize()
572 +
573 + graph = torch.cuda.CUDAGraph()
574 + with torch.cuda.graph(graph):
575 + output = glm52_gemm.run_glm52_plan(plan, x, weight)
576 + graph.replay()
577 + torch.accelerator.synchronize()
578 +
579 + assert output is not None
580 + reference = x.float() @ weight.float().t()
581 + torch.testing.assert_close(output.float(), reference, rtol=2e-2, atol=2e-1)
582 +
583 +
584 @pytest.mark.parametrize("n,k,num_tokens", CUTE_CASES)
585 def test_cute_selected_shapes(n: int, k: int, num_tokens: int) -> None:
586 _require_sm103_and_cute()
@@ -393,6 +613,15 @@ DSV3_CASES = sorted(
613 for num_tokens in _dsv3_probe_tokens(spec.dsv3_tokens)
614 )
615
616 +GLM_DSV3_CASES = [
617 + (num_tokens, spec)
618 + for spec in (
619 + glm52_gemm.GLM52_QKV_A_PROJECTION,
620 + glm52_gemm.GLM52_Q_B_PROJECTION,
621 + )
622 + for num_tokens in sorted(_dsv3_probe_tokens(spec.dsv3_tokens))
623 +]
624 +
625
626 @pytest.mark.parametrize("num_tokens,n,k", DSV3_CASES)
627 def test_dsv3_selected_shapes(num_tokens: int, n: int, k: int) -> None:
@@ -413,6 +642,26 @@ def test_dsv3_selected_shapes(num_tokens: int, n: int, k: int) -> None:
642 assert cosine > 0.999
643
644
645 +@pytest.mark.parametrize("num_tokens,spec", GLM_DSV3_CASES)
646 +def test_glm_dsv3_selected_shapes(
647 + num_tokens: int,
648 + spec: glm52_gemm.GLM52ProjectionSpec,
649 +) -> None:
650 + _require_sm103_and_dsv3()
651 + torch.manual_seed(42)
652 + x = torch.randn(num_tokens, spec.k, dtype=torch.bfloat16, device="cuda")
653 + weight = torch.randn(spec.n, spec.k, dtype=torch.bfloat16, device="cuda")
654 +
655 + output = glm52_gemm.run_glm52_plan(spec.build_plan(), x, weight)
656 +
657 + assert output is not None
658 + reference = torch.nn.functional.linear(x, weight)
659 + cosine = torch.nn.functional.cosine_similarity(
660 + output.float().flatten(), reference.float().flatten(), dim=0
661 + ).item()
662 + assert cosine > 0.999
663 +
664 +
665 def test_nonpacked_single_token_dsv3_falls_back() -> None:
666 _require_sm103_and_dsv3()
667 n, k = 1536, 128
vllm/model_executor/kernels/linear/cute_dsl/_skinny_gemm.py
+92 -19
@@ -8,6 +8,8 @@ import cutlass.cute as cute
8 from cuda.bindings.driver import CUstream
9 from cutlass import const_expr
10
11 +_PREFETCH_TILES = 2
12 +
13
14 class CuteSkinnyGemm:
15 """Shape-dynamic low-latency GEMM for small token counts.
@@ -15,8 +17,9 @@ class CuteSkinnyGemm:
17 Computes ``C[M, N] = A[M, K] @ B[N, K].T + residual`` with BF16 or FP16
18 inputs, FP32 accumulators, and an output matching the input dtype. The
19 residual term is optional and is added before the output conversion. N and
18 - K are runtime values. The tiny M dimension is fully unrolled alongside a
19 - small set of tuning parameters.
20 + K are runtime values unless the configuration specializes K at compile
21 + time. The tiny M dimension is fully unrolled alongside a small set of
22 + tuning parameters.
23 """
24
25 def __init__(
@@ -30,9 +33,15 @@ class CuteSkinnyGemm:
33 k_unroll: int = 1,
34 has_residual: bool = False,
35 use_pdl: bool = False,
36 + static_k: int | None = None,
37 ) -> None:
38 if block_size % cute.arch.WARP_SIZE != 0:
39 raise ValueError("block_size must be a multiple of the warp size")
40 + k_tile_size = block_size * vector_width
41 + if static_k is not None and (
42 + static_k % k_tile_size or static_k < _PREFETCH_TILES * k_tile_size
43 + ):
44 + raise ValueError("static K must contain at least two complete tiles")
45 self.element_type = element_type
46 self.num_rows = num_rows
47 self.block_size = block_size
@@ -41,6 +50,7 @@ class CuteSkinnyGemm:
50 self.k_unroll = k_unroll
51 self.has_residual = has_residual
52 self.use_pdl = use_pdl
53 + self.static_k = static_k
54 self.num_warps = block_size // cute.arch.WARP_SIZE
55
56 @cute.jit
@@ -66,7 +76,22 @@ class CuteSkinnyGemm:
76 num_bits_per_copy=self.vector_width * self.element_type.width,
77 load_cache_mode=cute.nvgpu.LoadCacheMode.STREAMING,
78 )
69 - self.kernel(gA, gB, gResidual, gC, k, copy_a, copy_b).launch(
79 + copy_b_prefetch = cute.make_copy_atom(
80 + cute.nvgpu.CopyG2ROp(),
81 + self.element_type,
82 + num_bits_per_copy=self.vector_width * self.element_type.width,
83 + load_cache_mode=cute.nvgpu.LoadCacheMode.ALWAYS,
84 + )
85 + self.kernel(
86 + gA,
87 + gB,
88 + gResidual,
89 + gC,
90 + k,
91 + copy_a,
92 + copy_b,
93 + copy_b_prefetch,
94 + ).launch(
95 grid=[cute.ceil_div(n, self.outputs_per_block), 1, 1],
96 block=[self.block_size, 1, 1],
97 smem=self.num_rows * self.outputs_per_block * self.num_warps * 4,
@@ -85,6 +110,7 @@ class CuteSkinnyGemm:
110 k_extent: cutlass.Int32,
111 copy_a: cute.CopyAtom,
112 copy_b: cute.CopyAtom,
113 + copy_b_prefetch: cute.CopyAtom,
114 ) -> None:
115 tidx, _, _ = cute.arch.thread_idx()
116 block_idx, _, _ = cute.arch.block_idx()
@@ -102,12 +128,8 @@ class CuteSkinnyGemm:
128 acc = cute.make_rmem_tensor(acc_layout, cutlass.Float32)
129 acc.fill(0.0)
130
105 - if const_expr(self.use_pdl):
106 - cute.arch.griddepcontrol_wait()
107 -
131 n_base = block_idx * outputs_per_block
132 k_tile_size: cutlass.Constexpr = block_size * vector_width
110 - num_k_tiles = k_extent // k_tile_size
133
134 gA_vec = cute.logical_divide(gA, (None, vector_width))
135 gB_vec = cute.logical_divide(gB, (None, vector_width))
@@ -127,21 +149,72 @@ class CuteSkinnyGemm:
149 self.element_type,
150 )
151
130 - for k_tile in cutlass.range(num_k_tiles, unroll=self.k_unroll):
131 - for mi in cutlass.range_constexpr(num_rows):
132 - cute.copy(copy_a, tA[mi, None, k_tile], a_regs[mi, None])
152 + if const_expr(self.static_k is not None):
153 + num_k_tiles: cutlass.Constexpr = self.static_k // k_tile_size
154 + prefetched_b = cute.make_rmem_tensor(
155 + cute.make_layout(
156 + (_PREFETCH_TILES, outputs_per_block, vector_width),
157 + stride=(outputs_per_block * vector_width, vector_width, 1),
158 + ),
159 + self.element_type,
160 + )
161 + for k_tile in cutlass.range_constexpr(_PREFETCH_TILES):
162 + for ni in cutlass.range_constexpr(outputs_per_block):
163 + tB_static = tB_all[n_base + ni, (None, (tidx, None))]
164 + cute.copy(
165 + copy_b_prefetch,
166 + tB_static[None, k_tile],
167 + prefetched_b[k_tile, ni, None],
168 + )
169
134 - for ni in cutlass.range_constexpr(outputs_per_block):
135 - n_idx = n_base + ni
136 - tB = tB_all[n_idx, (None, (tidx, None))]
137 - cute.copy(copy_b, tB[None, k_tile], b_regs[ni, None])
170 + if const_expr(self.use_pdl):
171 + cute.arch.griddepcontrol_wait()
172
139 - for vi in cutlass.range_constexpr(vector_width):
140 - for mi in cutlass.range_constexpr(num_rows):
173 + for k_tile in cutlass.range_constexpr(num_k_tiles):
174 + if const_expr(k_tile < _PREFETCH_TILES):
175 + b_f32 = prefetched_b[k_tile, None, None].load().to(cutlass.Float32)
176 + else:
177 for ni in cutlass.range_constexpr(outputs_per_block):
142 - acc[mi, ni] = acc[mi, ni] + a_regs[mi, vi].to(
143 - cutlass.Float32
144 - ) * b_regs[ni, vi].to(cutlass.Float32)
178 + tB_static = tB_all[n_base + ni, (None, (tidx, None))]
179 + cute.copy(
180 + copy_b,
181 + tB_static[None, k_tile],
182 + b_regs[ni, None],
183 + )
184 + b_f32 = b_regs.load().to(cutlass.Float32)
185 +
186 + for mi in cutlass.range_constexpr(num_rows):
187 + cute.copy(copy_a, tA[mi, None, k_tile], a_regs[mi, None])
188 + a_f32 = a_regs.load().to(cutlass.Float32)
189 +
190 + for vi in cutlass.range_constexpr(vector_width):
191 + for mi in cutlass.range_constexpr(num_rows):
192 + for ni in cutlass.range_constexpr(outputs_per_block):
193 + acc[mi, ni] += a_f32[mi, vi] * b_f32[ni, vi]
194 + else:
195 + if const_expr(self.use_pdl):
196 + cute.arch.griddepcontrol_wait()
197 +
198 + num_k_tiles = k_extent // k_tile_size
199 + for k_tile in cutlass.range(num_k_tiles, unroll=self.k_unroll):
200 + for mi in cutlass.range_constexpr(num_rows):
201 + cute.copy(copy_a, tA[mi, None, k_tile], a_regs[mi, None])
202 +
203 + for ni in cutlass.range_constexpr(outputs_per_block):
204 + n_idx = n_base + ni
205 + tB_dynamic = tB_all[n_idx, (None, (tidx, None))]
206 + cute.copy(
207 + copy_b,
208 + tB_dynamic[None, k_tile],
209 + b_regs[ni, None],
210 + )
211 +
212 + for vi in cutlass.range_constexpr(vector_width):
213 + for mi in cutlass.range_constexpr(num_rows):
214 + for ni in cutlass.range_constexpr(outputs_per_block):
215 + acc[mi, ni] = acc[mi, ni] + a_regs[mi, vi].to(
216 + cutlass.Float32
217 + ) * b_regs[ni, vi].to(cutlass.Float32)
218
219 for mi in cutlass.range_constexpr(num_rows):
220 for ni in cutlass.range_constexpr(outputs_per_block):
vllm/model_executor/kernels/linear/cute_dsl/skinny_gemm.py
+9 -1
@@ -23,6 +23,7 @@ class SkinnyGemmConfig:
23 outputs_per_block: int
24 k_unroll: int = 1
25 vector_width: int = 8
26 + static_k: int | None = None
27
28
29 class ShapeDynamicSkinnyGemm:
@@ -112,7 +113,11 @@ class ShapeDynamicSkinnyGemm:
113
114 element_type = self._cutlass_dtype(dtype)
115 n = cute.sym_int(divisibility=config.outputs_per_block)
115 - k = cute.sym_int(divisibility=config.block_size * config.vector_width)
116 + k = (
117 + config.static_k
118 + if config.static_k is not None
119 + else cute.sym_int(divisibility=config.block_size * config.vector_width)
120 + )
121 a = make_fake_tensor(
122 element_type,
123 (config.num_rows, k),
@@ -134,6 +139,7 @@ class ShapeDynamicSkinnyGemm:
139 k_unroll=config.k_unroll,
140 has_residual=has_residual,
141 use_pdl=self._use_pdl(),
142 + static_k=config.static_k,
143 )
144 self._compiled[(dtype, config, has_residual)] = cute.compile(
145 kernel,
@@ -239,6 +245,8 @@ class ShapeDynamicSkinnyGemm:
245 raise ValueError(
246 "K must be divisible by block_size * vector_width for this config"
247 )
248 + if config.static_k is not None and a.shape[1] != config.static_k:
249 + raise ValueError("input K must match config static_k")
250 has_residual = residual is not None
251 cache_key = (a.dtype, config, has_residual)
252 if cache_key not in self._compiled:
vllm/models/deepseek_v32/nvidia/glm52_low_latency_gemm.py new
+199
@@ -0,0 +1,199 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""GLM-5.2 decode GEMM selection for unquantized BF16 on SM103."""
4 +
5 +from __future__ import annotations
6 +
7 +from dataclasses import dataclass
8 +from typing import Literal
9 +
10 +import torch
11 +from torch import nn
12 +
13 +import vllm.envs as envs
14 +from vllm import _custom_ops as ops
15 +from vllm.model_executor.kernels.linear.cute_dsl.skinny_gemm import (
16 + SkinnyGemmConfig,
17 + shape_dynamic_skinny_gemm,
18 +)
19 +from vllm.model_executor.layers.linear import (
20 + LinearBase,
21 + UnquantizedLinearMethod,
22 +)
23 +from vllm.platforms import current_platform
24 +
25 +Backend = Literal["cute", "dsv3_fused_a"]
26 +ResolvedCall = tuple[Backend, SkinnyGemmConfig | None]
27 +
28 +
29 +@dataclass(frozen=True, slots=True)
30 +class GLM52ProjectionSpec:
31 + n: int
32 + k: int
33 + cute_configs: tuple[tuple[int, SkinnyGemmConfig], ...]
34 + dsv3_tokens: frozenset[int] = frozenset()
35 +
36 + def build_plan(self) -> dict[int, ResolvedCall]:
37 + plan: dict[int, ResolvedCall] = {
38 + num_tokens: ("cute", config) for num_tokens, config in self.cute_configs
39 + }
40 + plan.update(
41 + (num_tokens, ("dsv3_fused_a", None)) for num_tokens in self.dsv3_tokens
42 + )
43 + return plan
44 +
45 +
46 +GLM52_QKV_A_PROJECTION = GLM52ProjectionSpec(
47 + n=2624,
48 + k=6144,
49 + cute_configs=(
50 + (1, SkinnyGemmConfig(1, 128, 4, static_k=6144)),
51 + (2, SkinnyGemmConfig(2, 128, 2)),
52 + ),
53 + dsv3_tokens=frozenset(range(3, 17)),
54 +)
55 +
56 +GLM52_Q_B_PROJECTION = GLM52ProjectionSpec(
57 + n=2048,
58 + k=2048,
59 + cute_configs=(
60 + (1, SkinnyGemmConfig(1, 128, 4, static_k=2048)),
61 + (2, SkinnyGemmConfig(2, 64, 2, k_unroll=2)),
62 + ),
63 + dsv3_tokens=frozenset(range(3, 17)),
64 +)
65 +
66 +# The MTP eh_proj is a plain nn.Linear, so it gets its plan through
67 +# build_glm52_plan rather than a quant_method swap. cuBLAS wins from M=4.
68 +GLM52_EH_PROJECTION = GLM52ProjectionSpec(
69 + n=6144,
70 + k=12288,
71 + cute_configs=(
72 + (1, SkinnyGemmConfig(1, 256, 2, vector_width=4, static_k=12288)),
73 + (2, SkinnyGemmConfig(2, 64, 2)),
74 + (3, SkinnyGemmConfig(3, 64, 2)),
75 + ),
76 +)
77 +
78 +GLM52_PROJECTIONS = {
79 + (spec.n, spec.k): spec
80 + for spec in (
81 + GLM52_QKV_A_PROJECTION,
82 + GLM52_Q_B_PROJECTION,
83 + GLM52_EH_PROJECTION,
84 + )
85 +}
86 +
87 +
88 +def _is_sm103() -> bool:
89 + return current_platform.is_device_capability((10, 3))
90 +
91 +
92 +def _is_supported_row_major(tensor: torch.Tensor) -> bool:
93 + return tensor.dim() == 2 and tensor.stride() == (tensor.shape[1], 1)
94 +
95 +
96 +def _runtime_ok(x: torch.Tensor, weight: torch.Tensor) -> bool:
97 + return (
98 + not envs.VLLM_BATCH_INVARIANT
99 + and _is_supported_row_major(x)
100 + and _is_supported_row_major(weight)
101 + and x.dtype == torch.bfloat16
102 + and weight.dtype == torch.bfloat16
103 + and x.is_cuda
104 + and weight.is_cuda
105 + and x.device == weight.device
106 + and x.shape[1] == weight.shape[1]
107 + )
108 +
109 +
110 +def run_glm52_plan(
111 + plan: dict[int, ResolvedCall] | None,
112 + x: torch.Tensor,
113 + weight: torch.Tensor,
114 +) -> torch.Tensor | None:
115 + if plan is None or not _runtime_ok(x, weight):
116 + return None
117 + entry = plan.get(x.shape[0])
118 + if entry is None:
119 + return None
120 +
121 + backend, config = entry
122 + if backend == "cute":
123 + if not shape_dynamic_skinny_gemm.is_available():
124 + return None
125 + return shape_dynamic_skinny_gemm(x, weight, config)
126 +
127 + if not hasattr(torch.ops._C, "dsv3_fused_a_gemm"):
128 + return None
129 + output = torch.empty(
130 + (x.shape[0], weight.shape[0]),
131 + dtype=x.dtype,
132 + device=x.device,
133 + )
134 + ops.dsv3_fused_a_gemm(output, x, weight.t(), enable_pdl=True)
135 + return output
136 +
137 +
138 +def _request_warmup(dtype: torch.dtype, configs: set[SkinnyGemmConfig]) -> None:
139 + if configs and shape_dynamic_skinny_gemm.is_available():
140 + shape_dynamic_skinny_gemm.request_warmup_configs(dtype, configs)
141 +
142 +
143 +def build_glm52_plan(
144 + weight: torch.Tensor | None, dtype: torch.dtype
145 +) -> dict[int, ResolvedCall] | None:
146 + """Plan for a weight the walk below cannot reach (a plain ``nn.Linear``)."""
147 + if dtype != torch.bfloat16 or not _is_sm103():
148 + return None
149 + if weight is None or weight.dim() != 2 or weight.dtype != torch.bfloat16:
150 + return None
151 + spec = GLM52_PROJECTIONS.get(tuple(weight.shape))
152 + if spec is None:
153 + return None
154 + _request_warmup(dtype, {config for _, config in spec.cute_configs})
155 + return spec.build_plan()
156 +
157 +
158 +class GLM52LowLatencyLinearMethod(UnquantizedLinearMethod):
159 + def __init__(self, plan: dict[int, ResolvedCall]) -> None:
160 + super().__init__()
161 + self._plan = plan
162 +
163 + def apply(
164 + self,
165 + layer: nn.Module,
166 + x: torch.Tensor,
167 + bias: torch.Tensor | None = None,
168 + ) -> torch.Tensor:
169 + if bias is None:
170 + output = run_glm52_plan(self._plan, x, layer.weight)
171 + if output is not None:
172 + return output
173 + return super().apply(layer, x, bias)
174 +
175 +
176 +def enable_glm52_low_latency_gemm(
177 + module: nn.Module,
178 + dtype: torch.dtype,
179 +) -> None:
180 + if dtype != torch.bfloat16 or not _is_sm103():
181 + return
182 +
183 + warmup_configs: set[SkinnyGemmConfig] = set()
184 + for child in module.modules():
185 + if (
186 + not isinstance(child, LinearBase)
187 + or type(child.quant_method) is not UnquantizedLinearMethod
188 + ):
189 + continue
190 + weight = getattr(child, "weight", None)
191 + if weight is None or weight.dim() != 2 or weight.dtype != torch.bfloat16:
192 + continue
193 + spec = GLM52_PROJECTIONS.get(tuple(weight.shape))
194 + if spec is None:
195 + continue
196 + child.quant_method = GLM52LowLatencyLinearMethod(spec.build_plan())
197 + warmup_configs.update(config for _, config in spec.cute_configs)
198 +
199 + _request_warmup(dtype, warmup_configs)
vllm/models/deepseek_v32/nvidia/model.py
+7
@@ -42,6 +42,8 @@ from vllm.models.common.ops.fused_allreduce_rms_norm import fused_allreduce_rms_
42 from vllm.models.deepseek_v32.attention import DeepseekV32Attention
43 from vllm.sequence import IntermediateTensors
44
45 +from .glm52_low_latency_gemm import enable_glm52_low_latency_gemm
46 +
47
48 def _all_gather_sp_states(
49 hidden_states: torch.Tensor,
@@ -405,6 +407,11 @@ class DeepseekV32ForCausalLM(DeepseekV2ForCausalLM):
407
408 model_cls = DeepseekV32Model
409
410 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
411 + super().__init__(vllm_config=vllm_config, prefix=prefix)
412 + if self.config.model_type == "glm_moe_dsa":
413 + enable_glm52_low_latency_gemm(self, vllm_config.model_config.dtype)
414 +
415 def set_moe_parameters(self):
416 # Same as the base, but keyed on the MoE block type rather than the
417 # decoder-layer type (DeepseekV32DecoderLayer is a plain nn.Module).
vllm/models/deepseek_v32/nvidia/mtp.py
+15 -1
@@ -42,6 +42,11 @@ from vllm.models.deepseek_v32.common.kernels import fused_eh_norm
42 from vllm.platforms import current_platform
43 from vllm.sequence import IntermediateTensors
44
45 +from .glm52_low_latency_gemm import (
46 + build_glm52_plan,
47 + enable_glm52_low_latency_gemm,
48 + run_glm52_plan,
49 +)
50 from .model import DeepseekV32DecoderLayer
51
52
@@ -56,6 +61,11 @@ 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 + self._eh_plan = (
65 + build_glm52_plan(self.eh_proj.weight, vllm_config.model_config.dtype)
66 + if config.model_type == "glm_moe_dsa"
67 + else None
68 + )
69
70 topk_indices_buffer = torch.empty(
71 vllm_config.scheduler_config.max_num_batched_tokens,
@@ -91,7 +101,9 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
101 self.hnorm.weight,
102 self.enorm.variance_epsilon,
103 )
94 - hidden_states = self.eh_proj(eh_input)
104 + hidden_states = run_glm52_plan(self._eh_plan, eh_input, self.eh_proj.weight)
105 + if hidden_states is None:
106 + hidden_states = self.eh_proj(eh_input)
107 hidden_states, residual = self.mtp_block(
108 positions=positions, hidden_states=hidden_states, residual=None
109 )
@@ -199,6 +211,8 @@ class DeepseekV32MTP(nn.Module, DeepseekV2MixtureOfExperts):
211 self.model = DeepseekV32MultiTokenPredictor(
212 vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
213 )
214 + if self.config.model_type == "glm_moe_dsa":
215 + enable_glm52_low_latency_gemm(self, vllm_config.model_config.dtype)
216 self.set_moe_parameters()
217
218 def set_moe_parameters(self):