Skip to content

[Perf][Model] Qwen4Exp QSA: sm_90 tuning table for _select_config - #57273

Merged
ZJY0516 merged 4 commits into
vllm-project:mainfrom
ShuoleiWang:qsa-sm90-select-config
Sep 17, 2026
Merged

ZJY0516 merged 4 commits into
vllm-project:mainfrom
ShuoleiWang:qsa-sm90-select-config

Conversation

@ShuoleiWang

Copy link
Copy Markdown
Contributor

Purpose

Retune one kernel's launch parameters for sm_90 (H100/H200/H20): _qsa_sparse_paged_gqa_splitk_kernel, the Triton split-K kernel that runs the sparse attention of Qwen3.8-Flash-Next's QSA layers over the 2048 selected tokens per query. Its tile width, split count and warps per CTA come from _select_config() in vllm/models/qwen4_exp/nvidia/ops/qsa.py, a table tuned on GB300 (#55557 added an sm_120 table beside it). Measured on H20, the GB300 entries are the wrong geometry in most decode regions, so this PR adds _select_sm90_config() in the same shape as the sm_120 helper. Besides that nothing else changes. Regions where the default entry is already fastest return None and keep it.

Each row below is one region of _select_config's key, base_programs = query rows × kv_heads per rank (at TP8 with MTP3 that is 4 × concurrency, so the second column shows which serving points land in the region). "GB300 entry" is what runs today, "sm_90 entry" what this PR selects on H100/H200/H20; a tuple is (BLOCK_N, target_splits, num_warps). "Latency" is the change in this kernel's time per call when switching from the GB300 entry to the sm_90 entry, measured on one H20 (attention + split-K merge + fused output gate, CUDA-graph replay, median of 3 interleaved orders × 9 replays, hot and L2-evicted), at the three head geometries group 3 / 6 / 12 = query heads per kv head at TP8 / TP4 / TP1-2. Negative is faster.

bf16 K/V cache:

base_programs TP8 + MTP3 GB300 entry sm_90 entry latency, group 3 / 6 / 12
≤ 24 C1 – C6 (32, 64, 4) (64, 64, 2) −11 % / −12 % / −14 % at 4 rows, −12 % / −19 % / −21 % at 16 rows
25 … 32 C7 – C8 (32, 16, 1) (64, 64, 2) −7 % / −7 % / ±0
33 … 64 C9 – C16 (32, 8, 1) (32, 16, 1) −8 % / −6 % / −4 %
65 … 128 C17 – C32 (32, 4, 1) (32, 8, 1) −14 % / −13 % / −9 %
129 … 256 C33 – C64 (32, 8, 1) unchanged best candidate within 3 % of the default
257 … 512 C65 – C128 (64, 4, 2) (32, 4, 1) −13 % / −12 % / −4 %
513 … 2048 C129 – C512 (64, 1, 2) (32, 4, 1) −18 % / not measured / −1 %
> 2048 prefill (32, 1, 1) prefill, (64, 1, 2) decode unchanged prefill entry re-confirmed (8 neighbours slower); decode entry not measured

fp8 K/V cache (the path added by #55557):

base_programs TP8 + MTP3 GB300 entry sm_90 entry latency, group 3 / 6 / 12
≤ 24 C1 – C6 (32, 64, 4) (64, 64, 2) −8 % / −8 % / −20 % at 4 rows, −15 % / −18 % / −22 % at 16 rows
25 … 32 C7 – C8 (32, 16, 1) unchanged default is fastest (best candidate +8 … +18 %)
33 … 256 C9 – C64 (32, 8, 1) / (32, 4, 1) / (32, 8, 1) unchanged default is fastest at group 3 (candidates −1 … +3 %); groups 6 / 12 not measured
257 … 512 C65 – C128 (64, 4, 2) (32, 4, 1) −43 % / −43 % / −31 %
513 … 2048 C129 – C512 (64, 1, 2) (32, 4, 1) −53 % / −45 % / −43 %
> 2048 prefill same as bf16 unchanged not measured

Why: at small base_programs the kernel hands tiles to split programs stride-wise, so (32, 64, 4) leaves one program with a two-tile tail (65 tiles over 64 splits) that the launch waits for, whereas a 64-wide tile gives 33 tiles over 33 splits, one per program. From a few hundred programs up the kernel is gather-bound and wants resident CTAs: BLOCK_N 64 needs 68–76 KB of shared memory per CTA (3 CTAs/SM) against 35–41 KB for BLOCK_N 32 with one warp (6 CTAs/SM). With fp8 the in-kernel dequant adds per-tile work, which is why the 64-wide entries lose almost half the time there.

No region regresses beyond run-to-run noise on any geometry; every candidate passed the FP32 reference at rtol=atol=2e-2. This kernel is 3–9 % of a decode step on this model, so end-to-end throughput moves by well under 1 % with bf16 caches; with fp8 caches the change is about 6 % of a C128 decode step. All measurements are H20; H100/H200 were not available. benchmarks/kernels/benchmark_qsa_select_config.py (added here) re-measures one shape in about 10 s, so a maintainer can check the direction there:

python benchmarks/kernels/benchmark_qsa_select_config.py --requests 128 --query-len 4 --group 3 --kv-heads 1 [--kv-dtype fp8]

Duplicate check. gh pr list --repo vllm-project/vllm --state open --search "<q>" for qsa select_config, qsa sm90, qsa hopper, qsa H20, qwen4_exp sparse attention tuning: no open PR touches the sm_90 entries of this table. The open QSA PRs are #55430 (tile-union prefill kernel on SM121) and #55122 (deterministic persistent top-k), both orthogonal; #55557 (merged) added the sm_120 table this PR sits next to.

Test Plan

pytest tests/models/qwen4_exp/test_qsa_reference.py -k "select_config_regions or sm90_"
pytest tests/models/qwen4_exp/test_qsa_reference.py
python benchmarks/kernels/benchmark_qsa_select_config.py --requests 128 --query-len 4 --group 3 --kv-heads 1 --candidates 64,4,2 32,8,1 32,6,1
python benchmarks/kernels/benchmark_qsa_select_config.py --requests 1 --query-len 4 --group 3 --kv-heads 1 --candidates 32,64,4 64,64,2
python benchmarks/kernels/benchmark_qsa_select_config.py --requests 128 --query-len 4 --group 3 --kv-heads 1 --kv-dtype fp8 --candidates 64,4,2 32,6,1
python benchmarks/kernels/benchmark_qsa_select_config.py --requests 4 --query-len 4 --group 3 --kv-heads 1 --kv-dtype fp8 --candidates 32,64,4 64,64,4

In addition, on 8× H20 (TP8/EP8, MTP3, bf16 K/V, --cudagraph-capture-sizes 1 4 16 64 256 512), stock vs this PR: server start and graph capture, vllm bench serve random 8k-in/1k-out at C1 (8 prompts) and C128 (256 prompts), greedy generations with logprobs on 16 prompts, and GSM8K through lm-evaluation-harness (0-shot chat template, greedy, first 300 problems, identical prompts on both servers).

Test Result

Unit tests (H20, main fc8132a5 tree):

command result
-k "select_config_regions or sm90_" 11 passed
whole file 83 passed, 0 failed

Sweep script (H20; current is the entry this PR selects):

shape K/V this PR GB300 entry GB300 vs this PR
512 rows, group 3 bf16 (32, 4, 1) 343 µs (64, 4, 2) 387 µs +13 %
4 rows, group 3 bf16 (64, 64, 2) 12.6 µs (32, 64, 4) 14.9 µs +18 % hot / +10 % L2-evicted
512 rows, group 3 fp8 (32, 4, 1) 330 µs (64, 4, 2) 577 µs +75 %
16 rows, group 3 fp8 (64, 64, 2) 24.5 µs (32, 64, 4) 29.1 µs +18 %

Serving (8× H20, bf16 K/V; one launch per arm, so the tok/s differences are within the 1–3 % stock-vs-stock drift of this box and only show the absence of a regression):

point stock this PR failed
C1, 8 prompts 263.6 tok/s 259.1 tok/s 0 / 0
C128, 256 prompts 1649.0 tok/s 1674.8 tok/s 0 / 0

Both servers captured every PIECEWISE and FULL graph size. The patched server's Triton cache holds only the (1 warp, N32) and (2 warps, N64) attention specializations; the stock one also has (4 warps, N32), i.e. the sm_90 table is the one running.

Model evaluation. The split count changes the split-K reduction order, so greedy outputs diverge from stock at near-ties: at every divergence the top-1/top-2 margin is 0–0.75 logprob (0–6 bf16 ULPs) and the other arm's token is inside the top-5; 12 of 16 chat prompts have bit-identical first-token top-5 logprobs.

GSM8K, 0-shot chat, greedy, 300 problems flexible-extract exact match
stock 85.0 % ± 2.1
this PR 86.3 % ± 2.0

AI assistance

Claude were used for the implementation, benchmark scripts and this description. The submitter reviewed every changed line and ran the commands above.

Signed-off-by: Shuolei Wang <shuoleiwang123@gmail.com>
@mergify mergify Bot added performance Performance-related issues qwen Related to Qwen models labels Sep 17, 2026

@gau-nernst gau-nernst left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please remove the benchmark script. Also I think the unit test changes are not necessary

Comment thread vllm/models/qwen4_exp/nvidia/ops/qsa.py Outdated
Comment on lines +571 to +572
elif _is_sm90() and (sm90 := _select_sm90_config(base_programs, is_fp8)):
BLOCK_N, target_splits, num_warps = sm90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why not do BLOCK_N, target_splits, num_warps = _select_sm90_config(base_programs, is_fp8) like everyone else?

Comment thread vllm/models/qwen4_exp/nvidia/ops/qsa.py Outdated
Comment on lines +524 to +536
Measured over the full attention + split-K merge + output gate at group
3/6/12 with 1 and 2 kv heads, bf16 and fp8 K/V. Two effects drive the table.
Small batches are latency-bound and the kernel hands tiles to split programs
stride-wise, so the default (32, 64, 4) leaves one program with a two-tile
tail (65 tiles over 64 splits) that the whole launch waits for; a 64-wide
tile makes 33 tiles, the 64 requested splits clamp to 33 and every program
runs exactly one tile. From a few hundred programs up the kernel is
gather-bound and wants resident CTAs: BLOCK_N 64 needs 68-76 KB of shared
memory per CTA (num_stages=2, head_dim 256), 3 CTAs per SM, against 6 for
BLOCK_N 32 with one warp; extra warps only split an already tiny
[group, 256] x [256, BLOCK_N] dot. With fp8 caches the default entries for
bp 25..256 are already the fastest measured, and the bp > 2048 region was
not re-measured for either dtype.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trim the docstring

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All done

Signed-off-by: Shuolei Wang <948904026@qq.com>
@ShuoleiWang
ShuoleiWang marked this pull request as ready for review September 17, 2026 02:46

@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.

@gau-nernst gau-nernst left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Wonder whether H20 tuned config translates to H200. But again, probably won't matter much

@gau-nernst

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

❌ This PR is 29 commits behind upstream main. Your branch must contain every commit currently on upstream main. No new CI build was started. Merge or rebase onto the latest main, then rerun /ci run. To test this branch at your own risk, use /ci run --allow-stale.

@ShuoleiWang

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

❌ @ShuoleiWang, A reviewer with write access must run /ci run, approve the PR, or add the ready label first.

@gau-nernst

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #89521 for commit 84c54de4d4ed.

@ZJY0516 ZJY0516 added the ready ONLY add when PR is ready to merge/full CI is needed label Sep 17, 2026
@ZJY0516
ZJY0516 enabled auto-merge (squash) September 17, 2026 05:15
@github-actions

Copy link
Copy Markdown

✅ @ShuoleiWang, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Your branch must contain every commit currently on its upstream target branch. Merge or rebase onto the latest target branch, then rerun the command. Append --allow-stale to a run command to test an outdated branch at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@gau-nernst

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

❌ This PR is 1 commit behind upstream main. Your branch must contain every commit currently on upstream main. No new CI build was started. Merge or rebase onto the latest main, then rerun /ci run. To test this branch at your own risk, use /ci run --allow-stale.

@ZJY0516

ZJY0516 commented Sep 17, 2026

Copy link
Copy Markdown
Member

/ci run --allow-stale

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #89545 for commit 4ab28b7cb5fb.

⚠️ This PR is 1 commit behind upstream main. Running CI at your own risk because --allow-stale was requested; outdated CI configuration may cause failures. Before merging, merge or rebase onto the latest main, then rerun /ci run on the latest PR commit.

@ZJY0516
ZJY0516 merged commit 7bbce75 into vllm-project:main Sep 17, 2026
81 of 82 checks passed
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
…lm-project#57273)

Signed-off-by: Shuolei Wang <shuoleiwang123@gmail.com>
Signed-off-by: Shuolei Wang <948904026@qq.com>
Co-authored-by: Roger Wang <hey@rogerw.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance-related issues qwen Related to Qwen models ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants