[ROCm][Kernel] W4A16 MoE prefill: M-aware moe_align block size (1.6x TTFT) - #1069
[ROCm][Kernel] W4A16 MoE prefill: M-aware moe_align block size (1.6x TTFT)#1069stoivonen-amd wants to merge 2 commits into
Conversation
The group_size <= 64 branch of _triton_config (added in f47a359 for Qwen3-Omni) was derived from a fixed-M sweep at a single captured problem size. Two of its assumptions do not hold across the prefill batch range: - num_stages=1 was inherited from the group_size=128 branch, whose "num_stages>1 regresses (weak ROCm Triton pipeliner)" note was measured at BM=32, BN=16/64, nw=2 -- much narrower tiles than the ones this branch emits. - gemm2's (BN=64, nw=4) is not the best tile at any M swept. Tiling x M sweep on Strix Halo (gfx1151) at the Qwen3-30B-A3B-AWQ shape (group_size=32, E=128, top_k=8, alignment=128): 108 configs x 10 token counts (M_routed 48..24448) x 2 routing models -- the captured expert concentration and a uniform 128-expert spread. Configs ranked by geomean of (us / per-M best) over all 20 points, so the pick holds across the batch range rather than at the single M a fixed-M sweep sees. Score 1.000 = best at every M; "worst" is the worst-case ratio. gemm1 (K>=1024) geomean worst BM128 BN64 GM1 nw8 ns4 (new) 1.040 1.146 BM128 BN64 GM1 nw8 ns1 (old) 1.284 1.933 BM128 BN128 GM1 nw8 ns4 1.162 1.770 gemm2 (K<1024) geomean worst BM64 BN128 GM1 nw8 ns4 (new) 1.043 1.087 BM64 BN64 GM1 nw4 ns1 (old) 1.109 1.207 BM128 BN128 GM1 nw8 ns4 1.044 1.153 num_stages=4 is the one universal win -- both GEMMs, both routing models, every batch size swept. For gemm1 that is the only field that moves: BN=128 wins only near M_routed=8192 and is up to 1.77x off the best at small batches, and nw=4 ties nw=8 at 1.040 so num_warps stays. For gemm2, BM=128 ties on geomean (1.044 vs 1.043) but has a worse tail (1.153 vs 1.087), so BLOCK_SIZE_M stays at 64. These are pure tiling parameters -- no change to which kernel runs, to the alignment, or to the group_size > 64 path (byte-identical). End-to-end TTFT, vllm-bench.py on cyankiwi/Qwen3-30B-A3B-Instruct-2507- AWQ-4bit, 10 prompts, --max-num-seqs 1 --output-len 8 --no-cudagraph --dtype float16, median ms: input-len before after 128 205.11 184.84 (1.11x) 512 288.80 270.43 (1.07x) 1024 408.45 393.62 (1.04x) Improvement is consistent across mean/median/P99. Signed-off-by: Stephan Toivonen <Stephan.Toivonen@amd.com> Co-authored-by: Claude <noreply@anthropic.com>
…group_size
_select_block_size_m returns TRITON_BLOCK_SIZE_M_SMALL_GS for every
prefill batch, with no M-awareness. For a large-E model that is very
expensive at short prefill, and it dominates anything the tiling can
reach.
moe_align_block_size pads *every touched expert* to a full block, and
fused_moe_kernel_gptq_awq's only early exit is
if pid_m * BLOCK_SIZE_M >= num_tokens_post_padded: return
so an all-padding block that lands inside num_tokens_post_padded runs
the entire K-contraction -- it loads and dequantizes its full slice of
INT4 weights and only discards the result at the store, via
token_mask = offs_token < num_valid_tokens. (gemm2 does not even
discard: it is invoked with num_valid_tokens = num_slots, so its
padding rows are stored.) Kernel work is therefore proportional to
num_tokens_post_padded, not to M_routed, and
npp = sum over touched experts of ceil(tokens_e / A) * A
so halving the alignment A halves the per-expert round-up. With
E=128 a short prefill touches nearly every expert with a handful of
tokens each and almost all of npp is padding.
Measured on real routing -- topk_ids captured from live prefills of
cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit (E=128, top_k=8,
group_size=32), averaged over MoE layers 0-3:
tokens experts npp@64 npp@128 npp@128 / routed rows
6 33 2112 4224 88.0x
128 110 7120 14080 13.8x
512 122 9456 16576 4.0x
1024 122 12864 18752 2.3x
2048 124 20816 25984 1.6x
A 128-token prefill does 13.8x the GEMM work it needs to.
Alignment x tile x M sweep on Strix Halo (gfx1151), joint gemm1+gemm2
(they share one alignment, so the pair is what matters), us:
tokens A=32 A=64 A=128 best
6 1509.2 876.1 1537.3 64 (1.75x vs A=128)
128 3485.3 2619.7 4655.7 64 (1.78x)
512 4391.1 3407.5 5453.1 64 (1.60x)
1024 6264.1 4754.0 6248.9 64 (1.31x)
2048 9838.3 8256.8 8355.5 64 (1.01x)
A=64 wins at every point from 6 to 1024 tokens under all three routing
models swept (1.14-1.85x) and is a wash at 2048. A=16 is worse than
A=32 everywhere: with BLOCK_SIZE_M = A the block count is pinned to
the number of touched experts regardless of A, so the per-block INT4
weight load + dequant is roughly constant and below 64 the M-tile can
no longer amortize it. 64 is the floor, not an arbitrary halving.
The crossover is where the mechanism says it should be -- one full
block per expert, i.e. num_tokens * topk / E >= 128 -- so the tier is
selected on routed rows per expert rather than a token count, and it
tracks E and top_k on other models.
This is the alignment, not the tile. Decomposed at 128 tokens
(gemm1+gemm2, us): 5237.2 at the pre-retune config and A=128, 4755.6
at the retuned config and A=128, 4882.9 at the new tile and A=128,
2619.7 at the new tile and A=64. The new gemm1 tile is *worse* than
the previous commit's at A=128; it only pays once the alignment moves,
which is why the two land together. Keeping the A=128 tile at A=64
would give back 1.10-1.76x of the win. BN=32/nw=2 is best at all 27
(tokens x routing) points at A=64, by 1.10x to 2.13x over the runner-
up; gemm2's tile is unchanged (geomean 1.000, worst 1.004 at A=64).
Batches of 2048+ tokens take the _LARGE tier and get byte-identical
config to before this patch, so f47a359's Qwen3-Omni tune -- which
was validated at --input-len 4096 -- is unaffected. Only sub-2048
batches change, which is the regime that tune never measured. The
group_size > 64 path is untouched.
Alignment is not a numerical parameter: it regroups routed rows into
blocks, and each row does the same full-K contraction against the same
expert weights in the same order. Verified rather than assumed --
greedy decode over 3 prompts x 48 tokens (2x 128-token and 1x
512-token prefills, both on the changed tier) produces byte-identical
token ids before and after. Workspace shrinks as a side effect:
num_slots at 128 tokens 17280 -> 9088, at 1024 tokens 24448 -> 16256.
End-to-end TTFT, vllm-bench.py, 10 prompts, --max-num-seqs 1
--output-len 8 --no-cudagraph --dtype float16, median ms:
input-len before after vs previous commit vs pre-retune
128 184.84 126.39 1.46x 1.62x
512 270.43 217.29 1.24x 1.33x
1024 393.62 348.11 1.13x 1.17x
The exllama HIP path this fallback regressed against measures 121.56
ms at 128 tokens under the same settings, so the short-prefill gap is
now 4% (it was 69%). At 512 tokens the Triton path is 1.65x faster
than exllama and at 1024 tokens 2.10x. Decode is unaffected: median
TPOT 17.80 -> 17.16 at 128 tokens and 22.97 -> 22.76 at 1024.
Signed-off-by: Stephan Toivonen <Stephan.Toivonen@amd.com>
Co-authored-by: Claude <noreply@anthropic.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels. Just a reminder: PRs would not trigger full CI run by default. Instead, it would only run You ask your reviewers to trigger select CI tests on top of Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging. To run CI, PR reviewers can either: Add If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. 🚀 |
Purpose
Closes the short-prefill TTFT regression on the
hybrid_tritonW4A16 MoE path (AIESW-36448). At 128 input tokens the Triton fallback was 69% slower than the exllama HIP kernel it replaced; after this series it is within 4%, and it is already 1.65x / 2.10x faster than exllama at 512 / 1024 tokens.The cost is
moe_align_block_sizepadding, not tiling.moe_align_block_sizepads every touched expert up to a full alignment block, andfused_moe_kernel_gptq_awq's only early exit isso an all-padding block that lands inside
num_tokens_post_paddedruns the entire K-contraction — it loads and dequantizes its full slice of INT4 weights and only discards the result at the store. (gemm2 does not even discard: it is invoked withnum_valid_tokens = num_slots, so its padding rows are stored.) Kernel work is therefore proportional tonum_tokens_post_padded, not toM_routed, andWith E=128 a short prefill touches nearly every expert with a handful of tokens each, so almost all of
nppis padding. Measured on routing captured from live prefills ofcyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit(E=128, top_k=8, group_size=32), averaged over MoE layers 0–3:A 128-token prefill does 13.8x the GEMM work it needs to.
_select_block_size_mreturned a single alignment for every prefill batch with no M-awareness, so this was unavoidable.Two commits, which must land together:
Retune the small-group-size Triton tiles. The
group_size <= 64branch of_triton_config(added inf47a359c96for Qwen3-Omni) was derived from a fixed-M sweep at one captured problem size.num_stages=1was inherited from thegroup_size=128branch, whose "pipeliner regresses" note was measured at much narrower tiles; and gemm2's(BLOCK_SIZE_N=64, num_warps=4)was not the best tile at any M swept. Four fields change. No change to which kernel runs, to the alignment, or to thegroup_size > 64path.Make the alignment M-aware.
_select_block_size_mgains a second tier: alignment 64 below one full padding block of routed rows per expert, 128 at or above it. This commit also carries the gemm1 tile that pairs with alignment 64 — that tile is worse than commit 1's at the old alignment, which is why the two cannot be split.64 is a floor, not an arbitrary halving: with
BLOCK_SIZE_M = Athe block count is pinned to the number of touched experts regardless ofA, so the per-block INT4 weight load + dequant is roughly constant and below 64 the M-tile can no longer amortize it. A=16 measures worse than A=32 everywhere.The crossover sits where the mechanism says it should — one full block per expert — so the tier is selected on
num_tokens * topk / Erather than a hard-coded token count, and it tracks E and top_k on other models.Scope and safety
group_size > 64path is untouched._LARGEtier and get byte-identical config to before, sof47a359c96's Qwen3-Omni tune — validated at--input-len 4096— is unaffected. Only sub-2048 batches change, which is the regime that tune never measured.num_slotsat 128 tokens 17280 → 9088, at 1024 tokens 24448 → 16256.Test Plan
All on Strix Halo (gfx1151, Ryzen AI MAX+ 395), ROCm,
cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit, float16.1. Existing kernel suites — nothing in them is expected to change, since the touched path is MoE-only and the perf golden covers the linear path:
pytest tests/kernels/moe/test_hybrid_w4a16_moe.py pytest tests/kernels/quantization/test_hybrid_w4a16_triton.py \ tests/kernels/quantization/test_moe_gemm_w4a16.py pytest tests/kernels/quantization/test_hybrid_w4a16_perf.py2. Bit-identity. Greedy decode (
temperature=0.0,enforce_eager=True,enable_prefix_caching=False) over 3 fixed prompts x 48 output tokens, with prompt lengths 128/128/512 chosen to land on the changed tier. Token ids dumped to JSON on each build and diffed.3. End-to-end TTFT, three builds x three prefill sizes:
vllm-bench.py --model cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit \ --num-prompts 10 --max-num-seqs 1 \ --input-len {128,512,1024} --output-len 8 \ --max-model-len $((inlen + 64)) --dtype float16 --no-cudagraph--output-len 8so the decode path (num_tokens <= MAX_SKINNY_BATCH_SIZE, untouched here) is exercised too. Each run's log is asserted to have loaded the vLLM install it was supposed to, sincevllm-bench.pyspawnsvllm serveand console-script shebangs are absolute.4. Kernel-level sweeps behind the constants: alignment x tile x M, joint gemm1+gemm2 (they share one alignment, so the pair is what matters), across 6..2048 tokens and three routing distributions — a uniform 128-expert spread plus
topk_idscaptured from real prefills.Test Result
1. Existing kernel suites — all passed:
tests/kernels/moe/test_hybrid_w4a16_moe.pytest_hybrid_w4a16_triton.py+test_moe_gemm_w4a16.pytests/kernels/quantization/test_hybrid_w4a16_perf.py(Those runs also included 45 GPU-free unit tests pinning the alignment staircase and the
BLOCK_SIZE_M | alignmentinvariant, which are not part of this PR; the counts above include them.)test_hybrid_w4a16_perf.pyfailed one case (i2560-o19456-g32-hybrid-w4a16-bf16) on the first run. That case is the linear path (wvsplitk_int4/hybrid_triton_w4a16,in_features/out_features, no experts) and is unreachable from an MoE-only change; it passed 3/3 in isolation and 140/140 on a repeat full run. Treated as a thermal/ordering flake.2. Bit-identity — confirmed. The two JSON dumps (3 prompts x 48 greedy tokens = 144 tokens) are byte-identical before and after.
3. End-to-end TTFT, median ms, 10 prompts:
Improvement is consistent across mean/median/P99. Decode is unaffected — median TPOT 17.80 → 17.16 at 128 tokens and 22.97 → 22.76 at 1024.
4. Kernel-level sweeps.
Joint gemm1+gemm2 vs alignment (us), real captured routing:
A=64 wins at every point from 6 to 1024 tokens under all three routing distributions (1.14–1.85x) and is a wash at 2048, which is where the
_LARGEtier takes over.This is the alignment, not the tile. Decomposed at 128 tokens (gemm1+gemm2, us):
The new gemm1 tile is worse than commit 1's at A=128 — it only pays once the alignment moves. Keeping commit 1's tile at A=64 would give back 1.10–1.76x of the win.
BLOCK_SIZE_N=32, num_warps=2is best at all 27 (tokens x routing) points at A=64, by 1.10x to 2.13x over the runner-up; gemm2's tile is unchanged between tiers (geomean 1.000, worst 1.004 at A=64).Caveat: tuned on one GPU and one model family. The alignment tier is selected on routed rows per expert so it tracks E and top_k, but the gemm1 tile at the low alignment is a fixed choice.
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.