Repository navigation
[ROCm][Perf][Qwen3.8] Dense mono decode on 128-CU gfx950 (MI350P) - #65078
jhartika-amd wants to merge 24 commits into
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
…, 128) K1 and K2 run one resident CTA per compute unit and were laid out for a 256-CU MI355X. Make the grid width a build parameter taken from the device and add 128-CTA stage plans for 128-CU gfx950 parts (MI350P): - mono/tiling.py (pure Python): resolve_grid() picks the width from the CU count, refuses counts without a validated plan, and accepts VLLM_ROCM_QWEN38_DENSE_MONO_GRID to run a validated width narrower than the GPU. FGeo / Geo move here with the grid as a field, plus the per-stage pass plans and their invariants (checked at import for 256 and 128). - FfnBuild.grid / K1Build.grid: part of the JIT key; the 128-CTA kernels get a _g128 symbol suffix, the 256-CTA names are unchanged. A build whose grid exceeds the device's CUs raises instead of hanging. - Stages whose tasks outnumber 128 CTAs take more passes a CTA, each task computed as before: o_proj / K1 front / TP2 out_proj owners 128 + b in a second pass; in_proj_qkvz 8 groups a CTA in two passes of 4 (TP1); GDN in passes of whole tokens when a CTA has more than 24 tasks; K2 down's two whole row groups, then the split group's 8 rows; gate_up 8 whole groups and 8 columns of a split one. Barriers between passes where the next one rewrites LDS the previous one reads. - On 256 CTAs every stage is a single pass and the traced program is the same as before (offsets of 0 emit nothing; the down store keeps its original form). - dense_mono: refuse with the resolver's message instead of the < 256 CU check, and pass the grid into both build keys. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
test_amd_dense_mono_tiling.py needs no GPU, FlyDSL or torch: it replaces the device query, checks the grid choice and refusals, pins the 256-CTA plan to the layout the kernels were written for, and replays every stage's task placement at 256 and 128 CTAs to check that each task runs exactly once and that no GDN pass waits on a later one. The GPU numerics tests build for the device's grid, so they run on a 128-CU part, and on a 256-CU one with VLLM_ROCM_QWEN38_DENSE_MONO_GRID=128. README: the grid width and the 256 vs 128 stage table. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
At TP2 on 128 CTAs a pass covers 4 qkvz groups, and above 8 rows its outputs (4 groups x 16 rows x S) outnumber the 512 threads, so the rows past the first 512 were never written. Write them in rounds of THREADS (Geo.qkvz_out_rounds). Every 256-CTA build and TP1 on 128 CTAs need one round and keep the original single-round code. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…t fixes K2 is bit-exact with the stock Triton gemm_afp4wfp4 only when it uses the same K splits. The counts were fixed (gate_up 4, down 4 / 2, o_proj 4), but they come from AITER's get_splitk and config tables: AITER main splits gate_up and TP1 down in 2 and TP2 down in 1 at up to 8 rows, so the startup self-check (bit-exact) refused to start. K2 now probes the stock GEMM's split count per width once (stock_split_fields, before any graph capture) and builds with it (FfnBuild.gu_split / d_split / o_split; 0 keeps the default, so builds on an AITER that agrees are unchanged). A count other than 1, 2 or 4 refuses with the reason. Tests: enter a VllmConfig with every custom op on for the module (the references construct SiluAndMul and RMSNormGated, which assert without one, and the kernels follow the ops' ROCm kernels, not the PyTorch fallbacks); import the Gemma norm functions by name (the ops package re-exports a function that shadows the module attribute); StockSplitTest checks that the build's split counts are the stock GEMM's on random data. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ock model
With Model Runner V2 the piecewise cudagraphs of the compiled stock model
are captured by dummy steps of each width, and those dummy steps look like
pure decode, so the mono graph took them over and the stock piecewise
graphs for widths up to the cap were never captured. The first prefill or
mixed step of such a width (a 5-token prompt pads to 8) then tried to
capture at run time on the default stream and the engine died
("CUDA graphs must be captured on a non-default stream").
Skip the mono graph when the forward context's cudagraph runtime mode is
PIECEWISE. Pure decode steps run under FULL cudagraphs (or eagerly) and keep
taking the mono graph.
Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
- SPDX copyright lines in dense_ffn.py / dense_gdn.py, ruff import order and format, E741 in runs_total, and a markdown line that started with "vllm-project#57560" (read as a heading) in the mono README. - tiling: torch.accelerator.current_device_index() instead of a torch.cuda call; d_pass_tiles returns an explicit pair. - The Qwen3.8 dense mono Buildkite job also runs test_amd_dense_mono_tiling.py (CPU only). No kernel changes: the 80 compiled K1 / K2 code objects at 256 and at 128 CTAs are byte-identical before and after. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
At TP1 a CTA of the 128-CTA K1 has 6 S GDN tasks (48 at 8 rows). They ran in passes of at most 24 tasks (the widest pass of the 256-CTA build), so S > 4 took two passes, the second one's SSM state loads serialized behind the first. One pass of up to 48 compiles without spills (247 VGPRs at 8 rows, 59 KB LDS) and is faster on MI350P: K1 at 8 rows 62.0 -> 57.9 us, at 6 rows 54.4 -> 51.9 us, at 5 rows 50.5 -> 48.5 us (CUDA-graph replay). Outputs are bitwise identical to the multi-pass build (checked at 5 and 8 rows against one token a pass). GDN_PASS_TASKS is now per TP: 48 at TP1, 24 at TP2 (one TP2 pass of 48 compiles but spills up to 127 SGPRs; not measured). The 256-CTA builds are unchanged (byte-identical code objects); the CPU tests cover the pass split at smaller caps. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
TP1 on one MI350P, same image for both arms, MTP off: throughput and TPOT at 8k / 1k concurrency 1-8 with row caps 8 and with the default caps, and GSM8K / GPQA at 8 concurrent requests. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
The MI355 job runs test_amd_dense_mono.py a second time with VLLM_ROCM_QWEN38_DENSE_MONO_GRID=128, so the 128-CTA (MI350P) builds are covered on the existing 256-CU runner. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
This pull request has merge conflicts that must be resolved before it can be |
…nd NIAH The MI350P throughput table now compares against the same image with the flag unset. Adds RULER NIAH 64k / 128k and GSM8K in the model card's sampled setting (three seeds per arm). Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
K1's state-slot mapping (copy, then null slot 0 -> -1) ran for every GDN layer: three small launches x 48 layers a decode step. GDN layers of a KV-cache group share the indices, so map each group's once, starting over at the first GDN layer of a step. On MI350P at one request this removes 144 launches and 0.5 ms of GPU time a step (8.6 -> 8.1 ms). Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
On 128 CTAs K1's front ran the input norm through 160 owner tasks (a second pass on CTAs 0..31) and two grid-wide hand-offs: the sum-of-squares partials, then the quantized x blocks. Up to 4 rows every CTA now runs the norm on the whole rows itself with K2's run_norm, which reduces in the owners' order, so x is bit-identical (in_proj_qkvz stays bit-exact with the stock GEMM) and no CTA waits on another before in_proj. Wider steps keep the owners: the kept rows spill there. K1 on MI350P (CUDA-graph replay): 30.3 -> 27.6 us at 1 row, 35.1 -> 32.3 us at 2, 43.5 -> 40.5 us at 4. The 256-CTA code objects and every K2 code object are unchanged. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
VLLM_ROCM_MONO_DECODE refused speculative decoding because K1 takes one token a request. K2's rows are independent, so at TP1 the mono graph now runs with K1 off: each GDN layer is the compiled stock layer up to out_proj's input (a view of the module whose out_proj passes its input through), and K2 takes the verify step's rows with out_proj folded. TP2, whose graph runs K1 on every GDN layer, still refuses. Also fixes the TP1 path for a GDN layer above K1's row cap: the hook that should skip out_proj there was never installed, so K2 got out_proj's output instead of its input. It is now a context manager around the eager stock call. MI350P, MTP-3, one request, same image: 5.42 -> 5.05 ms a token, the acceptance rate unchanged. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ecode K1's strict test failed on this stack (about one bf16 ulp in a few percent of the outputs). Three causes, none in the 128-CTA decomposition: - beta: K1 rounded sigmoid(b) to bf16, as the stock packed-decode kernel did before vLLM vllm-project#53877 ("Keep packed GDN decode beta in FP32"); it now stays fp32. This also applies to the 256-CTA build. - gated norm: the stock RMSNormGated launch takes two rows a program once S x nv rows exceed 2 x the GPU's CUs (FLA calc_rows_per_block), which changes its reduction: a thread's 4 columns as two pairs added elementwise, then 32 threads with xor 16, 8, 4, 2, 1. On 128 CUs that is 6 rows and up at TP1 (11 and up at TP2). K1Build.norm_rows (from the device's CUs, tiling.gated_norm_rows) selects that reduction. - TP2 out_proj: K1 hard-coded 4 K splits; the installed AITER's are now probed (K1Build.o_split, as K2's). Current AITER splits it in 3, which K1 cannot reproduce, so dense_mono declines TP2 with K1's out_proj and the oproj test skips those widths. On MI350P: test_amd_dense_mono.py 9 passed, 167 subtests passed (K1 TP1 at every width 1-8 and TP2 with stock out_proj now bit-exact), 16 skipped. K2 code objects unchanged. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
With speculative decoding K1 was off: a verify step runs each request's 1 + k tokens through the GDN conv and recurrence in order, from the state after its last accepted token, with a state slot a token and a widened conv state. K1Build.spec now does that: a GDN task is a request's tokens; the conv slides its window from offset n_acc - 1 and rewrites the widened state; the recurrence starts from slot idx[r, n_acc - 1] and stores each token's state to idx[r, j]; null slots (<= 0) are skipped. The accepted counts travel in the w_os argument (unused without out_proj), so the kernel ABI and every non-spec code object are unchanged. dense_mono takes vLLM's persistent spec-decode buffers as they are (no per-step copies) and enters only whole verify steps (requests x (1 + k) == rows); warm-up and other steps run stock. A startup self-check compares K1 on verify steps with the stock spec-decode kernels and fp32. Against the stock spec path the conv state, res_out and the slots written are exact; the recurrence keeps the decode kernel's reductions, so its outputs agree with fused_sigmoid_gating_delta_rule_update within fp32 rounding (DenseGdnSpecTest). MI350P, MTP-3, one request: 5.42 (MTP) -> 5.05 (K2 only) -> 4.50 ms a token, acceptance unchanged. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ck spec path The stock spec-decode recurrence (fused_sigmoid_gating_delta_rule_update) launches 4 warps; its q / k l2-norm sums an element a lane over two waves (each wave in the DPP order, then the two added), where the decode kernel K1 mirrors sums two elements a lane over one wave. Read from its TTGIR; the state reductions, gates and update already match. K1's spec path now reduces that way, and DenseGdnSpecTest asserts exact equality: res_out, core, the conv state and every state slot, 1 and 2 requests of 4 tokens and a padded request. Non-spec code objects unchanged. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ing (TP1) With MTP-3 a verify step is 4 rows a request. On MI350P, K1 and K2 beat the stock path on verify steps of one and two requests (4 and 8 rows): 8k / 1k output throughput +19.3 % at one request and +10.2 % at two with caps 8, against +3.7 % at two with the default caps (4), which leave those steps stock. Plain decode steps keep the 4-row default. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
591a94d to
068b383
Compare
…rify steps 8k / 1k output throughput with MTP-3, flag unset vs set (K1 and K2 on verify steps, caps 8): +19.4 / +19.7 % at one request, +10.2 % at two, unchanged at four and eight. GSM8K in the model card's setting at one request: thinking +0.53 +- 0.67, non-thinking -0.68 +- 0.80 points, paired. The README no longer says speculative decoding runs stock. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ernel's numerics Main serves ROCm MTP verify steps with the fused Triton GDN decode kernel (fused_gdn_decode_post_conv_mtp, vllm-project#61855) where it applies, in place of the sigmoid-gated delta rule kernel + RMSNormGated that K1's spec mode reproduces. Launched, that kernel keeps the decode kernel's state layout (4 columns a lane, 32 lanes a row), so the recurrence is unchanged; two reductions differ: - q / k l2 norm: an element a lane over two warps, each warp summed with DPP row_shr 8..1, row_bcast 15 / 31 and the first step contracted into an fma by the upper lane (scan_dot), the two warp sums added. - gated norm: the sum of squares of the bf16 o in tiles of 32 rows (rows 16 apart contracted, then xor 8, 4, 2, 1), the four tiles added (t0 + t2) + (t1 + t3). K1Build.spec_fused selects them; dense_mono sets it when the GDN layers run verify steps on the fused kernel (enable_fused_gdn_spec_decode), and the startup self-check then compares against that kernel. stock_fields refuses launches the fused kernel would tile wider than 32 rows (requests x value heads >= CUs; not reachable within the 8-row caps at TP1). DenseGdnSpecTest::test_verify_steps_fused (skipped where vLLM lacks the kernel) is bit-exact on MI350P against it on a nightly stack: core, every state slot, the conv state, res_out and a padded request, 1 and 2 requests x 32 draws; test_verify_steps still passes there and on the base stack. All other code objects are unchanged (80 / 80 at 128 and 256 CTAs, and the non-fused spec builds). Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
…ive decoding Reverts the 8-row default caps under speculative decoding. Whether K1 + K2 beat the stock path on an MTP-3 verify step of two requests (8 rows) depends on the stock MXFP4 decode GEMM: with the AITER ASM GEMM, 8k / 1k at two requests ran +10.2 % with caps 8 on MI350P, but with AITER's FlyDSL A4W4 decode GEMM the same steps ran about 5 % slower than stock (412.6 / 407.4 vs 432.8 / 433.4 tok/s; caps 4: 431.8). One request (4 rows) gains on both (+19 % / +6 % output throughput) and is inside the default caps. VLLM_ROCM_QWEN38_DENSE_MONO_K1_MAX_ROWS / _K2_MAX_ROWS=8 still select the wider steps; the README gives both measurements. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
… rows) With o_proj folded, K2's norm took two grid-wide hand-offs after the o_proj owners: the sum-of-squares partials to the owners, then each owner's fp4 x to every CTA. On 128 CTAs up to 4 rows the owners now publish their bf16 o_proj outputs once and every CTA gathers the rows, adds the residual from memory and runs the norm itself with run_norm (the reduction order the owners used), as K1's front does. Bit-exact: DenseFfnTest passes. MI350P, one launch: 66.9 -> 65.5 us at 1 row, 69.9 -> 67.0 at 2, 70.2 -> 69.2 at 4. At 5-8 rows the owners stay faster (72.9 vs 73.4 us at 5, 78.1 vs 79.8 at 8), so wider steps keep them. Only those four 128-CTA builds change; the 256-CTA code objects and the other 128-CTA builds are byte-identical, no spills. Signed-off-by: Jouni Hartikainen <jhartika@amd.com>
Overview
Run #61573's Qwen3.8-27B dense mono decode (K1: GDN mixer, K2: MXFP4 FFN) on 128-CU gfx950 (MI350P), and on MTP verify steps. Stacked on #61573: the first two commits are #61573's, review the rest. Draft until #61573 lands.
Claims
Validation
Setup: 1x MI350P (128 CUs), TP1, vLLM v0.32.0 + AITER main with the MI350P enablement PRs, FlyDSL 0.4.1, this branch's
vllm/files overlaid. Before is the same image with the flag unset.VLLM_ROCM_USE_AITER=1 VLLM_ROCM_USE_AITER_FP4_ASM_GEMM=1 VLLM_ROCM_MONO_DECODE=1 \ vllm serve amd/Qwen3.8-27B-Quark-AWQ-MXFP4 --tensor-parallel-size 1 \ --attention-backend ROCM_AITER_UNIFIED_ATTN --gdn-prefill-backend aiter_flydsl --kv-cache-dtype fp8 \ --reasoning-parser qwen3 --max-model-len 262144 --max-num-seqs 128 --max-num-batched-tokens 32768 \ --gpu-memory-utilization 0.92 --no-enable-prefix-caching # MTP-3: add --speculative-config '{"method":"mtp","num_speculative_tokens":3}'Performance
sglang.bench_serving, random, 8k / 1k, range ratio 0.8; 8 / 16 / 32 / 64 prompts at concurrency 1 / 2 / 4 / 8. Default row caps (4).A second prompt seed at concurrency 1 agrees within 1.2 pt. Above the caps nothing changes: concurrency 16 / 32 / 64 +0.4 to +0.5 %, 128k input at 4 / 8 +1.6 / -0.1 %.
Accuracy
niah_single_264k / 128k, 50 samplesMTP off at 8 concurrent with row caps 8, so K1 and K2 produce nearly every token; MTP-3 one request at a time. Model card setting: all 1319 questions, sampled (thinking: temperature 1.0, top_p 0.95, top_k 20; non-thinking: the card's
gsm8k_nothinktask), the same request seed in both arms of a pair.Tests
test_amd_dense_mono.pyon MI350P: 10 passed, 231 subtests (K2 TP1 / TP2, K1 TP1 / TP2, K1 on verify steps, all bit-exact). 16 TP2 cases with K1's out_proj skip (below), and the fused-MTP test skips on this base; on a recent nightly with #61855 it passes too (11 passed, 295 subtests). Compile-only (COMPILE_ONLY=1 ARCH=gfx950): all 80 K1 / K2 configurations at 128 and 256 CTAs, no VGPR spills or scratch. The MI355 Buildkite job also runs the GPU tests withVLLM_ROCM_QWEN38_DENSE_MONO_GRID=128.Details
Why #61573 does not run on 128 CUs. K1 and K2 run one CTA per CU, all resident, exchanging data through spin-waited mailboxes, so the grid must equal the CU count; #61573 hard-codes 256 (a 256-CTA grid deadlocks on 128 CUs), and several stage layouts assume one task per CTA.
Change.
mono/tiling.py(new, pure Python) picks the grid from the CU count (256 or 128, else a clear error) and holds the per-stage pass plans with their invariants. The grid is a build-key field; 128-CTA kernels end in_g128.K1Build.spec_fused).Limitations.
VLLM_ROCM_QWEN38_DENSE_MONO_OPROJ=0.Not a duplicate. Closest is #63341 (DSv4.1 mono, draft), which refuses 128 CUs pending the task decomposition this PR does for the Qwen3.8 dense kernels.
This PR was created with AI assistance.
Pull Request Checklist
I used vLLM's
/pr-checklistskill. (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.