Skip to content

[Qwen3.5] asm FP8 FMHA for prefix-hit prefill; fuse RMSNorm+FP8 quant and tiled sigmoid-gate+FP8 quant - #2534

Merged
zhuyuhua-v merged 8 commits into
mainfrom
zhiwei/qwen35_prefill_opt
Oct 13, 2026
Merged

zhuyuhua-v merged 8 commits into
mainfrom
zhiwei/qwen35_prefill_opt

Conversation

@ZhiweiYan-96

@ZhiweiYan-96 ZhiweiYan-96 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR speeds up Qwen3.5 on gfx950, mainly in prefill.

Measured on MI355X, Qwen3.5-397B-A17B-FP8, TP4, against main (details under Results):

  • One same-shape prefill step goes from 437 to 356 ms (-18.5%).
  • At AgentX c48, per-request latency drops by a median of 28.4% when the same requests are paired.
  • Throughput per GPU at c48 goes from 29.4k to 35.5k tok/s (+20.8%).
  • The c48 baseline is from an earlier day and commit; see the caveat in Results.

New attention kernel for prefix-hit prefill.

  • Full-attention prefill with a cached prefix now runs on aiter's asm fmha_fwd_hd256_fp8_causal_group_gfx950. Before, it ran on CK FmhaFwdKernel<bf16>.
  • This covers prefix-cache hits and every chunk after the first in a chunked prefill. ATOM already called the asm kernel for prefill without a prefix.
  • Two conditions in _can_use_fp8_prefill_attention blocked it:
    • not attn_metadata.has_cached turned it off whenever a prefix was cached.
    • q.shape[0] == k.shape[0] was checked after the prefix gather, when k holds cached + new rows, so it never held.
  • Those prefills therefore fell back to BF16 flash_attn_varlen_func. Qwen3.5 at TP4 has GQA 8 per rank (8 q heads, 1 kv head), and the BF16 hd256 asm kernel needs nhead_q == nhead_k. So the fallback ran the CK kernel.
  • The asm kernel takes cu_seqlens_k longer than cu_seqlens_q and applies a bottom-right causal mask. SGLang's aiter backend uses the same kernel and the same shape of call for context-chunk prefill.

Fusions.

  • Input RMSNorm + FP8 per-group quant now run as one kernel in both full-attention and GDN layers. The wiring matches Qwen3NextDecoderLayer, and it drops one dynamic_per_group_scaled_quant per layer.
  • sigmoid(gate) * o + FP8 per-group quant for o_proj is a new tiled kernel, _fused_sigmoid_mul_fp8_group_quant_tiled_kernel.
    • It uses one program per 8 x 256 tile below 4096 rows and one per 8 x 1024 tile from 4096 rows. The per-group kernel used one program per (row, 128-column group).
    • Its output is bit-identical to the per-group kernel's.
    • The kernel runs inside a custom op, sigmoid_mul_fp8_group_quant, so the tile is picked from each call's row count. A branch inside the compiled graph would keep the size it was traced with.
  • Gemma q/k norm + RoPE + KV-cache write (triton_fused_norm_rope_cache) stays fused up to ATOM_QK_NORM_ROPE_CACHE_FUSION_MAX_TOKENS (default 8192). Above that it runs as separate kernels, because the fused kernel is slower at prefill sizes. The fused kernel also now applies Qwen3.5's interleaved M-RoPE rule (f % 3). Text-only positions were already correct; image and video positions were not.
  • The two fusions above are active only with ATOM_ENABLE_QK_NORM_ROPE_CACHE_QUANT_FUSION=1, which still defaults to off. The FMHA change, the input-norm fusion and the GDN changes below are on by default.

GDN prefill.

  • The chunk kernel now writes the attention output straight into the op's output buffer (o=). This removes a 64 MiB hipMemcpy per GDN layer at T=16k.
  • The read/write state slots (int64) and ~has_initial_state are now built once per step in the metadata builder instead of once per layer.

Results

Setup.

  • MI355X, Qwen3.5-397B-A17B-FP8, TP4, FP8 KV.
  • MTP-3 with forced acceptance length 3.39.
  • Otherwise recipes/Qwen3.5.md defaults. AITER_QUICK_REDUCE_QUANTIZATION is not set, so the prefill all-reduce runs on RCCL.

Same-shape kernel breakdown

How it was measured.

  • One kineto trace per side, rank 0.
  • Kernels in each step annotation are split into the 60 decoder layers at the all-reduce after each layer's fused MoE. Layer type comes from config.json layer_types: 15 full-attention layers and 45 GDN layers.
  • The step total is 15 x (mean full-layer time) + 45 x (mean GDN-layer time).
  • Prefill runs one 32768-token prompt with --state-checkpoint-interval-tokens -1 on both sides; without it, the 8192-token checkpoint rungs split the second chunk. Its second chunk is the annotation prefill[bs=1 tok=16368 ctx=32752].
  • Decode runs six concurrent ~130k-token prompts. It averages over 120 (main) and 123 (this PR) decode[bs=6 tok=24 d=6 spec=3] steps.
Step annotation main This PR Change
prefill[bs=1 tok=16368 ctx=32752] 15 x 11667.4 + 45 x 5818.6 us = 436.8 ms 15 x 6336.8 + 45 x 5800.8 us = 356.1 ms -80.7 ms (-18.5%)
decode[bs=6 tok=24 d=6 spec=3] 15 x 339.1 + 45 x 209.2 us = 14.50 ms 15 x 314.5 + 45 x 206.6 us = 14.01 ms -0.49 ms (-3.4%)

Prefill, by change. Per-layer values are kernel times taken from the two traces.

Change Per layer (us) Per step
FMHA: CK BF16 6249.1 -> asm FP8 1238.9, plus three .to(fp8) casts 32.4 -4978 x 15 full layers -74.7 ms
Input RMSNorm + quant -63 x 15 full layers, -19 x 45 GDN layers -1.8 ms
GDN output memcpy (27.4) + per-layer glue (34.9 -> 18.9) -43 x 45 GDN layers -2.0 ms
Tiled gate + quant: main's inductor sigmoid*o 36.9 + o_proj quant 19.8 -> 34.1 -23 x 15 full layers -0.3 ms
Norm + RoPE + cache above 8192 tokens: main's 5 kernels 89.8 -> 3 kernels 91.6 +2 x 15 full layers +0.03 ms
Sum of the above -78.8 ms
  • The measured change is -80.7 ms.
  • The remaining -1.9 ms is RCCL all-reduce time, which varies by +-370 us per layer between traces with cross-rank waiting. It is not attributed to any change.

Decode, by change. Per full-attention layer, derived from the same traces.

Change Kernels removed (us) Kernels added (us) Net (us)
Fusion flag on qk norm 4.5, cos/sin 4.2, RoPE 4.3 + 4.2, cache write 4.5, gate 4.2, o_proj quant 4.1 _fused_qkv_norm_rope_cache_kernel 4.5, tiled gate 4.0 -21.5
Input RMSNorm + quant input norm 4.3, qkv quant 4.1 fused norm + quant 4.0 -4.4
  • Sum: -25.9 us. The measured full-layer change is -24.6 us (339.1 -> 314.5).

Trace breakdown CSVs and the xlsx are on the author's machine and are available on request.

Kernel microbenchmarks

Per-rank shapes.

FMHA, prefix-hit prefill.

  • 8 q heads, 1 kv head, hd256.
  • CUDA-event wall time over 10 iterations after 3 warm-up iterations.
  • The FP8 column includes the three casts.
New tokens / context CK BF16 (us) asm FP8 (us) Speedup
16384 / 32768 5878 1408 4.18x
16384 / 131072 29653 6154 4.82x
4 seqs x 4096 / 32768 7327 1651 4.44x

Gate + quant.

  • [M, 2048] bf16; the gate is a strided view of the qkv output, as in the model.
  • GPU time from the profiler, averaged over 50 calls.
  • The tiled output is bit-identical to the per-group kernel's for M = 16 to 16384.
Rows 24 384 1024 16384
Per-group kernel (us) 3.06 6.34 12.44 147
Tiled kernel (us) 3.17 3.66 4.92 30.6

Norm + RoPE + cache: fused vs unfused.

  • CUDA-event wall time over 20 iterations, including launch.
  • This sets the 8192 threshold.
Tokens 2048 4096 8192 16384
Fused (us) 38.7 39.2 60.2 113.1
Unfused (us) 66.4 66.4 67.1 82.5

End-to-end, AgentX c48

Run setup.

  • aiperf inferencex-agentx-mvp scenario on semianalysis_cc_traces_weka_062126_256k.
  • Concurrency 48, 3600 s, seed 42, 10 warm-up requests per lane, --apply-chat-template.
  • This PR ran with ATOM_ENABLE_QK_NORM_ROPE_CACHE_QUANT_FUSION=1.
  • The main baseline is a run from 2026-10-09 on 16cc652 with otherwise identical settings.

Per request, paired on (source_trace_id, turn_index).

  • 2811 pairs whose output lengths match exactly.
  • Each row reports the median change, a bootstrap 95% CI from 2000 resamples, and a sign test.
Metric Median change 95% CI Faster in Sign test
Request latency -28.4% [-29.7%, -27.4%] 80.4% p = 9e-228
TTFT -33.3% [-35.1%, -31.9%] 75.9% p = 2e-166
Decode duration -27.2% [-28.5%, -26.3%] 77.3% p = 4e-184

Aggregate, InferenceX metrics.

Metric main This PR Change
Throughput per GPU (tok/s) 29,391 35,494 +20.8%
P90 interactivity, 1000 / p90 ITL (tok/s/user) 17.53 26.37 +50%
P90 E2E-normalized interactivity (tok/s/user) 14.12 21.97 +56%
TTFT p50 / p90 (s) 1.10 / 3.52 0.70 / 2.41 -36% / -32%
ITL p50 / p90 (ms) 29.5 / 57.1 20.6 / 37.9 -30% / -34%
Completed requests (failed) 4308 (1) 5266 (1)
  • P90 E2E-normalized interactivity is 1 / p90 of (TPOT + TTFT / OSL), computed per request.
  • In this closed-loop workload a faster server completes a different mix of trajectories, so the aggregate rows move partly because of that mix. The paired rows remove the mix effect.

Caveat on the size of the gain.

  • A busy-time estimate from the kernel breakdown predicts about a 16% gain.
    • The main run spends about 35% of its time in prefill. That comes from 8,605 uncached prompt tokens/s at an estimated 41 us/token, at the run's mean context of 98k tokens.
    • This PR cuts prefill cost per token by about 40%, to 24.8 us/token.
    • Decode steps are 3.4% faster.
  • The paired -28% is above that estimate. Two things may explain the gap: queueing in a near-saturated closed loop, and the baseline being on another commit and day.
  • A same-day main baseline on 73defd9 is pending.

Accuracy

All accuracy runs use GSM8K 3-shot via local-completions, as in recipes/Qwen3.5.md, with forced acceptance off and paired McNemar tests on the same questions.

Code Questions Flexible Strict McNemar p vs main (flexible / strict)
main 1319 0.8704 0.8537
First five commits 1319 0.8726 0.8567 0.81 / 0.70
Whole PR first 256 0.8555 0.48
  • On the same 256 questions, main scores 0.8711 and the revision just before the tiled-gate commit scores 0.8867. So run-to-run spread here is about +-1.5%.
  • Long-prefix probe: a fact placed at the start of a ~57k-token prompt is asked about three times, twice from the prefix cache. It is answered correctly on main and on this PR.

_can_use_fp8_prefill_attention refused any batch with a cached prefix
(`not has_cached`, and `q.shape[0] == k.shape[0]` evaluated after the prefix
gather), so every chunk after the first, and every prefix-cache hit, fell
back to aiter.flash_attn_varlen_func in BF16. With GQA 8 per rank (Qwen3.5 at
TP4) the BF16 hd256 asm kernel does not apply, so that meant the CK
FmhaFwdKernel.

The asm fmha_fwd_hd256_fp8_causal_group kernel already takes the gathered
cached+new K/V with cu_seqlens_k longer than cu_seqlens_q and masks
bottom-right causal, which is the same call SGLang's aiter backend makes for
context-chunk prefill. Microbench on gfx950 (8 q heads, 1 kv head, hd256):
16k new / 32k ctx 5878 -> 1408 us, 16k / 128k 29653 -> 6154 us, including the
three FP8 casts.
triton_fused_norm_rope_cache picked each rotary frequency's position by
contiguous mrope_section ranges (f < s0 -> t, f < s0+s1 -> h, else w).
Qwen3.5 sets mrope_interleaved, where frequency f takes h when f % 3 == 1 and
f < 3*section_h, w when f % 3 == 2 and f < 3*section_w, else t -- the rule
triton_mrope.py and aiter's MRotaryEmbedding already use. Text-only positions
(t == h == w) gave the same result either way; image and video tokens did not.

The kernel now takes MROPE_INTERLEAVED / MROPE_SH / MROPE_SW from
rotary_emb.mrope_interleaved and mrope_section. Against GemmaRMSNorm +
aiter MRotaryEmbedding with t != h != w positions: max |dq| 0.031, |dk| 0.031
(bf16 rounding), versus 8.97 / 8.00 with the old rule.
Qwen3_5DecoderLayer built input_layernorm without a quant config, so the norm
wrote BF16 and qkv_proj / in_proj_qkvz quantized it again in a separate
dynamic_per_group_scaled_quant launch. Qwen3NextDecoderLayer already passes the
consuming projection's layer quant config; the inherited forward routes the
FP8 output and scales to qkv_proj and to the GDN layer's x_fp8 / x_scale.

Do the same: full-attention layers take qkv_proj's config, linear-attention
layers take in_proj_qkvz's with write_bf16=True for the BF16 in_proj_ba.
GemmaRMSNorm only fuses for per_1x128, so BF16 and MXFP4 checkpoints are
unchanged.
For a pure prefill (no spec tokens) the non-spec rows are rows [0, T) of the
custom op's output, but chunk_gated_delta_rule wrote into a fresh `o` and the
merge step copied it over: a 64 MiB hipMemcpy per GDN layer at T=16384, 28.7 us
in the c16 trace (labelled gdn_state_copy there, but it is the output).

Pass that slice as `o=`, which chunk_gated_delta_rule already supports and
checks for shape, dtype and contiguity, and skip the copy when it was used.
Each GDN layer's prefill path cast the int32 read and write slot indices to
int64 for advanced indexing, ran `~has_initial_state`, and zeroed fresh states
through boolean-mask indexing: three extra launches per layer (about 12 us at
c16) that only depend on the step.

The metadata builder now also carries no_initial_state and int64 copies of
the read / write slots; the layer gathers with index_select, zeroes with
masked_fill_, and writes back with index_copy_.
… count

With ATOM_ENABLE_QK_NORM_ROPE_CACHE_QUANT_FUSION=1 both one-kernel fusions are
slower than their unfused twins at prefill sizes, though they save launches at
decode sizes. Qwen3.5 TP4 on MI355X, fused vs unfused (us):

  tokens          16    2048   4096   8192   16384
  gate + quant    21.5  21.6   41.6   78.5   147.4   vs 27.9 26.6 28.5 47.3 87.2
  norm+rope+cache 38.9  38.7   39.2   60.2   113.1   vs 64.7 66.4 66.4 67.1 82.5

Above ATOM_QK_NORM_ROPE_CACHE_FUSION_MAX_TOKENS (8192) the attention impl now
runs fused_qk_norm, the MRoPE Triton kernel and the per-token FP8 cache write
separately, writing the same SHUFFLE layout and scales. Above
ATOM_SIGMOID_MUL_QUANT_FUSION_MAX_TOKENS (2048) the gate runs as
o * sigmoid(gate) followed by per_group_quant_hip, the same scales o_proj would
produce itself. The gate choice sits in a custom op (sigmoid_mul_fp8_group_quant)
so it is made per call; inside the compiled graph a branch on M would be fixed
by the traced size.

prefill[bs=1 tok=16368 ctx=32752] per full-attention layer: 6532 -> 6417 us;
decode[bs=6 tok=24] keeps the fused kernels (316 -> 317 us). GSM8K 3-shot,
first 256: 0.8867 flexible vs 0.8711 on main (McNemar p=0.48).
The per-group kernel runs one program per (row, 128-column group), which
scales badly with rows: 6.3 us of GPU time at 384 rows and 147 us at 16384
(Qwen3.5 TP4, N=2048, MI355X). The previous commit therefore sent prefill-sized
batches to sigmoid*o + per_group_quant_hip, which still took 95 us.

Add _fused_sigmoid_mul_fp8_group_quant_tiled_kernel: one program per
BLOCK_M x BLOCK_N tile, with the same arithmetic in the same order, so its FP8
bytes and scales are bit-identical to the per-group kernel's at every size
tested. sigmoid_mul_fp8_group_quant now always uses it, picking the tile from
the row count: 8 x 256 on 4 warps below 4096 rows, 8 x 1024 on 8 warps from
there (BLOCK_N halves until it divides N). GPU time, per-group vs tiled:

  rows        24     384    1024    16384
  per-group   3.06   6.34   12.44   147
  tiled       3.17   3.66    4.92    30.6

With no fallback left to choose, ATOM_SIGMOID_MUL_QUANT_FUSION_MAX_TOKENS is
removed. In the prefill[bs=1 tok=16368 ctx=32752] trace the gate costs 34 us
per full-attention layer, down from 95 us; decode is 4.0 us, down from 4.2 us.
Copilot AI balanced review requested due to automatic review settings October 12, 2026 01:38

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2534 --add-label <label>

@ZhiweiYan-96
ZhiweiYan-96 requested a lite review from Copilot October 12, 2026 04:58

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@wuhuikx
wuhuikx requested a review from junhaha666 October 12, 2026 06:36
@ZhiweiYan-96

Copy link
Copy Markdown
Contributor Author

@junhaha666 Could you please help review the PR?

@wuhuikx

wuhuikx commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Reviewed commit 7850f2ae. I found two compatibility regressions that should be addressed before merging.

  1. [P1] Populate the new GDN metadata fields in the SGLang builder

    Location: atom/model_ops/attention_gdn.py:471–475

    The new prefill path directly consumes non_spec_state_indices_in_i64, no_initial_state, and non_spec_state_indices_i64, but this PR only populates them in the native metadata builder. SGLang's _build_gdn_metadata leaves all three at their default value of None.

    Ordinary SGLang prefill delegates to this implementation through super().forward(). On the non-FlyDSL path, ssm_state.index_select(0, None) will fail. The FlyDSL path also reaches the subsequent index_copy_, which requires the missing write-index tensor.

    Please update the SGLang metadata builder to populate these fields and add regression coverage for ordinary and chunked prefill through SGLang.

  2. [P2] Preserve BF16 KV-cache support in the long-prefill fallback

    Location: atom/model_ops/attention_mha.py:466–475

    With ATOM_ENABLE_QK_NORM_ROPE_CACHE_QUANT_FUSION=1, Gemma-norm models enter _gemma_norm_rope_then_cache() when the batch exceeds the default 8192-token threshold and uses the ASM cache layout. This helper unconditionally calls reshape_and_cache_with_pertoken_quant, without checking kv_cache_dtype.

    The existing fused kernel supports BF16 cache writes, whereas the per-token quantization interface does not support a BF16 destination cache. The cache builder also does not allocate k_scale/v_scale in BF16 mode. Consequently, Qwen3.5 / Qwen3-Next with BF16 KV cache can run smaller batches but fail once a batch crosses this threshold.

    Please branch on the cache dtype: retain the quantized writer for FP8 and use reshape_and_cache(..., kv_cache_dtype="auto", asm_layout=True) for BF16, or restrict the new fallback to FP8 caches.

Validation scope: I inspected all nine changed files and the relevant callers, and syntax checks passed for all eight changed Python files. The GitHub checks that ran passed, but those results do not rule out the two scenarios above. These findings are based on code-path and interface inspection; I did not run GPU or end-to-end reproductions because the local Python environment lacks PyTorch.

@zhuyuhua-v
zhuyuhua-v merged commit e0f5084 into main Oct 13, 2026
77 of 88 checks passed
@zhuyuhua-v
zhuyuhua-v deleted the zhiwei/qwen35_prefill_opt branch October 13, 2026 07:03
zhuyuhua-v added a commit that referenced this pull request Oct 13, 2026
Conflict in atom/models/qwen3_5.py: #2534 added the same per_1x128 input-norm
quant config and write_bf16 to Qwen3_5DecoderLayer that this branch already
has; kept this branch's version, which also carries fused_allreduce.

attention_gdn.py and qwen3_next.py merged cleanly: #2534's prefill direct
write (wrote_core_attn_out) and sigmoid_mul_fp8_group_quant rename sit next to
this branch's spec-decode o= path and reduce_results plumbing.

Signed-off-by: zhuyuhua-v <yuhzhu@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants