Repository navigation
[Perf] [AMD] Qwen3-Next: route GDN decode at AITER's fused gfx950 kernel - #39554
rbrugaro-amd wants to merge 11 commits into
Conversation
Replaces the four-kernel Qwen3-Next GDN decode chain (fused_qkvzba_split_reshape_cat -> causal_conv1d_update -> fused_recurrent_gated_delta_rule_packed_decode -> gated RMSNorm) with a single AITER Gluon launch that consumes the packed projections directly and folds the output gated RMSNorm into the recurrence. Off by default. Opt in with SGLANG_QWEN3_NEXT_GDN_FUSED_BACKEND=aiter. Follows the attempt-and-verify handoff Kimi-K3 already uses for its fused KDA decode (kimi_k3.py / kda_backend.py): the model offers the output-norm weight on the attention layer, the backend consumes it only if a covered kernel engages, and the model applies its own norm when the stash comes back unconsumed. The handoff is needed because the fusion spans the model/backend boundary -- the backend owns the conv and recurrent state pools but receives post-split mixed_qkv and never sees z or the norm weight, while the model has the reverse. Reuses the GDN backend's existing tuple input (projected_qkvz, projected_ba), which already exists for Qwen3.5's fused proj+Conv1D path; Qwen3-Next simply had not opted in. Everything is probed and nothing assumed: platform, env var, AITER import, Triton Gluon availability, and a per-call covered() contract delegated to AITER's own predicate so the two cannot drift, plus the one thing AITER cannot see -- the model's output-gate activation, which the kernel hard-codes to SiLU. A miss logs once and falls back rather than raising: a capability, not a mode. The CUDA path is unaffected: the fused path requires is_hip() and gfx950, and with the env var unset the probe returns False before any AITER import, leaving the existing chain byte-identical. Kernel side: ROCm/aiter fused_gdn_decode_qkvz, verified bit-identical to its upstream source on every output at M = 1, 32 and 256 on MI355X. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
The reject path already logged its reason once; the success path logged nothing, so an arm that silently fell back was indistinguishable from one that fused. Both directions are now observable from the server log, which is the structural evidence a perf claim needs. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
Covers SGLang's side of the contract -- the opt-in, the capability probe and the decline paths -- rather than the kernel's numerics, which AITER's own op tests own. The decline cases need neither a GPU nor AITER, which is the point: the fused path must degrade to the existing chain everywhere it is not supported, and that is exactly the behaviour CI can check on any runner. Included is the one condition AITER's predicate cannot see, the model's output-gate activation, since the kernel folds a SiLU gate and anything else would be silently wrong numerics. Also asserts every decline carries a non-empty reason, because the caller logs it once on the fallback path and an empty string there is useless. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
available() imported triton.experimental.gluon and stopped there. A Triton that ships the Gluon module but not gl.amd.cdna3 -- the one arch-specific primitive this kernel uses -- would import cleanly and then fail at first kernel compile with an opaque error, which is precisely the failure the gate exists to prevent. Now checks for gl.amd.cdna3.buffer_load as well. SGLang does not pin Triton, so this feature-probes rather than version-compares, following aiter_mla_gluon.py's inspect.signature idiom for the same reason. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
Wire the Qwen3-Next GDN (gated delta-net) prefill to AITER's fused single-kernel `fused_gdn_prefill_qkvz` behind the shared opt-in flag `SGLANG_QWEN3_NEXT_GDN_FUSED_BACKEND=aiter`. This is the prefill sibling of the decode consumer (sgl-project#39554): it replaces the four-kernel prefill chain (causal_conv1d_split_qkv -> fused gating -> chunked delta scan -> gated RMSNorm) with the fused kernel's tight Gluon launches, and folds the per-head group-128 FP8 quant into the epilogue so a block-FP8 out_proj consumes the activations directly. - adapter `kernels/ops/attention/gdn_fused_prefill_aiter.py`: fail-closed available()/covered()/run() -- platform, opt-in env, Triton>=3.8 Gluon probe, and a per-call shape/dtype contract delegated to AITER's predicate. Any miss runs the ordinary unfused chain. - GDNAttnBackend.try_fused_gdn_prefill: build the ragged-batch args from forward_metadata + the mamba conv/state pools, covered()->run(), stash the post-norm output and the group-128 FP8 activations; decline MIS / target-verify / tracked-conv batches. - RadixLinearAttention.try_fused_gdn_prefill: unwrap HybridLinearAttnBackend to the GDN backend; guard extend / not-verify / not-piecewise. - qwen3_next.py: static readiness gate (available + num_v_heads==2*num_k_heads + head_k==head_v==128 + conv==4), publish the bf16 norm weight after load, attempt the fused path first in forward; when out_proj is block-FP8 feed it the kernel's group-128 FP8 activations directly, else the bf16 normalized fallback. Requires the AITER fused prefill kernel (ROCm/aiter#5606). E2E (Qwen3-Next-80B FP8, MI355X, TP4): prefill-focused (ISL 6144/OSL 8) Median TTFT -27%, input throughput +35%; balanced 1k/1k TTFT -7%, decode-neutral (TPOT flat); GSM8K parity. Off by default; unsupported platform / uncovered call is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ROCm/aiter#5520 review widened fused_gdn_decode_qkvz_supported() to take A_log, dt_bias and norm_weight, which it previously never saw -- it could return covered for a launch whose parameter tensors were on the CPU or wrongly shaped, after which the kernel dereferenced them at fixed offsets. Threads those through covered() and the backend call site so the delegation stays complete, which is the point of delegating: the two contracts cannot drift if SGLang forwards everything AITER checks. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
625f185 to
d969914
Compare
| fused_gdn = ( | ||
| self._gdn_fused_decode_ready and forward_batch.forward_mode.is_decode() | ||
| ) | ||
| if fused_gdn: |
There was a problem hiding this comment.
Please add a guard with if_hip or if_rocm to make sure your change doesn't affect the NVIDIA common path. Use if_hip if this kernel can be used by all AMD GPUs; use use_aiter if this kernel imports from the AITER library.
There was a problem hiding this comment.
@yichiche thanks for comment. I added _use_aiter = get_bool_env_var("SGLANG_USE_AITER") and is_hip() and gated both the forward-path branch and the constructor's readiness check on it. Used _use_aiter rather than is_hip since the kernel imports from AITER, per your guidance. The NVIDIA path is unaffected — with the flag false nothing is probed, allocated, or dispatched.
The kernel imports from AITER, so gate on the AITER opt-in as well as the platform. The NVIDIA path is unaffected. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
|
@rbrugaro-amd Please resolve conflict issue. |
Resolves the one conflict, in gdn_backend.py: upstream added target-verify qkv routing helpers (_init_target_verify_qkv_routing, _replayssm_fold_uses_cutedsl, _target_verify_supports_strided_qkv) at the same point in GDNAttnBackend where this branch adds _try_aiter_fused_gdn_decode. Both are kept; the additions are independent. AITER sgl-project#5520 landed upstream on 2026-10-06 (dd6b8907), so the fused decode op this branch consumes is now in aiter main unchanged. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
|
Rebased onto current main and refreshed every number. Description updated with the full tables; summary of what changed: Conflict resolved. Merged The AITER dependency is no longer pending. ROCm/aiter#5520 merged on 2026-10-06 ( @yichiche — on your earlier note about guarding the NVIDIA path: that is addressed in Re-validated end to end on a current nightly (
¹ with GSM8K (1319 questions): 0.943 → 0.946 at the default cap and 0.940 → 0.942 at the raised cap, These deltas are within a point of what this PR originally claimed, now carried across a ~1700-commit jump in main, a merged AITER dependency, and a move to ROCm 10 with native Triton 3.8. Two things worth flagging for reviewers:
The reproduction block in the description also gained three flags that were used for every measurement but had never been published ( Ready for another look. |
The test was added at test/srt/test_gdn_fused_decode_aiter.py, which no longer reaches CI: run_suite.py discovers tests by globbing test/registered/**/*.py, and test/srt is referenced by no workflow -- only by the stale-PR notice that documents the move. The directory is down to four pre-migration stragglers against 2260 files under test/registered, so the file was collected by nothing and the suite never ran it. Move it to test/registered/attention/, alongside the other GDN and KDA attention tests, and add the registration marker that discovery requires; the glob is strict, so a file placed there without one fails collection rather than being skipped. Registered for stage-b-test-1-gpu-small-amd-mi35x: the op is gfx950-only, so mi35x is the suite where the device-dependent class runs instead of skipping. No CUDA registration -- the fused path is unreachable off ROCm. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
|
@yichiche The registered test was not actually reaching CI, and now is. (addressed in commit 5d220d9) It had been added under |
This comment has been minimized.
This comment has been minimized.
Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
Resolves one conflict in gdn_backend.py's imports. Upstream's kernels regroup (sgl-project#45112) moved fused_gdn_gating from ops/attention/fla/ to ops/attention/linear/; this branch's adjacent line adds the gdn_fused_decode_aiter import. Took upstream's relocated path and kept the adapter import. The refactor did not move ops/attention/gdn_fused_decode_aiter.py, and the adapter imports nothing from the regrouped tree. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
|
@yichiche can you please take another look? the aiter dependent PR ROCm/aiter#5520 merged 2 weeks ago |
[AMD] Qwen3-Next: route GDN decode at AITER's fused gfx950 kernel
Motivation
Qwen3-Next's Gated DeltaNet decode step runs four kernels per layer, per step,
across 36 GDN layers:
AITER ships a single Gluon kernel that does all four and consumes the packed
qkvz/baprojections directly. This wires Qwen3-Next to it.+4.7% to +8.6% output throughput on 4x MI355X / TP4, no accuracy
regression. Off by default; opt in with
SGLANG_QWEN3_NEXT_GDN_FUSED_BACKEND=aiter.Note the fused proj+Conv1D path that already exists in the GDN backend
(
fused_qkvzba_causal_conv1d_update_contiguous) is Qwen3.5-only —_gdn_decode_fused_proj_convappears inqwen3_5.pyand nowhere else — soQwen3-Next still runs the full unfused chain today.
Modifications
The fusion spans SGLang's model/backend boundary, which is why this is not a
one-line dispatch change.
Qwen3GatedDeltaNet.forwardowns the qkvz split(stage 1) and the gated RMSNorm (stage 5);
GDNAttnBackend.forward_decodeownsthe conv and recurrence (stages 2-4) and the state pools. Neither side holds all
the inputs: the backend receives post-split
mixed_qkvand never seeszorthe norm weight, while the model never touches the mamba pools.
This follows the attempt-and-verify stash that Kimi-K3 already uses for its
fused KDA decode (
kimi_k3.py/kda_backend.py): the model offers theoutput-norm weight on the attention layer, the backend consumes it only if a
covered kernel engages, and the model applies its own norm when the stash comes
back unconsumed. Purely additive — no signature changes, and it fails safe.
New:
kernels/ops/attention/gdn_fused_decode_aiter.py— fail-closed adapter:enabled()(env),available()(platform, AITER import, Triton Gluon),covered()(per-call contract) andrun().covered()delegates the tensorcontract to AITER's own
fused_gdn_decode_qkvz_supported()so the two cannotdrift, and adds the one thing AITER cannot see — the model's output-gate
activation, which the kernel hard-codes to SiLU.
test/srt/test_gdn_fused_decode_aiter.py— the opt-in, the capability probeand every decline path. The decline cases need neither a GPU nor AITER, which
is the point: they are exactly what CI can check on any runner.
Modified:
srt/layers/attention/linear/gdn_backend.py—_try_aiter_fused_gdn_decode()inside the existing
isinstance(mixed_qkv, tuple)branch. Returns post-normoutput and sets the consumed flag, or
Noneto fall through to today's chain.Logs once on engage and once on reject-with-reason.
srt/models/qwen3_next.py— the decode-only stash and fast path,_gdn_fused_decode_ready(static gate: platform, opt-in, head ratio, headdims, conv width), and
_prepare_gdn_fused_decode()called fromload_weightsto publish the bf16 norm weight before graph capture. A zeroconv bias is pre-allocated at init for models without one, so nothing is
allocated inside a captured decode graph.
The backend's tuple input
(projected_qkvz, projected_ba)already exists forQwen3.5's fused path; Qwen3-Next simply had not opted in. No new mechanism.
Scope
The CUDA path is unaffected. The fused path requires
is_hip()and gfx950,and with the env var unset the probe returns
Falsebefore AITER is imported,leaving the existing chain byte-identical. Every gate degrades to the current
behaviour:
Following
_can_fuse_accept_state's framing inkda_backend.py: this is acapability, not a mode — falling short of the contract falls back rather than
raising.
SGLang authors no Gluon; this only consumes AITER's, and Gluon availability is
probed rather than pinned, as
aiter_mla_gluon.pydoes.A note on Triton 3.7 images
Some ROCm builds ship Triton 3.7 carrying a backported Gluon that imports
cleanly but miscompiles the layout ops this kernel uses. AITER therefore gates
on
triton >= 3.8rather than on an import probe. On such an image this featuredeclines and falls back — correct, but inert. Concretely, of the 2026-10-12
nightlies,
rocm724ships Triton 3.7.0 androcm10ships 3.8.0.Environment
Hardware: 4x AMD Instinct MI355X (gfx950), TP4 / EP1.
Image:
lmsysorg/sglang-rocm:v0.5.21-rocm10-mi35x-20261012.Model:
Qwen/Qwen3-Next-80B-A3B-Instruct-FP8.dd6b8907Both arms differ by exactly this PR's commits — the baseline is this
branch's own merge base (
f92199b569), not an older image default, so nounrelated upstream drift is folded into the comparison. Random dataset,
ISL/OSL 1024/1024,
--random-range-ratio 1,num-prompts == max-concurrency. All numbers un-profiled.Accuracy Tests
python3 -m sglang.test.few_shot_gsm8k --num-questions 1319 --parallel 256(the old
benchmark/gsm8k/bench_sglang.pyhas been removed from main):Standard error at n=1319, p~0.94 is 0.0065, so this is "no detected
regression" rather than a measured equality. One run per arm per
configuration.
Kernel-level correctness lives in the AITER PR: the op is verified
bit-identical to its reference implementation on all five outputs across
batch 1-256, and its test suite covers the pad-slot contract, every dispatch
branch and determinism.
Speed Tests
Engagement is confirmed structurally from the server log — one
AITER fused GDN decode engagedline and zeronot coveredlines — so thethroughput above is attributable to the fused path actually running, not to a
silent fallback.
Raised graph cap
AITER's op carries a second decomposition above batch 128, which only fires when
--cuda-graph-max-bs-decodeexceeds its default of 64. Same two arms at a capof 256:
The gain narrows at the top of the range, as expected — the decode kernel is a
smaller share of step time once the batch is large enough to be bandwidth-bound.
At this cap SGLang captures 36 decode graph sizes and the op covers all of
them; selection inside AITER is a range check, not a shape table.
Kernel level
Per decode step, 36 GDN layers, TP-0, from torch profiler traces. These traces
were taken on the earlier ROCm 7.0 image and have not been re-collected on the
image above — the ratio is the claim, not the absolute microseconds:
fused_recurrent_gated_delta_rule_packed_decode_kernelfused_qkvzba_split_reshape_cat_kernel_causal_conv1d_update_kernellayer_norm_gated_fwd_kernelMethodology notes
Both arms were run with an idle-GPU assertion (no server processes, <3% VRAM)
before each arm, not just between them. An earlier ladder produced a spurious
-80% baseline result from contention with a stale server; it reproduced within
that session, which is precisely why the guard now runs per arm.
Absolute throughput here is not comparable to the figures previously quoted
in this PR: those came from a ROCm 7.0 image with Triton 3.8 installed manually,
these from ROCm 10 with native 3.8 — a different compiler and a different
absolute scale. The arm-vs-arm deltas are the like-for-like comparison, and they
are unchanged across that move and a ~1700-commit jump in main.
Known gaps
FP8 activations that a block-FP8
out_projconsumes directly, removing afurther
dynamic_per_group_scaled_quantlaunch per GDN layer. This PR callsit with
quant_dtype=None. Measured against an out-of-tree referenceimplementation that does use it, that leaves roughly 2 percentage points
of throughput on the table. It is deferred because no linear-attention backend
currently returns an
(fp8, scales)tuple into aLinear, and it deserves itsown gate and its own review.
Reproduction
Checklist
pre-commitclean on all changed filestest/srt/test_gdn_fused_decode_aiter.py, 9 tests)dd6b8907) and the minimum version statedCI States
Latest PR Test (Base): ❌ Run #39374448593
Latest PR Test (Extra): ❌ Run #39374448586
Latest PR Test (AMD ROCm 10): ❌ Run #39374448597