[Kernel][Perf] Add fused CUDA post-conv MTP decode kernel for Qwen3.5 GDN - #51674
Conversation
gau-nernst
left a comment
There was a problem hiding this comment.
Added some comments on style and default behavior. The GDN dispatch logic is getting quite complicated, I need more time to review it. Thank you 🙏
There was a problem hiding this comment.
If this is only used by fused_gdn_decode_kernel.cu, I wonder if it's better to just inline it in the .cu file
| splitting_ops = config.compilation_config.splitting_ops | ||
| assert splitting_ops is not None | ||
| assert { | ||
| "vllm::qwen_gdn_attention_core_fused_norm_packed", | ||
| } <= set(splitting_ops) |
There was a problem hiding this comment.
This assertion is a regression guard for the default piecewise-compilation config.
The fused MTP decode path is invoked through a new custom op, vllm::qwen_gdn_attention_core_fused_norm_packed (the packed variant of qwen_gdn_attention_core that also carries the output gate for the fused norm). Like the other GDN/mamba attention-core ops, it mutates the recurrent state cache in place and dispatches on attention metadata at runtime, so it must not be traced into the compiled subgraphs — it has to act as a graph-split boundary for piecewise CUDA graphs. That's why the PR adds it to CompilationConfig._attention_ops (see vllm/config/compilation.py), which is what populates splitting_ops when the engine resolves the default FULL_AND_PIECEWISE cudagraph mode.
test_splitting_ops_dynamic already verifies how splitting_ops gets populated under the default config; the added assertion just pins that the new op is included there by default. Without it, a refactor that dropped the op from _attention_ops would still pass the kernel tests but silently let torch.compile trace through the op, breaking piecewise CUDA graph capture for the fused GDN MTP path.
| if(FUSED_KDA_DECODE_ARCHS) | ||
| set(FUSED_KDA_DECODE_SRC | ||
| "csrc/libtorch_stable/kimi_k3/fused_kda_decode_kernel.cu") | ||
| "csrc/libtorch_stable/kimi_k3/fused_kda_decode_kernel.cu" | ||
| "csrc/libtorch_stable/gdn/fused_gdn_decode_kernel.cu") | ||
| set_gencode_flags_for_srcs( | ||
| SRCS "${FUSED_KDA_DECODE_SRC}" | ||
| CUDA_ARCHS "${FUSED_KDA_DECODE_ARCHS}") |
There was a problem hiding this comment.
nit: since you add the fused GDN decode kernel under this, we probably want to rename it to FUSED_GDN_KDA_DECODE_ARCHS and FUSED_GDN_KDA_DECODE_SRC etc...
Note: currently this also compiles for sm90a and sm120f
There was a problem hiding this comment.
Thanks. It now has its own FUSED_GDN_DECODE_ARCHS / FUSED_GDN_DECODE_SRC block and a dedicated VLLM_ENABLE_FUSED_GDN_DECODE define, separate from FUSED_KDA_DECODE_*. The split is necessary rather than cosmetic: the two kernels now build for different arch sets (KDA stays on 9.0a;10.0f;12.0f, while the GDN decode kernel builds for 8.0;8.6;8.9;9.0a;10.0f;12.0f since it only needs cp.async and BF16 math). So each name now matches exactly onw kernel - no shared naming left to rename.
| # Select the Qwen3.5 GDN decode implementation. | ||
| "VLLM_QWEN3_5_GDN_DECODE_KERNEL": env_with_choices( | ||
| "VLLM_QWEN3_5_GDN_DECODE_KERNEL", | ||
| "triton", | ||
| ["triton", "fused"], | ||
| case_sensitive=False, | ||
| ), |
There was a problem hiding this comment.
Why can't we enable it by default?
There was a problem hiding this comment.
I haven't tested it on various datasets yet, it only passes the gsm8k and random data tests.
So, I prefer making it an env option currently.
I'm not familiar with the development guide of vLLM, if you think it's OK to enable it default. I'm happy to do change it.
There was a problem hiding this comment.
I think it's fine to enable it by default as long as the constraints are met cc @ZJY0516
There was a problem hiding this comment.
I think it's okay if it's always faster
There was a problem hiding this comment.
BTW, I prefer the name like "cuda" or "cuda-fused", instead of "fused"
There was a problem hiding this comment.
Done, switch to "cuda" instead of "fused" and make it default.
| or vllm_config.model_config.dtype != torch.bfloat16 | ||
| or conv_state_dtype != torch.bfloat16 | ||
| or recurrent_state_dtype not in FUSED_GDN_STATE_DTYPES | ||
| or not current_platform.is_device_capability_family(100) |
There was a problem hiding this comment.
At a quick glance, the kernel doesn't seem to use sm100-exclusive features. It should work on sm80 and after? (also further gated by the Cmake configuration below)
There was a problem hiding this comment.
thanks, I have extended the constraint to the the compute capability >= 80
| core_attn_out = torch.zeros( | ||
| (num_tokens, self.num_v_heads // self.tp_size, self.head_v_dim), | ||
| dtype=hidden_states.dtype, | ||
| device=hidden_states.device, | ||
| ) |
There was a problem hiding this comment.
Is torch.zeros() necessary (instead of torch.empty())?
There was a problem hiding this comment.
Good question. torch.empty would indeed save some memset overhead, but it's intentionally torch.zeros here, matching the non-fused allocation a few lines below which carries an explicit comment pointing at #28182. That PR tried exactly this swap for the GDN buffers and is still open: with full CUDA graphs the batch is padded, and the GDN core (both the Triton path and this fused kernel, which only writes the rows covered by spec_query_start_loc) fills only the unpadded rows, so torch.empty leaves garbage in the padded tail that then flows through out_proj into the hidden states. The warmup path (attn_metadata is None) doesn't write the buffer at all. I'd prefer to keep the two call sites consitent; if #28182 lands with a safe way to drop the zero-init, both can be switched together.
There was a problem hiding this comment.
@ZJY0516 told me the original issue has been fixed (I use torch.empty() for the Kimi-K3-owned KDA layer). But it's fine, just a small detail, we can leave it for future PRs.
| def _output_projection_normalized( | ||
| self, | ||
| core_attn_out: torch.Tensor, | ||
| ) -> torch.Tensor: | ||
| core_attn_out = core_attn_out.flatten(-2) | ||
| output, _ = self.out_proj(core_attn_out) | ||
| return output |
|
/ci run |
|
✅ @Jie-Fang, CI is now available for this PR.
|
|
✅ Triggered Buildkite CI #83496 for commit |
|
Hi @Jie-Fang, the pre-commit checks have failed. Please run: uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-filesThen, commit the changes and push to your branch. For future commits, |
The fused kernel now handles only pure spec-decode batches; mixed batches fall back to the Triton path. Fusing the mixed case required spec_token_prefix_len metadata and prefix-slicing logic across the core forward for little measured gain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jie Fang <jief@nvidia.com>
The kernel only relies on cp.async and BF16 math, both available since SM80. Give it a dedicated arch list and VLLM_ENABLE_FUSED_GDN_DECODE define instead of piggybacking on the fused KDA decode configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jie Fang <jief@nvidia.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jie Fang <jief@nvidia.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jie Fang <jief@nvidia.com>
VLLM_QWEN3_5_GDN_DECODE_KERNEL={triton,fused} becomes
VLLM_GDN_DECODE_KERNEL={cuda,triton} with cuda as the default: the
kernel's constraints are shape- and dtype-based rather than tied to
Qwen3.5. When a model or platform doesn't meet them the default falls
back to the Triton path; an explicit cuda setting raises with the
reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jie Fang <jief@nvidia.com>
The rebase conflict resolution kept upstream's a_spec/b_spec index_select but left the delta-rule call reading the full-batch a/b, undoing the upstream fix for mixed batches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jie Fang <jief@nvidia.com>
bf57415 to
38a1c17
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #83745 for commit |
Conflicts: vllm/envs.py, tests/test_envs.py. Both resolved take-ours. main's legacy `if TYPE_CHECKING:` block and `environment_variables` dict are superseded wholesale by the pydantic BaseSettings tree on this branch; main's conflicting test tail covers `env_with_choices` / `env_list_with_choices`, helpers this branch deleted. Ported from main (b216db3..03a8d0b): - vllm-project#51674 (1be3628), fused CUDA post-conv MTP decode kernel for Qwen3.5 GDN. VLLM_GDN_DECODE_KERNEL -> `gdn_decode_kernel: Literal["cuda", "triton"] = "cuda"` in UsageSettings, next to `enable_fla_packed_recurrent_decode`. main passes `case_sensitive=False`, so a bare Literal would reject the "CUDA" main accepts; added `_lower_gdn_decode_kernel`, mirroring `_lower_mm_hasher`. Deliberately no strip: main's env_with_choices does not strip, so " cuda " must keep raising. Caller qwen_gdn_linear_attn.py:492. - vllm-project#50487 (03a8d0b), Kimi K3 DFlash aux state. VLLM_KIMI_K3_AUX_ATTN_RES_STREAM -> `kimi_k3_aux_attn_res_stream: bool = False` in QuantSettings, between `kimi_k3_shard_sp_shared_expert` and `kimi_k3_gemm_rs` to match main's ordering. main's `bool(int(getenv(...)))` needs no validator. Caller kimi_k3/nvidia/model.py:1218. Both vars have already-merged callers, so both ports are mandatory. Neither was added to main's `ignored_factors`, so both remain compile factors on the branch and carry no `compile_factor: False` marker; verified at runtime. Not ported: main's `test_gdn_decode_kernel_env`, which exercises the deleted `env_with_choices` helper through the back-compat shim. No branch-flavored replacement was added (user decision); the lowercase coercion was verified by hand instead. Nothing else dropped, both commits accounted for. Parity check: 294 branch fields vs 295 main runtime entries, sole difference VLLM_TRITON_ATTN_USE_TD, the known shim divergence, re-confirmed byte-identical in base and theirs. Tests: 54 passed across tests/test_envs.py, tests/test_envs_pydantic.py and tests/docs/test_env_vars_gen.py; `pre-commit run --files vllm/envs.py tests/test_envs.py` clean. The GPU-only consumer suites (tests/kernels/mamba/test_gdn_fused_mtp.py, tests/models/kimi_k3/test_eagle3.py) were not run here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Vinay Damodaran <vrdn@hey.com>
…_conv_mtp declared under KDA ifdef The declaration sat inside VLLM_ENABLE_FUSED_KDA_DECODE while its binding (torch_bindings.cpp:811) is guarded by VLLM_ENABLE_FUSED_GDN_DECODE — any build with GDN archs but no KDA archs (sm_89 wheel builder) fails 'not declared in this scope'. Declaration moved to its own GDN guard.
Upstream made it prefill-lazy; the ReplaySSM cursor-reset and ring- coherence blocks read it on pure-(spec-)decode batches (incl. cudagraph capture), raising UnboundLocalError at capture time. Restore the pre-vllm-project#51674 unconditional compute (one tensor sub). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… GDN (vllm-project#51674) Signed-off-by: Jie Fang <jief@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… GDN (vllm-project#51674) Signed-off-by: Jie Fang <jief@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Wyett <wyettzeng@gmail.com>
… GDN (vllm-project#51674) Signed-off-by: Jie Fang <jief@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Zhu, Zufang <zufang.zhu@intel.com>
The fused GDN decode kernel (vllm-project#51674) only served the MTP speculative path; plain decode still ran the Triton packed recurrent kernel plus a separate gated-RMSNorm launch per layer. Invoke the fused kernel with draft width 1 for pure-decode batches: each request loads its recurrent state slot, advances it in place, and gets the gated-RMSNorm epilogue in the same launch. Per-layer eager cost drops ~10x (127us -> 12us, L40S, B<=8); Qwen3.8-27B TP2 enforce-eager serving improves 15.3% output throughput / -13.6% TPOT. Neutral under FULL CUDA graphs where launch overhead is already amortized (graph-replay GPU time is on par with the Triton path). Signed-off-by: Cheng Rui <286040359@qq.com> Co-authored-by: Claude <noreply@anthropic.com>
… GDN (vllm-project#51674) Signed-off-by: Jie Fang <jief@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: khushali9 <khushali.desai9@gmail.com>
The fused GDN decode kernel (vllm-project#51674) only served the MTP speculative path; plain decode still ran the Triton packed recurrent kernel plus a separate gated-RMSNorm launch per layer. Invoke the fused kernel with draft width 1 for pure-decode batches: each request loads its recurrent state slot, advances it in place, and gets the gated-RMSNorm epilogue in the same launch. Per-layer eager cost drops ~10x (127us -> 12us, L40S, B<=8); Qwen3.8-27B TP2 enforce-eager serving improves 15.3% output throughput / -13.6% TPOT. Neutral under FULL CUDA graphs where launch overhead is already amortized (graph-replay GPU time is on par with the Triton path). Signed-off-by: Cheng Rui <286040359@qq.com> Co-authored-by: Claude <noreply@anthropic.com>
Purpose
Speed up Qwen3.5 (GDN linear attention) MTP speculative decode on Blackwell. During
MTP decode, the Triton path launches a chain of small kernels per step (gating,
delta-rule recurrence, state rewind/update, gated RMSNorm), which leaves the GPU
latency-bound at decode batch sizes.
This PR adds a single fused CUDA kernel,
fused_gdn_decode_post_conv_mtp, thatconsumes the post-convolution
mixed_qkvand performs the entire GDN MTP decodestep in one launch:
softplus/sigmoidona,b,A_log,dt_bias)with rewind to the last accepted token via
num_accepted_tokensTest Plan
New/extended tests (all require SM100):
pytest tests/kernels/mamba/test_gdn_fused_mtp.py -v pytest tests/kernels/test_fused_gdn_post_conv.py -v pytest tests/v1/attention/test_gdn_metadata_builder.py -v pytest tests/test_envs.py -k "gdn" pytest tests/compile/test_config.pytest_gdn_fused_mtp.pycovers the model-path dispatch for pure spec-decode,mixed, prefill, and regular-decode batches against the Triton reference.
test_fused_gdn_post_conv.py::test_fused_gdn_decode_post_conv_mtp_ratio8coversthe kernel directly for BF16/FP32 state, ragged acceptance patterns, and TP head
counts (tp4/tp16).
Test Result
On 1x B200 (SM100), CUDA 13.1, torch 2.13.0+cu130:
tests/kernels/mamba/test_gdn_fused_mtp.py: 6 passedtests/kernels/test_fused_gdn_post_conv.py: 70 passedtests/v1/attention/test_gdn_metadata_builder.py: 10 passedtests/test_envs.py -k gdn: 1 passedtests/compile/test_config.py: 44 passedMicrobenchmark
1x B200, CUDA 13.1, torch 2.13.0+cu130. Both variants measured over the
same kernel boundary: post-conv
mixed_qkvin, gated+normedcore_attn_outout, recurrent state updated in place.fused_sigmoid_gating_delta_rule_update+ gated RMSNorm(
rmsnorm_fn), exactly as dispatched by_forward_core_fused_normtoday.fused_gdn_decode_post_conv_mtplaunch.events (production MTP decode runs under full CUDA graphs, so graph-replay
GPU time is the relevant metric; CPU launch overhead excluded for both).
(126 MB), i.e. state comes from HBM as in real serving where other layers
evict L2 between calls; warm reuses one buffer set (L2-hot upper bound).
num_accepted_tokens=2. Context length doesnot affect either variant (fixed-size recurrent state), so results apply to
any seqlen (e.g. 8192).
Cold-cache speedup is 1.17x-2.20x, largest in the latency-sensitive
BS=4-32 decode regime.
TP=4 (H=4, HV=32), bfloat16 recurrent state
TP=4 (H=4, HV=32), float32 recurrent state
TP=16 (H=1, HV=8), bfloat16 recurrent state
TP=16 (H=1, HV=8), float32 recurrent state
Model Evaluation
An lm_eval score comparison was not run: the fused path requires the
num_v_heads == 8 * num_k_headsGDN head layout, and no public Qwen3.5checkpoint ships that config (0.8B is 16/16, 35B-A3B is 16/32, 397B-A17B is
16/64) — on those models the dispatch check never selects the fused kernel, so
default behavior is unchanged. Correctness of the fused kernel against the
Triton path is covered by the numerical-equivalence tests above:
test_fused_gdn_post_conv.py::test_fused_gdn_decode_post_conv_mtp_ratio8checks kernel outputs and updated recurrent state against
fused_sigmoid_gating_delta_rule_update+rmsnorm_fnacross BF16/FP32 state,ragged acceptance patterns, and TP head counts, and
test_gdn_fused_mtp.pychecks the model-path dispatch (pure / mixed / prefill / decode batches)
end-to-end at the layer level.
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.