Skip to content

[AMD] Optimize Qwen3.5 MTP unified attention on gfx950 - #36330

Merged
HaiShaw merged 6 commits into
sgl-project:mainfrom
yichiche:perf/gfx950-qwen35-mtp-unified-attention
Aug 27, 2026
Merged

HaiShaw merged 6 commits into
sgl-project:mainfrom
yichiche:perf/gfx950-qwen35-mtp-unified-attention

Conversation

@yichiche

@yichiche yichiche commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Qwen3.5 target verification on MI355X uses a short multi-token query (max_seqlen_q <= 4) with 16 query heads, one KV head, head size 256, page size 16, and FP8 KV cache. AITER's generic 3D unified-attention configuration processes one query token per workgroup and reloads the same paged KV data for each speculative token, leaving this latency-sensitive path substantially slower than the comparison implementation.

Modifications

  • Add an SGLang-owned Triton 3D unified-attention kernel and segment-reduction kernel specialized for the measured gfx950 Qwen3.5 MTP shape.
  • Process two speculative query tokens per workgroup with BLOCK_M=32 and BLOCK_Q=2, reusing each loaded KV tile across both tokens.
  • Use TILE_SIZE=32 and select 8-64 segments using both CU parallelism and per-segment KV work.
  • Dispatch to the specialized kernel only for gfx950, BF16 query, FP8 KV, 16 query heads, one KV head, head size 256, page size 16, non-sliding attention, and no softcap or attention sinks. All other shapes retain the existing AITER path.
  • Add an AMD unit test comparing the SGLang kernel against AITER for mixed speculative query lengths.

Accuracy Tests

IL70000 / OL300: gsm8k

Validation Result
End-to-end accuracy 0.927
End-to-end invalid rate 0.003

GPQA (/raid/models/amd-Qwen3.5-397B-A17B-MXFP4, 198 questions, repeat 8)

Validation Result
Mean score 0.879
Reference (Qwen/Qwen3.5-397B-A17B) 0.884
Output throughput 714.3 token/s

Benchmarking and Profiling

MI355X microbenchmark configuration: batch 20, query length 4, 16 query heads, one KV head, head size 256, page size 16, BF16 query, and FP8 KV cache. Values are kernel-path latency measured with triton.testing.do_bench after warmup.

KV length AITER baseline SGLang optimized Speedup
8,192 117.5 us 51.3 us 2.29x
40,960 367.5 us 186.4 us 1.97x
89,088 684.1 us 347.6 us 1.97x

Decode-phase trace at input length 70,000. Before is kernel_unified_attention_3d (TILE_SIZE=16, NUM_SEGMENTS=128); after is qwen35_mtp_unified_attention_3d (TILE_SIZE=32, NUM_SEGMENTS=64).

Measurement Concurrency 4 (us) Concurrency 64 (us)
Attention kernel, per layer 137.4 -> 85.9 (-37.5%) 140.3 -> 86.5 (-38.3%)
Decode total 12,218 -> 11,285 (-7.6%) 12,146 -> 11,043 (-9.1%)

The 51.5 us and 53.8 us saved per layer, across the 15 attention layers on this path, show up as a 933 us and 1,103 us drop in total decode time.

End-to-end serving on MI355X, TP2, input length 70,000 and output length 300 (tok/s/gpu is total tok/s divided by 2):

Concurrency Median E2E (ms) Total tok/s tok/s/gpu Median TTFT (ms) Median TPOT (ms)
4 13,989 -> 13,522 (-3.3%) 19,908 -> 20,490 (+2.9%) 9,954 -> 10,245 (+2.9%) 6,283 -> 5,842 (-7.6%) 27.49 -> 23.80 (-13.4%)
8 26,431 -> 25,765 (-2.5%) 19,885 -> 21,784 (+9.6%) 9,942 -> 10,892 (+9.6%) 6,759 -> 5,845 (-13.5%) 65.68 -> 64.50 (-1.8%)
16 51,799 -> 50,534 (-2.4%) 21,173 -> 21,801 (+3.0%) 10,586 -> 10,900 (+3.0%) 9,770 -> 9,425 (-3.5%) 129.76 -> 136.77 (+5.4%)

End-to-end latency, throughput, and TTFT improve at every concurrency level tested. Median TPOT regresses 5.4% at concurrency 16; the cause has not been isolated yet, though median end-to-end time still drops 2.4% there.

The same run reports 368.547 s total latency and 613.599 output token/s.

Checklist

Review Process

  1. Ping Merge Oncalls to start the PR flow. See the PR Merge Process.
  2. Get approvals from CODEOWNERS and other reviewers.
  3. Trigger CI tests with comments or contact authorized users to do so.
    • /tag-run-ci-label, /rerun-failed-ci, /tag-and-rerun-ci
  4. After green CI and required approvals, ask Merge Oncalls to merge.

CI States

Latest PR Test (Base): ⏳ Run #33045741639
Latest PR Test (Extra): ❌ Run #33045741325
Latest PR Test (AMD ROCm 7.2): ⏳ Run #33045741649

@yichiche yichiche added the run-ci CI: run the baseline test suite on this PR label Aug 25, 2026
@yichiche
yichiche force-pushed the perf/gfx950-qwen35-mtp-unified-attention branch from ec8ba3d to 2e742d3 Compare August 25, 2026 13:53
Record in a module docstring which launch parameters this fork changes and
why, so the next reader does not have to diff against ROCm/aiter to find out
that block_m=32 is tied to the 16:1 GQA ratio rather than to head_dim 256.

Derive num_queries_per_kv in the dispatch gate instead of pinning
tp_q_head_num, and move the rationale above the condition.
@yichiche
yichiche force-pushed the perf/gfx950-qwen35-mtp-unified-attention branch from 9f9633f to 90d832d Compare August 25, 2026 16:08
@1am9trash 1am9trash added the amd label Aug 26, 2026
… counts

The dispatch gate already derived num_queries_per_kv, but paired it with an
absolute tp_v_head_num == 1 while the wrapper asserted num_query_heads == 16
and num_kv_heads == 1. Only the tp_v_head_num clause kept that assert from
firing, so a 16:1 layout with more than one kv head (Qwen3.5-397B-A17B at TP1:
32 q / 2 kv) silently fell back instead of taking the kernel.

The kernel is kv-head generic: kv_head_idx = program_id(1) drives the Q head
offset and both cache loads, the segment buffers stride on num_query_heads,
and the descales are per-tensor scalars. What is fixed is the 16:1 ratio, via
block_q = block_m // num_queries_per_kv = 2.

Assert the ratio in the wrapper, and gate on tp_k_head_num == tp_v_head_num,
which is the wrapper's real precondition -- K and V get separate views but the
kernel has a single kv-head grid dim.

Covered by a 32 q / 2 kv case in test_aiter_unified_attention_mtp; both it and
the existing 16 q / 1 kv case match upstream unified_attention at 1e-2.
@yichiche
yichiche force-pushed the perf/gfx950-qwen35-mtp-unified-attention branch from 75257a6 to f62ee5d Compare August 26, 2026 09:00

@1am9trash 1am9trash left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM.

is_gfx95_supported()
and 1 < self.forward_metadata.max_q_len <= 4
and max_kv_len > 512
and num_queries_per_kv == 16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi, @yichiche
The backend uses num_queries_per_kv == 16, but the wrapper still has assert num_query_heads == 16 and num_kv_heads == 1.
Are these asserts necessary? Can this kernel handle kv heads > 1, or is it tp2-only?
Right now Qwen-3.5 tp1 (32 q_head / 2 kv_head) may enter the kernel path but hits the assert fail.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks. Comment resolved.
Changed in f62ee5d.
Extended the kernel usage to q/kv ratio = 16 cases. Also added test coverage to q/kv=32/2 situation.

@yichiche yichiche Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Update the assert to accommodate the general case where num_queries_per_kv == 16, either with num_query_heads = 32 and num_kv_heads = 2 (TP2) or num_query_heads = 16 and num_kv_heads = 1 (TP1):

  assert num_query_heads % num_kv_heads == 0 
  assert num_queries_per_kv == 16

@yichiche

Copy link
Copy Markdown
Collaborator Author

@amd-bot ci-status

@amd-bot

amd-bot commented Aug 27, 2026

Copy link
Copy Markdown

@yichiche

CI Status for PR #36330

Merge verdict: Safe to merge from a correctness standpoint — this PR's changed code is exercised and passed. The new gfx950 kernel + its unit test ran on real MI35x hardware (stage-b-test-1-gpu-small-amd-mi35x, job ✅) and both test methods passed. Every executed failure is infra/network/environment noise unrelated to this AMD-only change (dataset-download timeouts, git RPC reset, missing shared libs, NPU build gap). Two caveats: the standard AMD suite did not get a clean run (widespread network timeouts), and the PR Test Extra pipelines never ran (gated off — see below), so a rerun is advisable before merge to get clean signal.

Note

The PR's value (a gfx950-only Triton MTP kernel behind a strict shape gate in aiter_backend.py) is covered: test/registered/amd/test_aiter_unified_attention_mtp.py (suite stage-b-test-1-gpu-small-amd-mi35x) ran on linux-mi35x-gpu-1 (gfx950) and passed both test_matches_aiter and test_matches_aiter_multi_kv_head. Non-matching shapes fall through to the unchanged original unified_attention path.

Warning

PR CI is incomplete. PR Test Extra and PR Test Extra (AMD) fast-failed at call-gate / pr-gate because the PR lacks the run-ci-extra label (log), so all extra jobs were skipped — not tested. Also, the standard AMD suite (run 32950738914) is polluted by ~13 infra timeouts; rerun to confirm a clean AMD pass.

Changed files: aiter_unified_attention_mtp.py (new, +742), aiter_backend.py (+47), attention/__init__.py (+1), test_aiter_unified_attention_mtp.py (new, +113)

Executed CI failure attribution: AMD: 13 executed failures (0 related) · Others: 3 executed failures (0 related) · Extra pipelines: not run (gate-skipped, not counted)

AMD Executed Failures

Job Test File Test Function Error Related? Why
small-amd (11) test/registered/attention/test_triton_sliding_window.py setUp/dataset load URLError: Connection timed out → retry() exceeded 🟢 mmlu dataset download timeout; not PR code
small-amd (0) test/registered/moe/test_torch_compile_moe.py dataset load URLError: Connection timed out 🟢 network infra; unrelated MoE test
small-amd (4) N/A (git checkout) N/A RPC failed; curl 56 Connection reset → exit 128 🟢 git fetch infra failure
large-amd (0) test/registered/quant/test_awq.py dataset load URLError: Connection timed out 🟢 network infra
small-amd-nondeterministic test/registered/models_e2e/test_vlm_models.py dataset load retry() exceeded 🟢 network infra
mi35x-8gpu-disagg test/registered/amd/disaggregation/test_disaggregation_basic.py — Memory access fault by GPU → retry() exceeded 🟢 generic disagg model; PR's shape gate not triggered (no 16:1/head256/MTP path); likely pre-existing/flaky on this runner
wait-for-stage-b-amd + pr-test-amd-finish N/A N/A Aggregators reflecting shard failures above 🟢 cascade of the infra failures

(Other failed small/large shards 1,7,8,10,12,13 share the same Connection timed out dataset-download signature — collapsed.)

Other Executed Failures

Job Test File Test Function Error Related? Why
PR Test (NPU) base-b-test-4-npu-a3 test/registered/npu/basic_function/HiCache/test_npu_hicache_mla.py — ModuleNotFoundError: No module named 'sgl_kernel' 🟢 NPU build/env gap; PR touches no NPU code (rest of NPU jobs fast-fail cascade off this)
PR Test (XPU) stage-b-test-1-gpu-xpu N/A (import) N/A OSError: libavutil.so.57: cannot open shared object file 🟢 torchcodec/ffmpeg missing in XPU image
PR Test (Xeon) build-test (gnr) test/registered/cpu/test_intel_amx_attention_backend_a.py mmlu eval HTTP Error 403: Forbidden 🟢 dataset download infra; CPU test unrelated to AMD change

Details / what to do before merge

  • No action needed for correctness — the load-bearing gfx950 kernel test passed on the right hardware.
  • Get clean AMD signal: rerun PR Test ROCm 7.2 (AMD) (run 32950738914); the failures are dataset-download Connection timed out / git RPC resets that typically clear on retry.
  • Extra coverage: if extra AMD/CPU coverage is wanted, add the run-ci-extra label (e.g. /tag-and-rerun-ci extra) so PR Test Extra* actually run — right now they are gate-skipped and prove nothing.
  • NPU/XPU/Xeon reds are pre-existing environment issues (missing sgl_kernel, missing libavutil, 403 on dataset host), not introduced by this PR.

Generated by amd-bot using Claude Code CLI

The module holds a Qwen3.5 MTP-verify specialization of the Triton
kernel_unified_attention_3d launch; name it after the kernel it
specializes rather than after AITER.
…_qwen35

Match the module name, so the entry point reads as the qwen35 variant of
unified_attention_3d_mtp.
The kernel is selected by shape, not by model, so no identifier should
name one: unified_attention_3d_mtp_qwen35 -> unified_attention_3d_mtp_func,
use_qwen35_mtp_kernel -> use_unified_attention_3d_mtp, and the Triton
kernel/repr names lose their qwen35_mtp prefix. Qwen3.5-397B-A17B at
TP1/TP2 being the only config that matches today is now stated in
comments, where it belongs.
@HaiShaw
HaiShaw merged commit c967cd1 into sgl-project:main Aug 27, 2026
88 of 136 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

amd jit-kernel run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants