Repository navigation
[ROCm][Perf] Tiny-dot kernel for the Qwen MoE shared-expert gate - #1297
Closed
roberteg16 wants to merge 3 commits into
Closed
roberteg16 wants to merge 3 commits into
roberteg16 wants to merge 3 commits into
Conversation
roberteg16
force-pushed
the
rogarcia.tiny-dot-upstream
branch
4 times, most recently
from
September 14, 2026 07:21
fb08448 to
d00ae57
Compare
The Qwen MoE shared_expert_gate is a ReplicatedLinear(hidden_size, 1), so it reaches rocm_unquantized_gemm as a 1x1xK GEMM. hipBLASLt answers that with a 64x96x32 macro tile and a SplitK post-pass to produce one scalar. Replace that shape with a fused elementwise-mul + reduction. Gated on gfx1151, the only architecture where it was measured; everything else keeps the BLAS call. benchmarks/kernels/bench_tiny_dot.py on gfx1151, 1x1xK bf16, BLAS -> eager, medians of five invocations since the BLAS arm is not repeatable enough for a single reading: K=1024 12.60 -> 9.16 us K=2048 16.12 -> 8.36 us K=4096 22.94 -> 8.64 us Co-authored-by: Matthias Gehre <matthias.gehre@amd.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
The eager (x*w).sum(dtype=x.dtype) fallback still costs two launches per call, and the gate runs once per MoE layer per decode step. A single workgroup with fp32 accumulation does it in one, and its cost is flat in K where both other paths grow. Restricted to bias=None and to a K whose elements still fit the workgroup's registers; anything else keeps the eager chain. benchmarks/kernels/bench_tiny_dot.py on gfx1151, 1x1xK bf16, eager -> Triton, medians of five invocations: K=1024 9.16 -> 5.24 us K=2048 8.36 -> 5.22 us K=4096 8.64 -> 5.20 us Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
roberteg16
force-pushed
the
rogarcia.tiny-dot-upstream
branch
2 times, most recently
from
September 14, 2026 07:42
9e3a762 to
33d9bd5
Compare
The Qwen MoE shared expert scales its output by sigmoid(shared_expert_gate(x)), which on a single token is three launches: mul, sum, sigmoid. An APPLY_SIGMOID constexpr folds all three into the tiny-dot kernel, and allocating the scalar output in the input dtype lets the store do the rounding instead of a separate copy. The call site keeps the eager spelling for multi-token batches, where the gate is a real GEMM, and on anything but gfx1151. It also reads the gate weight directly rather than calling the layer, so it requires an unquantized weight and no bias. benchmarks/kernels/bench_tiny_dot.py --sigmoid on gfx1151, 1x1xK=2048 bf16: eager fused 8.36 -> 10.88 us, the sigmoid costing another launch Triton 5.22 -> 5.26 us, the fold making it free End-to-end numbers for the whole series are in the pull request. Co-authored-by: Matthias Gehre <matthias.gehre@amd.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
roberteg16
force-pushed
the
rogarcia.tiny-dot-upstream
branch
2 times, most recently
from
September 14, 2026 08:11
3b0bd05 to
e323989
Compare
Author
|
Upstream PR: vllm-project#56276 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A Qwen MoE block gates its shared expert with
sigmoid(shared_expert_gate(x)), whereshared_expert_gateis aReplicatedLinear(hidden_size, 1). During decode with a single token that is a 1x1xK GEMM, and hipBLASLt answers it with a 64x96x32 macro tile plus a SplitK post-pass in order to produce one scalar. Qwen3.5-35B-A3B has 40 such layers, so this fires 40 times per decode step.This series replaces that shape with a dot product, then folds the surrounding work into the same kernel. On gfx1151 it is worth 3.2% of decode throughput end to end, with prefill and TTFT unchanged.
Everything is gated on
on_gfx1151(), the only architecture measured here. gfx9, gfx1100, gfx12xx and every non-ROCm platform keep the code they have today: the series removes exactly one line of existing upstream code, the shared-expert gate multiply inQwen2MoeMLP. Everything else is additive.What this touches
The dispatcher change fires for any unquantized bf16/fp16
Linear(K, 1)on a single token, which in this tree means theshared_expert_gateinqwen2_moe,qwen3_moe,qwen3_next,qwen3_5andinterns2_mobius. The only other 1-wide projection,qwen3_dspark'sproj, is fp32 and therefore excluded.The call-site change is narrower: it lives in
Qwen2MoeMLP, so it reaches Qwen2-MoE, Qwen3-Next, Qwen3.5-MoE and InternS2 Mobius.qwen3_moehas its ownQwen3MoeMLPwhoseforwardis line-for-line identical; I left it alone because I have not measured it, but folding the two would be the obvious follow-up.Checkpoints without a shared expert are untouched, as is any deployment running with
VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS, whereexpert_gateisNone.Commits
Replace the 1x1xK BLAS call with a fused dot on gfx1151(x*w).sum(dtype=x.dtype)instead of BLAS.Serve the tiny scalar projection from a Triton kernelbias=Noneand K within the register budget._tiny_dotholds that choice so callers do not repeat it. Adds the correctness test and the A/B benchmark.Fold the shared_expert_gate sigmoid into the tiny-dot kernelAPPLY_SIGMOIDconstexpr collapses mul + sum + sigmoid into one launch at theQwen2MoeMLPcall site, and the scalar output is allocated in the input dtype so the store does the rounding rather than a separate copy. Extends the benchmark with--sigmoid.End-to-end results
Qwen3.5-35B-A3B-W4A16 on gfx1151 (Strix Halo), concurrency 1, 100-token prompts, 128 output tokens, 10 prompts,
max_num_seqs=1. Three runs per arm, medians below.upstream/mainPer-run decode, showing the spread:
upstream/main43.3 / 43.3 / 43.2, this branch 44.4 / 44.7 / 44.7. Intra-arm spread is about 0.1%, so the 3.2% separation is roughly 30x the noise.On the measurement methodology, because the first attempt was wrong and it matters. Running the two arms back to back in a fixed order produced a 17% prefill improvement that cannot exist: prefill submits 100 tokens at once, the fast path requires
n == 1, so it never executes during prefill. The cause was thermal. Each arm ran immediately after the other on an already-warm GPU, and whichever arm ran second was penalised. Since the branch always ran first, the bias inflated its result.The numbers above come from a re-run with the order reversed and a cooldown before every run, with all twelve runs starting at 34-35 °C. Under those conditions prefill and TTFT come out equal between arms, which is what the code requires, and the decode difference survives unchanged. Anyone reproducing this should not interleave the arms without a cooldown.
Kernel microbenchmark
benchmarks/kernels/bench_tiny_dot.py, times in µs. Each cell is the median of five separate process invocations, because the BLAS arm is not repeatable enough for a single reading (see below).The Triton kernel is flat in K at roughly 5 µs, being one launch over a single workgroup, while BLAS grows with K. Qwen3.5-35B-A3B uses K=2048.
With
--sigmoid, which is how the gate is actually used, at K=2048 bf16:Folding it into the kernel is what makes the sigmoid free; the other two arms pay another launch for it, so the Triton margin over the eager chain widens from 1.60x to 2.07x.
On the variance, since it decides how these numbers should be read. Across the five invocations the Triton arm holds within 2-7% and the eager arm within 1-2% on most cells, but the BLAS arm swings by up to 49% and in one invocation returned 4471 µs against a median of 18.92. Single readings of the BLAS arm are therefore not reportable, and an earlier revision of this description quoted one such outlier. Anyone reproducing this should take medians across processes rather than trusting one run,
do_bench's internal median notwithstanding.Why K is bounded, and where
Doing the reduction in one workgroup is what makes it one launch: there is no cross-workgroup reduction inside a kernel, so splitting K would cost either a second launch or atomics, and the launch is the thing being eliminated. The price is that register pressure grows with K, and
K <= 4096was inherited from the downstream branch without a measurement behind it.Reading VGPR counts out of the compiled kernel shows the real boundary is a function of elements per lane alone, identical across different combinations of K and
num_warpsthat share it:The allocator hits the 256-VGPR ceiling at 256 elements per lane and spills, costing a factor of 5. So the kernel now sizes the workgroup to hold each lane at 128 elements, and the bound becomes a property of the hardware: 32 warps per workgroup times 32 lanes times 128 elements, i.e. 131072.
Measured with that heuristic, against the eager fallback:
131072 is the last K that fits; at 262144 the warp count is already at the hardware maximum, elements per lane double, and eager wins. Nothing changes for the models in this series, which use K=2048 and sit a factor of 16 below the ceiling; the point is that the limit is now derived rather than guessed, and large-hidden MoEs stop falling back for no reason.
Accuracy
GSM8K, 5-shot, temperature 0, 400 questions per arm, no invalid responses on either side.
upstream/mainThe four-question gap is 0.51 standard errors of the difference, so the two arms are indistinguishable. That is the expected outcome rather than a lucky one: the Triton path accumulates in fp32 and rounds once on the store, which is what
(x*w).sum(dtype=x.dtype)does, so it is numerically equivalent to the code it replaces rather than an approximation of it.tests/kernels/test_tiny_dot_triton.pychecks that equivalence directly across 24 combinations of K, dtype and sigmoid.400 rather than the full 1319 because this box sustains about 40 tok/s aggregate, which puts the full set at roughly 100 minutes per arm. At n=400 the standard error on each arm is 1.4%, enough to catch a gross regression and not enough to resolve a sub-1% one.
Testing
The new test compares the kernel against the eager reference across K = 32 / 1024 / 2048 / 4096 / 65536 / 131072, both dtypes, with and without the folded sigmoid. The two largest K exercise the multi-warp launches.
Software stack
Measured on ROCm 10.1.0a20260822 (
torch.version.hip7.16), PyTorch 2.13.0, Triton 3.8.0. This is newer than whatdocker/Dockerfile.rocm_basepins for the shipped image (ROCm 7.2.3, PyTorch 2.12, Triton 3.7), so the ratios against BLAS are specific to this stack, hipBLASLt being exactly the component that moves between them. The kernel itself uses only long-stable Triton primitives (tl.arange,tl.load,tl.sum,tl.exp,tl.store) and carries no version requirement. gfx1151 is a shipped target in that image'sPYTORCH_ROCM_ARCH, but no gfx11 runner exists in CI, so these numbers cannot be reproduced by CI.Not a duplicate
dispatch_unquantized_gemm()inmoe_runner.py; that path concatenates the router gate with the shared gate into a[num_experts + 1, hidden]weight, so it is an m=513 GEMM. This series handles the unfused path, where the gate is a standaloneLinear(hidden, 1)and m == 1. Different file, disjoint conditions. WhenVLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTSis on,Qwen2MoeMLP.expert_gateisNoneand none of this runs at all.on_gfx115x()predicate commit; that commit was dropped here in favour of the existingon_gfx1151(), so the two PRs no longer touch a common line. gfx1151 is also the only part of gfx115x measured in this series.AI assistance
This change was prepared with AI assistance (Claude). Every line was reviewed by the submitter, and the measurements above were run and checked by hand.