Skip to content

[Perf] [AMD] qwen3: Fuse the 1x128 FP8 input quant into RMSNorm and SiLU-and-mul for block-FP8 linears on gfx950 - #48043

Open
zengleixin-amd wants to merge 1 commit into
sgl-project:mainfrom
zengleixin-amd:feature/leixin/qwen3-blockfp8-quant-fusion
Open

zengleixin-amd wants to merge 1 commit into
sgl-project:mainfrom
zengleixin-amd:feature/leixin/qwen3-blockfp8-quant-fusion

Conversation

@zengleixin-amd

@zengleixin-amd zengleixin-amd commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

On gfx950 with aiter, block-FP8 dense linears run the bpreshuffle blockscale GEMM, which takes a 1x128 group-quantized FP8 input. In a Qwen3 decoder layer each such input is quantized by a separate kernel after the op that produces it: after the input RMSNorm (qkv_proj), after the post-attention RMSNorm (gate_up_proj) and after SiLU-and-mul (down_proj). Each quant reads back a bf16 tensor that was just written and adds a launch. Qwen3.5 already reads its attention input through the layer boundary's NormQuantReadout (aiter fused_rms_fp8_group_quant); Qwen3 does not.

Modifications

  • models/qwen3.py: in decode, the attention and FFN input norms request the fp8 quant format when the following linear runs the aiter bpreshuffle block-FP8 GEMM (aiter_takes_fp8_group_input), on gfx95 with aiter, with bf16 activations (the GEMM returns bf16 for a pre-quantized input), and under the same runtime gate as the SiLU-and-mul fusion (fp8_group_quant_fusion_allowed(): not while torch.compile traces, nor under deterministic inference, an RL on-policy target, batch-invariant mode or LoRA). The FFN boundary declares read=NORM_QUANT_READOUT. The attention norm stays unfused when post_residual_addition is passed (Qwen3-VL deepstack), because the fused path does not apply it. The token count and the aiter fused KV-write dtype check accept the (fp8, scale) tuple. A norm requests the quant only where its stage's entry reads last, on the rows the stage computes (StageBoundary.reads_quantized): under attention DP the FFN entry gathers after its read, so the FFN norm stays unfused there. quant_format is passed to prepare only when non-empty, so every other configuration calls the boundary exactly as before.
  • layers/quantization/fp8_activation.py: on the same gfx95 bpreshuffle path, DenseSiluAndMulQuant (used by the Qwen2, Qwen3 and Llama dense MLPs) runs SiLU-and-mul and the 1x128 quant in one kernel. Its runtime conditions moved into fp8_group_quant_fusion_allowed(), shared with the fused norm, without changing them.
  • kernels/ops/gemm/smallm_fp8_blockscale_gfx950: the gfx950 small-M block-FP8 GEMM quantizes its bf16 input itself and is skipped for a pre-quantized one. prefers_bf16_input(M, N, K) reports, per listed shape, the row counts at which it on a bf16 input beats the bpreshuffle GEMM on a pre-quantized input (measured on MI355X, below), and both producers (the fused norm, through aiter_prefers_bf16_input in fp8_activation.py, and the fused SiLU-and-mul) keep their output in bf16 there. For Qwen3 this only touches qkv_proj of Qwen3-0.6B and Qwen3-1.7B at TP1, up to 16 rows. The decoder takes that row count from positions (one entry per token; the layer input can be an opaque OwedOutput under TP > 1) and reads it only past the decode, gfx95 aiter and runtime gates.
  • New Triton kernel kernels/ops/quantization/silu_mul_fp8_group_quant.py. It rounds like SiluAndMul (silu to bf16, then the product to bf16) and quantizes like aiter's per-1x128 quant, so its output is identical to the unfused path.
  • layers/layer_boundary/stage.py: new StageBoundary.reads_quantized(forward_batch), and the prepare docstring states which reads take quant_format, and that it is a request: a fusion that completes the sum with the read (e.g. the aiter / custom all-reduce + RMSNorm fusion under TP > 1) returns the bf16 input, which the GEMM then quantizes itself.
  • layers/layer_boundary/prepare.py: reads_quantized(entry) (only _reduce_update_read / _update_read steps, with no input move after them, hand on a quantized read). The six entry steps that cannot quantize (_reduce_update_read_dp_gather, _dp_gather_sum_read, _attn_tp_reduce_scatter_update_read, _attn_tp_slice_update_read, _tp_reduce_scatter_update_read_gather, _tp_sum_with_residual_read) accept quant_format and raise NotImplementedError for a non-empty one.
  • Tests: new test/registered/kernels/ops/quantization/test_fused_norm_act_fp8_group_quant_gfx95.py (stage-b-test-1-gpu-small-amd-mi35x); test_boundary_output_contracts.py gets the new _quant_formats hook on its layer stub, a regression test that runs Qwen3DecoderLayer.forward into the FFN entry bound under attention DP (prefill, and a decode that would otherwise fuse; it failed with TypeError: _reduce_update_read_dp_gather() got an unexpected keyword argument 'quant_format' before the fix), and a check that the decode reads stay quantized without attention DP and the FFN's turns off with it; the attention-DP test also asserts that decode still sends quant_format="fp8" to the attention read; a gate test checks that bf16 fuses while fp16 and torch.compile tracing do not (both failed before the gate change); the kernel test adds the TP8/TP4 per-rank intermediate sizes 2176 and 1536, whose group counts leave a partial last program. A test checks that the Qwen3-0.6B qkv_proj norm stays unfused at 16 rows and fuses at 17 rows and with SGLANG_ROCM_SMALLM_FP8_BS=0, with gate_up_proj fused throughout. A two-layer test runs Qwen3DecoderLayer.forward on real attention / FFN stages under attention TP 2, where each layer hands on an OwedOutput, in prefill and in a decode that checks the fusion.

Accuracy Tests

MI355X (gfx950), image lmsysorg/sglang-rocm:v0.5.22-rocm10-mi35x-20261025 (torch 2.11.0+rocm10, aiter 285579b6), SGLANG_USE_AITER=1, Qwen3-14B-FP8, TP1.

Kernel outputs. The fused SiLU-and-mul quant equals the unfused path exactly (fp8 values and scales) for the Qwen3-14B and Qwen3-1.7B intermediate sizes at M = 1 to 512; the test asserts equality. The fused norm is aiter's fused_rms_fp8_group_quant, which quantizes the fp32 normalized value directly while the unfused path rounds it to bf16 first. On 112 batch sizes (M = 1 to 64, every CUDA-graph decode size up to 512, a few odd sizes) and on 1,600 real decode inputs dumped from the server, about 2.2% of the fp8 values differ by one e4m3 step, scales differ within the bf16 rounding of the group amax, and the residual output is identical. The test bounds this (at most one step, at most 5% of values).

GSM8K (python -m sglang.test.few_shot_gsm8k --num-questions 1319, greedy):

Base main This PR Questions main-only right / PR-only right (majority over runs) McNemar exact p
68e57f4005, 12 runs each 0.9012 0.8922 31 / 21 0.21
d74749b0b6, 3 runs each 0.9020 0.8910 32 / 19 0.09

Accuracies are the eval's own; the per-question outcomes are parsed from its dumped outputs. Runs of the same build differ only by nondeterminism, so the per-question test is the one that matters; neither difference is significant. The change in the norm path flips about 50 borderline questions, and which way the net goes is not tied to accuracy: a torch reimplementation of the same arithmetic as the aiter kernel (at most 0.04% of fp8 values differ from it) scored 0.9018 against main's 0.9012 on the same build (12 runs each), and the bf16-rounded variant of that reimplementation scored 0.9003. The SiLU-and-mul fusion alone (norm fusion disabled) scored 0.9012 against 0.9007.

Distance to the BF16 model. Teacher-forced on the gold answers of 400 GSM8K questions (48,692 tokens), compared with Qwen/Qwen3-14B (BF16). For this measurement the fused norm was also applied in prefill (an experiment-only switch; the PR fuses decode only).

Qwen3-14B-FP8 mean |Δ logprob of gold token| KL over BF16 top-20 top-1 agreement
main 0.02928 0.00200 0.99127
fused norm (aiter kernel) 0.02871 0.00182 0.99179

The fused norm is not further from the BF16 model than main (the gap in |Δ logprob| is significant in its favor, 95% bootstrap interval over questions [0.00025, 0.00165]).

Attention DP (MI355X, --tp 2 --attn-dp-size 2): Qwen3-8B (BF16) serves, with the same greedy outputs as main on the check prompts; Qwen3-14B-FP8 serves. Without attention DP, a TP1 Qwen3-14B-FP8 eager profile of 30 steps launches the same fused kernels as before this change (fused_rms_fp8_group_quant 2240, silu_mul_fp8_group_quant 1120).

Speed Tests and Profiling

python -m sglang.bench_serving --backend sglang --dataset-name random-ids --random-input-len 1024 --random-output-len 1024 --random-range-ratio 1.0, 80 prompts at concurrency 16 and 320 at concurrency 64, servers in the order main, PR, PR, main on one GPU with the node otherwise idle, 4 runs per server with the first dropped:

Model, base Concurrency Output throughput (tok/s) main → PR Median TPOT (ms) main → PR
Qwen3-14B-FP8, 68e57f4005 16 2035 → 2145 (+5.4%) 7.57 → 7.18 (-5.1%)
Qwen3-14B-FP8, 68e57f4005 64 5608 → 5805 (+3.5%) 10.61 → 10.23 (-3.6%)
Qwen3-14B-FP8, d74749b0b6 16 2040 → 2149 (+5.3%) 7.56 → 7.15 (-5.4%)
Qwen3-14B-FP8, d74749b0b6 64 5647 → 5848 (+3.6%) 10.57 → 10.13 (-4.2%)
Qwen3-1.7B-FP8, 68e57f4005 16 5469 → 5702 (+4.3%) 2.85 → 2.73 (-4.2%)
Qwen3-1.7B-FP8, 68e57f4005 64 13412 → 14071 (+4.9%) 4.59 → 4.36 (-5.0%)

The two runs of each build agree within 0.4%. The SiLU-and-mul fusion alone gives +3.1% / +2.0% (concurrency 16 / 64) on Qwen3-14B-FP8.

A torch-profiler kernel census of eager decode on Qwen3-14B-FP8 shows _fused_rms_fp8_group_quant_kernel and _silu_mul_fp8_group_quant_kernel on this PR (2240 and 1120 calls in 30 steps, batch 8) and neither on main.

Small-M GEMM shapes (MI355X, CUDA graph, per call, norm or SiLU-and-mul plus GEMM): the bf16 input to the small-M GEMM is faster up to the listed row count, the pre-quantized input above it.

(N, K) bf16 to small-M GEMM faster pre-quantized faster
(4096, 1024), (4096, 2048) M <= 16 (by 2-17%) M >= 17 (by 9-25%)
(2560, 4096) M <= 12 M >= 16
(5120, 4096), (4608, 4096) M <= 4 M >= 8 (by 3-44%)

python -m sglang.benchmark.one_batch on Qwen3-1.7B-FP8 TP1 (input 512, output 128, two rounds with the GPUs swapped), median decode step of this PR with vs without the small-M rule: 0.973 / 0.980 / 0.981 / 0.981 at batch 1 / 4 / 8 / 16, 1.001 / 1.001 at batch 24 / 32.

Scope: block-FP8 checkpoints in compressed-tensors format (for example the published Llama-3.1-8B and Qwen2.5 FP8-block ones) load through CompressedTensorsLinearMethod and do not take these paths (census on RedHatAI/Llama-3.1-8B-Instruct-FP8-block: no fused kernels). Llama/Qwen2 checkpoints in the native fp8 format take the SiLU-and-mul fusion only, which is bit-exact; their speed was not measured.

Checklist


Found by Hyperloom.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ⏳ Run #42193308108
Latest PR Test (Extra): ❌ Run #42193308010
Latest PR Test (AMD ROCm 10): ⏳ Run #42193307957

Tip

Base CI only covers the core cases, and its workload may not exercise your change. Use /rerun-test to run the tests your change touches, or copy this prompt for an agent:

Find the registered tests this PR affects and run them with /rerun-test, following https://github.com/sgl-project/sglang/blob/main/.agents/skills/rerun-test/SKILL.md

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

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

[BBuf Review Agent] One thing to fix before this can go in.

Comment thread python/sglang/srt/models/qwen3.py Outdated
hidden_states = self.ffn_boundary.prepare(
hidden_states,
forward_batch,
quant_format=ffn_quant,

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.

[BBuf Review Agent] With Qwen3 --tp 2 --attn-dp-size 2, even CUDA/BF16 prefill now fails because _reduce_update_read_dp_gather rejects quant_format, including the empty string. Could we handle this keyword on the DP boundary paths and cover the real decoder-to-boundary call?

@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/qwen3-blockfp8-quant-fusion branch from 46b550d to 151ce19 Compare November 5, 2026 03:08
@github-actions

This comment has been minimized.

@BBuf

BBuf commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Nov 5, 2026

@BBuf BBuf 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. The attention-DP regression is addressed.

@BBuf

BBuf commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

[BBuf Review Agent] Could you rebase this onto the latest main? It conflicts with main now.

@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/qwen3-blockfp8-quant-fusion branch from 151ce19 to c52e332 Compare November 6, 2026 02:56
@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@peymanr

peymanr commented Nov 6, 2026

Copy link
Copy Markdown

PR Pundit review

(Testing PR Pundit Code Review agent. Not all comments may be relevant. This is a one-time automated pass: please address what is useful, no need to request a re-review. Please reply to each point, whether it applies or not: we harvest these replies, and both kinds are enormously helpful for tuning the agent. Reach out to @peymanr for suggestions.)

# Finding Where
1 🟠 fp16 Qwen3 block-FP8 model decode emits bf16 qkv/MLP outputs: this PR's fp8 norm fusion lacks an activation-dtype guard python/sglang/srt/models/qwen3.py:610
2 🟠 Under torch.compile the SiLU fusion falls back while norms stay fused, losing the SiLU speedup: this PR gave the gates different condition lists python/sglang/srt/models/qwen3.py:609-620
3 🟠 Llama/Qwen2 block-FP8 decode on gfx950 silently switches to the fused quant kernel with no model-level test: this PR added the dispatch to the shared forward python/sglang/srt/layers/quantization/fp8_activation.py:97-98
4 🟠 Qwen3 decoder DP test would pass even if the decode fp8 fusion were disabled: this PR's attention stub swallows and never records quant_format test/registered/unit/layer_boundary/test_boundary_output_contracts.py:317-420
5 🟠 Kernel test never exercises the partial-last-program tail mask: this PR picks only unsharded Qwen3 intermediate sizes test/registered/kernels/ops/quantization/test_fused_norm_act_fp8_group_quant_gfx95.py:150-157

1. 🟠 Should fix: fp16 Qwen3 block-FP8 model decode emits bf16 qkv/MLP outputs: this PR's fp8 norm fusion lacks an activation-dtype guard

python/sglang/srt/models/qwen3.py:610

_quant_formats enables the fused fp8 norm for qkv_proj/gate_up_proj without checking the activation dtype, unlike _forward_decode_aiter, which requires x.dtype == torch.bfloat16. aiter_w8a8_block_fp8_linear returns bf16 whenever input_scale is given, and Fp8Config allows fp16 activations. So an fp16 Qwen3 block-FP8 model on gfx950 would get bf16 qkv/gate_up outputs at decode, while prefill and the residual stay fp16. Qwen3Attention.forward also hardcodes act_dtype = torch.bfloat16 for tuple inputs. Gate the fusion on a bf16 model dtype (the hidden % 128 part is unnecessary, because block-FP8 already enforces divisibility by block_k).

Who is affected: Only Qwen3 block-FP8 checkpoints run with --dtype float16 on gfx950 with aiter; bf16 deployments are unaffected.

Checked against the code by an independent pass.

Related past reviews: #44266 on python/sglang/srt/models/qwen3.py, #36825 on python/sglang/kernels/ops/diffusion/norm/scale_residual_norm_scale_shift_triton.py

2. 🟠 Should fix: Under torch.compile the SiLU fusion falls back while norms stay fused, losing the SiLU speedup: this PR gave the gates different condition lists

python/sglang/srt/models/qwen3.py:609-620

_quant_formats and _forward_decode_aiter use different back-off lists. Batch-invariant mode is already covered: maybe_enable_batch_invariant_mode turns it on only when enable_deterministic_inference is set, and _quant_formats checks that flag. LoRA wraps layers in init_lora_manager before any decode, so the base_layer check sees the wrapper. The only real mismatch is torch.compile: the norm stays fused while the SiLU quant falls back to the plain act_fn(x). That is a performance inconsistency, not a numerics change; sharing one predicate would keep the two gates in step.

Who is affected: Qwen3 block-FP8 on gfx950 with SGLANG_USE_AITER=1 and --enable-torch-compile.

Checked against the code by an independent pass.

3. 🟠 Should fix: Llama/Qwen2 block-FP8 decode on gfx950 silently switches to the fused quant kernel with no model-level test: this PR added the dispatch to the shared forward

python/sglang/srt/layers/quantization/fp8_activation.py:97-98

DenseSiluAndMulQuant is shared by Qwen2MLP (which qwen3 imports as Qwen3MLP) and LlamaMLP, so the new if self.aiter: branch also fuses SiLU-and-mul with the 1x128 FP8 quant for Qwen2/Llama-family block-FP8 decode on gfx950, beyond the qwen3 scope in the title. The branch has no gfx95 check of its own; it relies on aiter_takes_fp8_group_input(down_proj) (bpreshuffled Fp8LinearMethod, set only on gfx95 with ROCm >= 7.2). Those down_projs keep input_is_parallel=True, so the (q, scale) tuple reaches the existing aiter_w8a8_block_fp8_linear input_scale path at any TP. No failure follows from this. The new test covers only the kernels, not Qwen2/Llama end-to-end decode, so consider a note in the PR or an accuracy check for those models.

Who is affected: Qwen2/Llama-family block-FP8 models decoding on gfx950 with SGLANG_USE_AITER=1 and ROCm >= 7.2.

Checked against the code by an independent pass.

Related past reviews: #19746 on python/sglang/srt/models/qwen3.py, #11104 on python/sglang/srt/models/qwen3.py, #41657 on python/sglang/kernels/jit/csrc/deepseek_v4/main_norm_rope.cuh

4. 🟠 Should fix: Qwen3 decoder DP test would pass even if the decode fp8 fusion were disabled: this PR's attention stub swallows and never records quant_format

test/registered/unit/layer_boundary/test_boundary_output_contracts.py:317-420

test_qwen3_decoder_calls_the_ffn_entry_bound_under_attention_dp only covers the negative case. The _no_quantized_read guard would fail it if "fp8" reached the FFN's DP-gather step. But the attention prepare stub swallows **kw, so nothing asserts that a DECODE batch passes quant_format="fp8" to the attention read; a regression disabling the fusion everywhere would still pass. assertRaises(NotImplementedError) could be tightened to assertRaisesRegex on the step name.

Who is affected: Test maintainers only; runtime behaviour is unaffected.

Checked against the code by an independent pass.

Related past reviews: #46908 on python/sglang/srt/models/qwen3.py, #30236 on test/registered/xpu/test_xpu_int4_dense.py

5. 🟠 Should fix: Kernel test never exercises the partial-last-program tail mask: this PR picks only unsharded Qwen3 intermediate sizes

test/registered/kernels/ops/quantization/test_fused_norm_act_fp8_group_quant_gfx95.py:150-157

In test_silu_mul_fp8_group_quant, the 'both scale layouts' check is a self-comparison only at m=1, because emit_transposed_bpreshuffle_scale returns m >= 2; M=8 through 512 do compare the transposed and row-major layouts. INTERMEDIATE_SIZES (17408, 6144) give 136 and 48 groups, both multiples of GROUPS_PER_PROG=8. The partial last program that TP-sharded sizes hit (e.g. 2176 -> 17 groups) is therefore not exercised, though the kernel's cols < D and g * GROUP < D masks look correct. Adding a TP-sharded size would close the gap.

Who is affected: Coverage only; affects test maintainers, not users.

Checked against the code by an independent pass.

Shared from a pilot of PR Pundit, an agentic code reviewer that builds and tests the change and reads the project's past reviews. Reviewed at c52e332f; findings checked and posted by @peymanr; points already raised on this PR are left out.

@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/qwen3-blockfp8-quant-fusion branch from c52e332 to 5a5c815 Compare November 6, 2026 06:48
@zengleixin-amd

Copy link
Copy Markdown
Contributor Author

@peymanr Thanks for the review. Point by point, fixed in 5a5c815 (also rebased onto the latest main):

  1. fp16 activations (applies, fixed). Confirmed: aiter_w8a8_block_fp8_linear returns bf16 whenever input_scale is given, so an fp16 model would get bf16 qkv/gate_up outputs at decode. The fused norm is now requested only when the linear's params_dtype is bf16; with that guard, a tuple input to Qwen3Attention only occurs with bf16 activations, so its act_dtype stays correct. A gate test covers it: before the change the gate returned fp8 for an fp16 model.

  2. torch.compile gate mismatch (applies, fixed). The two gates now share one predicate, fp8_group_quant_fusion_allowed() in fp8_activation.py (torch.compile tracing, deterministic inference / RL on-policy, batch-invariant mode, LoRA), used by both Qwen3DecoderLayer._quant_formats and DenseSiluAndMulQuant._forward_decode_aiter, so the norm and SiLU-and-mul fusions turn off together.

  3. Qwen2/Llama also take the fused SiLU-and-mul (by design, no change). The PR description lists the shared users of DenseSiluAndMulQuant (Qwen2, Qwen3 and Llama dense MLPs). The fused kernel rounds like SiluAndMul and quantizes like aiter's per-1x128 quant, so its fp8 output equals the unfused path's; the kernel test checks this bitwise, so those models only change in speed, not in numerics.

  4. DP test only covered the negative case (applies, fixed). The attention stub now records what the decoder passes it, and the test asserts that a decode batch sends quant_format="fp8" to the attention read (and prefill sends none), while the FFN entry under attention DP gets none. The negative check is now assertRaisesRegex(NotImplementedError, "_reduce_update_read_dp_gather"). A new test also covers the gate itself: bf16 fuses, fp16 and torch.compile tracing do not.

  5. Partial last program not exercised (applies, fixed). Added the TP8 / TP4 per-rank intermediate sizes 2176 (17 groups) and 1536 (12 groups), which leave a partial last program; they pass on MI355X. Agreed on the m=1 note: the two-layout comparison is only meaningful for m >= 2, which M = 8..512 cover.

@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

[BBuf Review Agent] @zengleixin-amd Checked 5a5c815: points 1, 2, 4 and 5 are addressed. I also accept the shared Qwen2/Llama SiLU path with the bitwise kernel checks, so all five points can be closed.

@peymanr

peymanr commented Nov 6, 2026

Copy link
Copy Markdown

PR Pundit review

(Testing PR Pundit Code Review agent. Not all comments may be relevant. This is a one-time automated pass: please address what is useful, no need to request a re-review. Please reply to each point, whether it applies or not: we harvest these replies, and both kinds are enormously helpful for tuning the agent. Reach out to @peymanr for suggestions.)

# Finding Where
1 🟠 Two Qwen3 qkv_proj shapes skip the gfx950 small-M decode kernel: this PR pre-quantizes qkv_proj input python/sglang/srt/models/qwen3.py:607 / python/sglang/srt/layers/quantization/fp8_utils.py:1985
2 🟠 Dense Qwen3-FP8 decode regression on MI35x passes CI: this PR's decoder tests stub the predicate and collaborators test/registered/unit/layer_boundary/test_boundary_output_contracts.py:380-505
3 🟠 Fused 1x128 quant silently skipped under TP>1 all-reduce fusion: this PR's reads_quantized ignores quant_format python/sglang/srt/layers/layer_boundary/prepare.py:650-665

1. 🟠 Should fix: Two Qwen3 qkv_proj shapes skip the gfx950 small-M decode kernel: this PR pre-quantizes qkv_proj input

python/sglang/srt/models/qwen3.py:607 / python/sglang/srt/layers/quantization/fp8_utils.py:1985

On gfx950 decode, the fused norm-quant hands qkv_proj a pre-quantized (fp8, scale) input with a non-None input_scale. aiter_w8a8_block_fp8_linear then skips try_smallm_fp8_bs_linear (SGLANG_ROCM_SMALLM_FP8_BS, on by default), which only runs for bf16 input with input_scale is None. In SHAPES this only covers qkv_proj of Qwen3-0.6B (4096, 1024) and Qwen3-1.7B (4096, 2048) at TP1 with M<=32. No Qwen3 gate_up_proj or down_proj shape at TP1/2/4 is in the table. Whether this is a net slowdown is unmeasured; consider benchmarking these two shapes, or skipping the attention-norm fusion when the small-M kernel would take qkv_proj.

Who is affected: Block-FP8 Qwen3-0.6B/1.7B served at TP1 on gfx950 with SGLANG_USE_AITER=1, decode batches of at most 32 tokens.

Checked against the code by an independent pass.

Related past reviews: #46908 on python/sglang/srt/models/qwen3.py

2. 🟠 Should fix: Dense Qwen3-FP8 decode regression on MI35x passes CI: this PR's decoder tests stub the predicate and collaborators

test/registered/unit/layer_boundary/test_boundary_output_contracts.py:380-505

The new unit tests for Qwen3DecoderLayer._quant_formats/forward stub the layer with SimpleNamespace, patch aiter_takes_fp8_group_input to return True and replace self_attn/mlp with lambdas. So the real eligibility predicate and the (fp8, scale) tuple path through Qwen3Attention (act_dtype branch, qkv_proj) and gate_up_proj never run in any test. The fused kernels are checked against the unfused reference in test_fused_norm_act_fp8_group_quant_gfx95.py, and EXTEND, fp16 and torch.compile gating are covered by the new unit tests. No registered AMD test serves a dense Qwen3 block-FP8 model with aiter (test_qwen3_instruct_fp8.py is the MoE 235B model on qwen3_moe.py), so a plumbing regression would reach users unseen. Add a fused-vs-unfused case on a real block-FP8 Qwen3DecoderLayer, or a small dense Qwen3-FP8 accuracy test on mi35x.

Who is affected: Dense Qwen3 block-FP8 models served on gfx950 with SGLANG_USE_AITER=1 in decode, if the predicate or tuple plumbing regresses.

Checked against the code by an independent pass.

Related past reviews: #46908 on python/sglang/srt/models/qwen3.py, #30236 on test/registered/xpu/test_xpu_int4_dense.py

3. 🟠 Should fix: Fused 1x128 quant silently skipped under TP>1 all-reduce fusion: this PR's reads_quantized ignores quant_format

python/sglang/srt/layers/layer_boundary/prepare.py:650-665

reads_quantized checks only which step functions the entry uses (_reduce_update_read / _update_read), not whether the fused kernels inside them honour quant_format. Under TP>1 with aiter/custom all-reduce fusion, _reduce_update_read's fusions loop (e.g. fused_ffn_input, CustomAllReduceFusion._ffn_input_add_norm) returns a bf16 (hidden, residual) without quantizing. The block-FP8 linear then quantizes that input itself, so the output is still correct; only the PR's fused 1x128 quant is silently skipped. The read_gathers path cannot be reached: NORM_QUANT_READOUT has no gathering_reads, and the gathered-input step has an input_move, for which reads_quantized returns False.

Who is affected: Qwen3 block-FP8 decode on gfx950 with SGLANG_USE_AITER and TP>1 where the aiter/custom all-reduce+RMSNorm fusion takes the batch.

Checked against the code by an independent pass.

Related past reviews: #32779 on python/sglang/kernels/ops/attention/dsa/triton_sparse_mla_prefill.py

Shared from a pilot of PR Pundit, an agentic code reviewer that builds and tests the change and reads the project's past reviews. Reviewed at 5a5c815a; findings checked and posted by @peymanr; points already raised on this PR are left out.

@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/qwen3-blockfp8-quant-fusion branch from 5a5c815 to 47e214b Compare November 6, 2026 08:15
@zengleixin-amd

zengleixin-amd commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

@peymanr Thanks for the second pass. Point by point, against 47e214b:

  1. Small-M GEMM skipped for a pre-quantized qkv_proj input (applies, fixed). Measured on MI355X (CUDA graph, norm or SiLU-and-mul plus GEMM): the bf16 input to the small-M kernel is faster only up to a shape-dependent row count, and the pre-quantized bpreshuffle GEMM is faster above it, also inside the small-M table's own range: up to 16 rows for (4096, 1024) / (4096, 2048), 12 for (2560, 4096), 4 for (5120, 4096) / (4608, 4096). smallm_fp8_blockscale_gfx950.prefers_bf16_input now carries those measured limits, and both producers (the fused norm via aiter_prefers_bf16_input, and the fused SiLU-and-mul) keep their output in bf16 there. On Qwen3-1.7B-FP8 TP1, sglang.benchmark.one_batch decode steps are 1.9-2.7% faster at batch 1-16 than the previous head and unchanged at 24/32. A unit test pins the Qwen3-0.6B qkv_proj case (unfused at 16 rows, fused at 17 and with SGLANG_ROCM_SMALLM_FP8_BS=0).

  2. Decoder tests stub the predicate and collaborators (applies, as a coverage gap). What covers each piece today:

    • Fused kernels: test_fused_norm_act_fp8_group_quant_gfx95.py compares both fused kernels with the unfused path on MI355X for the Qwen3 hidden / intermediate sizes and M = 1 to 512: SiLU-and-mul bitwise, RMSNorm within one e4m3 step on at most 5% of values.
    • Gate: _quant_formats is tested with real linear shapes and the real small-M predicate (bf16 vs fp16, torch.compile tracing, attention DP, small-M row counts); only aiter_takes_fp8_group_input, which needs a loaded bpreshuffled weight, and the runtime context are patched.
    • Tuple plumbing (Qwen3Attention's act_dtype, qkv_proj / gate_up_proj taking (fp8, scale)): no registered test. It was checked end to end on MI355X: Qwen3-14B-FP8 GSM8K per-question comparison against main (no significant difference), a kernel census showing both fused kernels run (2240 / 1120 calls in 30 decode steps at batch 8), Qwen3-14B-FP8 and Qwen3-1.7B-FP8 serving benchmarks, and attention-DP serving (Qwen3-8B BF16 and Qwen3-14B-FP8, TP2, attention DP 2).

    A break in that plumbing would most likely fail loudly (a tuple reaching a tensor-only call) rather than drift numerically, so I did not add a per-commit end-to-end accuracy test, which would cost every AMD PR a model load and an eval run.

  3. Fused quant skipped under the TP>1 all-reduce + RMSNorm fusion (applies, by design). Correct: the fusion that completes the all-reduce with the norm returns bf16, and the block-FP8 linear quantizes it itself, so the output is right and only one of the two fusions can apply; the all-reduce one takes precedence. The StageBoundary.prepare docstring now states that quant_format is a request that such a fusion answers with the bf16 input, so consumers must accept either.

@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

[BBuf Review Agent] @zengleixin-amd I accept the small-M fix, existing MI355X coverage and bf16 fallback for all-reduce fusion. But qwen3.py:654 still reads hidden_states.shape before any gates or boundary prepare, so a previous FFN's OwedOutput crashes ordinary TP2, including CUDA/BF16 prefill; could we get the row count from batch/boundary metadata and cover the two-layer handoff?

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

[BBuf Review Agent] One thing to fix before this can go in.

Comment thread python/sglang/srt/models/qwen3.py Outdated
capture_output=None,
) -> torch.Tensor:
attn_quant, ffn_quant = self._quant_formats(
forward_batch, num_tokens=hidden_states.shape[0]

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.

[BBuf Review Agent] With Qwen3 --tp 2, the previous FFN can return an OwedOutput, so the next decoder layer now raises AttributeError on hidden_states.shape. This runs before the decode/ROCm checks, so BF16 prefill on CUDA is affected too. Could we obtain the row count through batch/boundary metadata and add a two-layer deferred-output regression test?

…or block-FP8 linears on gfx950

On gfx950 with aiter, block-FP8 dense linears take a 1x128 group-quantized
FP8 input, which Qwen3 produced with a separate quant kernel after each
input norm and after SiLU-and-mul. In decode, the attention and FFN input
norms now request the fp8 quant format (aiter fused_rms_fp8_group_quant
through the layer boundary's NormQuantReadout) when the next linear runs
the bpreshuffle block-FP8 GEMM, and DenseSiluAndMulQuant runs a new Triton
kernel that fuses SiLU-and-mul with the quant. The SiLU-and-mul kernel
rounds like SiluAndMul, so its output equals the unfused path. The
attention norm stays unfused when post_residual_addition is passed.

A read takes the quant format only where the boundary's entry reads last,
on the rows the stage computes (StageBoundary.reads_quantized); the FFN
entry under attention DP gathers after its read and stays unfused. The
quant format is passed to prepare only when non-empty, and the entry steps
that cannot quantize accept it and reject a non-empty one.

The fused norm needs bf16 activations, since the GEMM returns bf16 for a
pre-quantized input, and both fused producers (norm and SiLU-and-mul) now
share one runtime gate, fp8_group_quant_fusion_allowed(), so they turn off
together (torch.compile tracing, deterministic inference / RL on-policy,
batch-invariant mode, LoRA).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@zengleixin-amd
zengleixin-amd force-pushed the feature/leixin/qwen3-blockfp8-quant-fusion branch from 47e214b to 3b748e1 Compare November 6, 2026 09:12
@zengleixin-amd

Copy link
Copy Markdown
Contributor Author

@BBuf Thanks, confirmed and fixed in 3b748e1. With --tp 2 the previous FFN hands on an OwedOutput, and the layer read hidden_states.shape before its boundary: Qwen3-8B BF16 --tp 2 crashed at startup on MI355X, and the NPU Qwen3-32B jobs on the previous head hit the same AttributeError.

  • Qwen3DecoderLayer._quant_formats now takes the row count from positions (one entry per token, the last dim under M-RoPE) and reads it only past the decode, gfx95 aiter and runtime gates, so prefill, CUDA and every configuration without the fusion no longer touch it.
  • New test_qwen3_decoder_takes_the_previous_layers_owed_output: two Qwen3DecoderLayer.forward calls on real attention / FFN stages under attention TP 2, each handing on an OwedOutput, in prefill and in a decode that checks the fusion (and the row count it sees). It fails with the same AttributeError on the previous head and passes now.

Verified on MI355X: Qwen3-8B BF16 TP2, Qwen3-14B-FP8 TP2 and TP1 serve and generate; test_boundary_output_contracts.py passes and the layer_boundary directory fails the same environment-only tests as before; the kernel test and pre-commit pass; Qwen3-1.7B-FP8 TP1 decode steps match the previous head (the small-M rule still applies).

@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

[BBuf Review Agent] @zengleixin-amd Checked 3b748e1: the row count now comes from positions after the fusion gates, and hidden_states is inspected only after boundary prepare. The two-layer test covers the OwedOutput handoff in prefill and decode, so this addresses my concern.

@BBuf

BBuf commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@BBuf BBuf 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. The deferred-output issue is fixed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

jit-kernel quant LLM Quantization run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants