Skip to content

[Perf][GLM-5.3-Flash] Add SM90 skinny GEMM plans for the BF16 decode projections - #65306

Open
ischencheng wants to merge 5 commits into
vllm-project:mainfrom
ischencheng:perf/glm53-flash-sm90-skinny-plans
Open

ischencheng wants to merge 5 commits into
vllm-project:mainfrom
ischencheng:perf/glm53-flash-sm90-skinny-plans

Conversation

@ischencheng

@ischencheng ischencheng commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Overview

On H100 / H200, GLM-5.3-Flash's unquantized BF16 decode projections all run cuBLAS, because skinny_gemm_plans.py only has an SM121 table (#62956). This adds an SM90 table for the TP=4 and TP=8 local shapes (KDA in/out projections and f_b/g_b, the MLA projections vLLM dequantizes at load, the indexer wq_b / wk, the LM head), so they take the existing CuTe DSL skinny GEMM at 1-4 tokens. No kernel or dispatch change.

Part of the GLM 5.3 perf tracker #57406 (same mechanism as #62956 on SM121) and the Q4 roadmap's GLM 5.3 model performance item #61024.

Claims

  • Per projection at M=1, cold weights, CUDA graphs: 1.08x to 1.96x over cuBLAS on H200 and H100 (table below). 36 (N, K, M) cells are planned. Every cell is at least 1.09x on H200 and 1.06x on H100. Everything else stays on cuBLAS.
  • Real model, zai-org/GLM-5.3-Flash official FP8, 4x H200, TP=4: C1 TPOT -5.7% (6.09 -> 5.75 ms). C2 moves within run spread, C4 and C8 are unchanged.
  • GSM8K shows no accuracy change at concurrency 1, 2 and 4: 245 -> 246 of 266, 112 -> 110 of 122, 1212 -> 1212 of 1319.

Validation

Component, H200 and H100, M=1. Speedup is the A/B described below (a chain with a small kernel between GEMMs, so it is lower than the GEMM-only ratio). The times come from the config sweeps, GEMM only, with that kernel subtracted. "% of HBM peak" is weight bytes / time / peak (4.8 TB/s H200, 3.35 TB/s H100).

projection (N, K) H200 cuBLAS -> skinny us H200 % of HBM peak H200 speedup H100 cuBLAS -> skinny us H100 % of HBM peak H100 speedup
KDA in_proj (TP4) (6416, 4096) 17.5 -> 14.4 76% 1.18x - - 1.11x
KDA in_proj (TP8) (3336, 4096) 12.4 -> 8.6 66% 1.38x 14.2 -> 10.6 77% 1.34x
KDA f_b / g_b (TP8) (1024, 128) 2.7 -> 2.0 3% 1.28x 2.3 -> 1.9 4% 1.22x
KDA o_proj (TP4) / MLA o_proj (TP8) (4096, 2048) 7.3 -> 6.3 56% 1.14x 8.6 -> 7.6 66% 1.11x
KDA o_proj (TP8) (4096, 1024) 5.3 -> 4.5 39% 1.11x 5.8 -> 4.7 53% 1.15x
MLA fused_qkv_a (2048, 4096) 9.4 -> 6.4 55% 1.41x 10.7 -> 7.4 68% 1.38x
MLA q_b (TP4) / indexer wq_b (4096, 1536) 6.2 -> 5.4 49% 1.14x 7.2 -> 6.3 59% 1.12x
MLA q_b (TP8) (2048, 1536) 5.4 -> 3.8 34% 1.31x 5.7 -> 4.1 46% 1.26x
MLA o_proj (TP4) (4096, 4096) 13.3 -> 9.9 71% 1.32x - - 1.25x
indexer wk + weights (160, 4096) 7.0 -> 2.3 12% 1.96x 6.9 -> 2.3 17% 1.93x
LM head (TP4) (38720, 4096) 85.6 -> 71.6 92% 1.20x - - 1.09x
LM head (TP8) (19360, 4096) 44.7 -> 36.9 89% 1.20x 57.8 -> 51.4 92% 1.08x

The A/B covers every shape at M=1-4 on H200, and on H100 the TP8 shapes (GLM's FP8 checkpoint needs TP8 there). Geomean over those 92 cases is 1.184x. The lowest planned cell is 1.07x. Unplanned cells are 0.98x to 1.02x, which is the spread identical code shows on different buffers. A separate H100 run of every shape covers the TP4-only rows too, and its lowest planned cell is 1.06x.

How it was measured: each case is a chain over copies of the weight adding up to 1 GiB or more (so every GEMM reads HBM, not L2), captured in a CUDA graph, timed interleaved against cuBLAS over 11 trials of 300 replays. Outputs are compared elementwise to a float64 reference with |out - ref| <= 7.5e-5 + 2^-8 |ref| (cuBLAS needs up to 3.8e-5 beyond bf16 rounding on these shapes, the skinny configs under 1e-6), on normal, outlier and scaled/sparse inputs. M=8 and M=64 (no plan) match main.

Full model A/B: zai-org/GLM-5.3-Flash official FP8, 4x H200, TP=4, one container, the two sides differ only in this table, server starts main / PR / main / PR. vllm serve --tensor-parallel-size 4 --language-model-only --disable-flashinfer-autotune --max-model-len 16384 --max-num-seqs 64 --no-enable-prefix-caching. vllm bench serve with 2048 random input tokens, 256 output tokens, greedy, two runs per start, median of the four.

conc median TPOT ms (main -> PR) output tok/s (main -> PR)
1 6.09 -> 5.75 (-5.7%) 150 -> 158 (+5.3%)
2 9.29 -> 8.98 (-3.4%) 196 -> 201 (+2.5%)
4 9.63 -> 9.62 (-0.1%) 375 -> 374 (-0.1%)
8 11.43 -> 11.45 (+0.2%) 606 -> 612 (+1.0%)

C1 is tight (main 6.07-6.10, PR 5.74-5.76). C2 is noisy (main 8.98-9.38, PR 8.65-9.42). 7168-token single-request TTFT is 279.4 -> 283.2 ms, with runs from 277 to 287 ms.

GSM8K: 5-shot completions, greedy, seed 42, in the same setup. Which cells run depends on M, so it ran at three concurrencies.

concurrency decode M questions correct (main -> PR) right on one side only (main / PR) exact McNemar p
1 1 266 245 -> 246 6 / 7 1.0
2 mostly 2 122 112 -> 110 3 / 1 0.63
4 mostly 4 1319 1212 -> 1212 24 / 24 1.0

The concurrency 1 and 2 rows are smaller because of the GPU budget. At M=4 the TP4 table only has the MLA qkv_a and o_proj and the indexer wk, so those two rows are the ones that cover the M=1 and M=2 cells. M=3 cells run only incidentally at concurrency 4; the float64 test covers them at kernel level. Greedy text does change: at concurrency 1, with no batching, it matches on 101 of 266 questions and the final answer on 250. I did not run main vs main.

TP=8 has no full-model run. On a one-rank dummy model with TP=8 shapes (1x H100, no all-reduce, 512-token prompts, 192 greedy tokens, median of base / plans / plans / base with two runs per arm) the step is C1 5.277 -> 4.982 ms (-5.6%), C2 -5.4%, C3 and C4 -3.8%, C8 -0.2%. That dummy model pads its vocab to 19392, so its LM head misses the plan; a real TP=8 rank has 19360 rows. A dummy rank has no all-reduce, so expect less on the real model at M >= 2, as the C2 and C4 rows above suggest.

Tests, 1x H100, on this head:

pytest -v tests/kernels/test_bf16_skinny_gemm.py   # 344 passed, 438 skipped (other GPUs)
pytest -v tests/kernels/test_bf16_skinny_gemm.py -k 'sm90_skinny_gemm_plans_match_linear or skinny_gemm_plans_are_valid or maybe_use_skinny_gemm'   # 108 passed, 0 skipped

The new test runs every SM90 cell, eagerly and under CUDA graph replay, against a float64 reference. It skips on other GPUs.

Details

cuBLAS picks 64x8 tiles for these shapes and launches about N/64 CTAs (64 for N=4096, 104 for the TP4 KDA in_proj on 132 SMs), so about 14% of warps are active and DRAM reaches 38-67% of peak (ncu, H200, M=1, LM head 80%). The MLA fused_qkv_a, MLA o_proj, indexer wk and TP8 in_proj also go through splitK plus a reduce kernel. The skinny GEMM launches N / outputs_per_block CTAs with no reduce: for the MLA o_proj, DRAM goes from 58% to 68% of peak and warps active from 14% to 85%. Summed over one TP4 decode step, these projections read 3.55 GB and take 1749 us with cuBLAS and 1460 us with the plans (ncu, serial, cold L2).

The table is keyed by capability (9, 0) and local (N, K). So H100 and H200 share it, H20 / H800 take it too (not measured), and any other model with the same shape gets these configs at M=1-4 and the JIT compile at startup, for example the o_proj of Llama-3.1-8B at TP=1 (4096, 4096) and of Qwen3-30B-A3B (2048, 4096). Each config comes from sweeps on both GPUs: the best worst-case speedup, kept only if it holds in the A/B above. TP4 f_b/g_b (2048, 128) loses on H200, and several M=3/4 cells barely win, so those stay on cuBLAS.

In eager mode a planned layer goes through #62956's custom op, which adds 4-7 us of host time per call (measured on single calls). Decode runs under CUDA graphs on Hopper. Prefill steps run eager, and the TTFT above stays within run spread. #64417's eager bypass would work for this op too, as a follow-up.

MTP: draft steps at C1 run M=1 and use the MLA and LM head plans, and verify at MTP5 runs M>=6, which has no plan. I did not measure MTP; all numbers here are with spec decoding off, the recipe default.

CI: the existing NVIDIA step for this test file runs on B200, where the SM90 cases skip, so this adds a small H100 step.

Not a duplicate: #64979 adds SM90 plans for dense Qwen3 / Llama-3.1 TP=1 shapes, and two of its keys, (2048, 4096) and (4096, 4096), are GLM shapes too. Whichever lands second merges the two tables. No open PR adds GEMM plans for GLM-5.3-Flash on SM90 (#57585 tunes its KDA recurrent kernel there, a different kernel).

AI assistance (Claude Code) was used. I reviewed the change and ran the tests above.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

Register a capability (9, 0) table for the TP4 and TP8 rank shapes of
GLM-5.3-Flash's unquantized BF16 linears (KDA, MLA, indexer, LM head) at
M = 1..4. Entries are kept only where the skinny GEMM beats cuBLAS by at
least 5% under CUDA graph replay on both H100 and H200.

Add a test that runs every SM90 entry against F.linear, eagerly and under
graph replay, and cover (9, 0) in the selection test.

Signed-off-by: Chen Cheng <85005591+ischencheng@users.noreply.github.com>
Remove the four H200 cells that measured under 1.08x against cuBLAS in
the graph-replay A/B: (6416, 4096) M=3, (19360, 4096) M=2 and M=3, and
(38720, 4096) M=4. Identical code on different buffers moves these
LM head and KDA in_proj cells by up to 4% on H200, so they could fall
under the 5% rule; those token counts go back to cuBLAS.

Signed-off-by: Chen Cheng <85005591+ischencheng@users.noreply.github.com>
tests/kernels/test_bf16_skinny_gemm.py only runs in the B200 Miscellaneous
Kernels step, where the SM90 plan tests skip. Add an H100 step that runs
the SM90 plan, plan validity and selection tests when the plans, the
skinny GEMM or its dispatch change.

Signed-off-by: Chen Cheng <85005591+ischencheng@users.noreply.github.com>
…/glm53-flash-sm90-skinny-plans

Signed-off-by: Chen Cheng <85005591+ischencheng@users.noreply.github.com>
The H100 step now also runs when linear.py or vocab_parallel_embedding.py
change, since both call maybe_use_skinny_gemm. The table comment records
that every cell was measured on both H100 and H200.

Signed-off-by: Chen Cheng <85005591+ischencheng@users.noreply.github.com>
@ischencheng
ischencheng requested a review from a team November 3, 2026 05:22

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant