[Bugfix][Perf][SM70] Make Turing (sm75) boot, compute correctly and keep its Inductor fusions - #572
Merged
Merged
Conversation
…-decode core Two independent Turing defects in the Qwen GDN linear attention layer. 1) _resolve_gdn_prefill_backend lets sm75 through (capability.minor in (0, 5)) and selects flashqla_sm70. Its default TileLang prefill asks for 86016 B of dynamic shared memory per block; Turing caps the opt-in limit at 65536 B (measured: shared_memory_per_block_optin). The worker dies during engine init with "Failed to set the allowed dynamic shared memory size to 86016", so upstream is not slow on Turing but unbootable. Split is_sm70_or_sm75 into is_sm70/is_sm75, gate FlashQLA on Volta only, and warn once with the reason. The Triton/FLA fallback already exists; the FlashQLA decode route is untouched and keeps working on Turing. The VLK CUDA variant of the kernel does fit into 65536 B but is slower than Triton from 2048 tokens per chunk upwards (measured: 512 tok 1.32x faster, 2048 tok 15 % slower, 8192 tok 21 % slower), so Triton is the better choice, not a stopgap. 2) The speculative branch calls fused_gdn_gating followed by fused_recurrent_gated_delta_rule, while the module already imports fused_sigmoid_gating_delta_rule_update and uses it in the DFlash2 branch directly above. Using the fused routine there as well drops the GDN core from 83.4 to 55.0 ms per run and matches the reference output byte for byte where the two-kernel path produced a variant. Measured on 2x Quadro RTX 8000 (sm75), TP2, Qwen3.8-27B-NVFP4, greedy with a fixed seed, 400 tokens, five repetitions. Acceptance length is bit-identical at 2.963 across every run, so the draft path is not involved. Quality checked separately with three 30-sentence prompts behind 13004 tokens of context: no degradation, no context bleed. Four new tests cover the resolver; without the fix exactly the two Turing cases fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Peuqui <peuqui@github.com>
The wrapper routes the whole GDN layer through torch.ops.vllm. qwen_gdn_full_forward, which is a splitting op, so Inductor never sees the input and output projections around the recurrent core. Its docstring states the intent: keep the projections in strict eager order instead of letting Inductor rewrite them. On Turing that costs about fifteen extra elementwise launches per GDN layer and step -- the gated RMSNorm alone falls back from two fused Triton kernels to twelve native ones. The guard is armed by VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH plus "speculation active" and asks for no device capability at all, so extending the pre-Ampere baseline to Turing also arms a Volta workaround there. Bind the automatic arm to Volta. The recurrent-core boundary is untouched: auto_sm70_qwen_gdn_full_forward still selects qwen_gdn_attention_core_standard_ spec, and an explicit VLLM_SM70_QWEN_GDN_FULL_FORWARD=1 still forces the wrapper on any device. The gate asks torch.accelerator.current_device_index(), not device 0. On a node that mixes Volta and Turing every rank sees all devices and only set_device differs, so device 0 answers for the wrong card on most ranks. Measured on such a node (2x Quadro RTX 8000 + 2x Tesla V100, TP2xPP2): the Turing ranks report (7, 5) and disarm, the Volta ranks report (7, 0) and stay armed. Measured, greedy with a fixed seed, five repetitions: - Qwen3.8-27B-NVFP4, TP2 on 2x RTX 8000, k=3: 69.58 -> 73.42 tok/s (+5.4 %). - Qwen3.8-Flash-Next-180B, TP2xPP2 across RTX 8000 and V100, k=4: 57.66 -> 58.83 tok/s (+2.0 %; only half the layers sit on Turing). Output is unchanged in both cases. Speculative decoding stays lossless: the 27B produces the same text as k=0 byte for byte, at all ten prompt lengths from 19 to 13004 tokens, before and after this change. Acceptance length is bit-identical (2.963 for the 27B, 3.030 for Flash-Next), so the draft path is not involved. Five tests cover the gate, including the mixed-node case; without the device_id argument exactly that test fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Peuqui <peuqui@github.com>
Follow-up to 1CatAI#514. That PR taught the SM70 config gates to look at every participating device instead of device 0; the capability they ask for stayed exactly (7, 0). A Turing-only deployment therefore never receives the Flash-V100 baseline -- no VLLM_SM70_GDN_* schedules, no packed FLA recurrent decode, and in particular no VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH. That is not a missing optimization. On 2x Quadro RTX 8000 (sm75, TP2) an unconfigured Qwen3.8-27B-NVFP4 answers with a single sentence repeated thirty times, and on the next question with Balinese code points. Bisecting the nine baseline defaults pinned the whole difference on VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH; the other eight are speed-neutral (68.80 vs 68.83 tok/s). The baseline is a pre-Ampere tuning, not a Volta tuning: both capabilities take the same kernels, the same fp16 accumulation contract and the same compile graph, and a heterogeneous Volta+Turing box already inherits it through the Volta card. Add _any_participating_device_is_pre_ampere() and gate the baseline on it. Nothing else changes: the other eight (7, 0) gates in this file stay as they are, and Ampere and later stay clean. Measured on 2x Quadro RTX 8000, TP2, Qwen3.8-27B-NVFP4, greedy with a fixed seed, 400 tokens, five repetitions. Without the fix the run needs VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH=1 on the command line; with it the config sets the switch itself ("Auto-setting VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH=1" in the boot log). Both give 73.42 tok/s and the same output. Quality checked over ten prompt lengths from 19 to 13004 tokens at k=0 and k=3: 10 of 10 byte-identical with the hand-configured reference in both cases. The existing regression test for 1CatAI#514 asserted that a homogeneous sm75 box stays clean, which is exactly the defect. It now asserts the opposite, and the "an unused sm70 card must not change defaults" case moves to an Ampere participant so that it keeps testing what it was written for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Peuqui <peuqui@github.com>
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.
Purpose
On a Turing-only deployment (2x Quadro RTX 8000, sm75), models that use the
Qwen GDN linear-attention layer -- Qwen3.5, Qwen3.8, Qwen3.8-Flash-Next -- do
not start; once they start they answer with garbage; once they answer
correctly they lose a third of their Inductor fusions as soon as speculative
decoding is on. This PR fixes the four defects behind that, in the order a
user meets them. Other architectures and heterogeneous Volta+Turing
deployments are unaffected by the first two: a visible Volta card already
pulls the baseline in, which is why this has stayed invisible.
1 — It does not boot.
_resolve_gdn_prefill_backendaccepts sm75(
capability.minor in (0, 5)) and selectsflashqla_sm70. That path's defaultTileLang prefill requests 86016 B of dynamic shared memory per block; Turing
caps the opt-in limit at 65536 B (measured:
shared_memory_per_block_optin).The worker dies during engine init:
Split
is_sm70_or_sm75intois_sm70/is_sm75, gate FlashQLA prefill onVolta only and warn once with the reason. The Triton/FLA fallback already
exists; the FlashQLA decode route is untouched and keeps working on Turing.
The VLK CUDA variant of the kernel does fit into 65536 B but is slower than
Triton from 2048 tokens per chunk upwards (measured: 512 tok 1.32x faster,
2048 tok 15 % slower, 8192 tok 21 % slower), so Triton is the better choice,
not a stopgap.
2 — It computes garbage. Follow-up to #514. That PR taught the SM70 config
gates to look at every participating device instead of device 0; the capability
they ask for stayed exactly
(7, 0). A Turing-only deployment therefore neverreceives the Flash-V100 baseline -- the boot log shows zero "Auto-setting
VLLM_SM70_*" lines. Unconfigured, Qwen3.8-27B-NVFP4 answers with garbage at
every prompt length from 19 to 13004 tokens, and with speculative
decoding disabled as well:
This is therefore not a speculative-decoding problem. Bisecting the nine
baseline defaults pinned the whole difference on
VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPH; the other eight are speed-neutral(68.80 vs 68.83 tok/s). The baseline is a pre-Ampere tuning, not a Volta
tuning -- a heterogeneous Volta+Turing box already inherits it through the
Volta card, which is why the defect has stayed invisible. Add
_any_participating_device_is_pre_ampere()and gate the baseline on it. Theother eight
(7, 0)gates in that file are untouched.3 — The speculative branch launches two kernels where one exists. The
branch calls
fused_gdn_gatingfollowed byfused_recurrent_gated_delta_rule,although the module already imports
fused_sigmoid_gating_delta_rule_updateand uses it in the DFlash2 branch directly above. Using the fused routine there
as well drops the GDN core from 83.4 to 55.0 ms per run and matches the
reference output byte for byte where the two-kernel path produced a variant.
4 — Speculation takes the whole layer out of Inductor.
QwenGatedDeltaNetAttention.forwardroutes the entire GDN layer throughtorch.ops.vllm.qwen_gdn_full_forward, which is a splitting op. Its docstringstates the intent: keep the projections around the recurrent core in strict
eager order instead of letting Inductor rewrite them. The guard is armed by
VLLM_SM70_FLASH_V100_0DOT3_COMPILE_GRAPHplus "speculation active" and asksfor no device capability at all — so fix 2 above would newly arm a Volta
workaround on Turing.
On Turing the cost is about fifteen extra elementwise launches per GDN layer
and step. The gated RMSNorm alone falls back from two fused Triton kernels to
twelve native ones; nsys, decode window, device 0:
elementwise_kernelvectorized_elementwise_kernelunrolled_elementwise_kernelreduce_kernelBind the automatic arm to Volta. The recurrent-core boundary is untouched:
auto_sm70_qwen_gdn_full_forwardstill selectsqwen_gdn_attention_core_standard_spec, and an explicitVLLM_SM70_QWEN_GDN_FULL_FORWARD=1still forces the wrapper on any device.The gate asks
torch.accelerator.current_device_index(), not device 0. On anode that mixes Volta and Turing every rank sees all devices and only
set_devicediffers, so device 0 answers for the wrong card on most ranks.Results
Qwen3.8-27B-NVFP4, TP2 on 2x RTX 8000, greedy with a fixed seed, 400 tokens,
five repetitions per row. Spread below 0.3 tok/s.
0106659946c064b10106659946c064b10106659946c064b1Qwen3.8-Flash-Next-180B, TP2xPP2 across 2x RTX 8000 and 2x V100, k=4, 300
tokens, three repetitions:
864572d17f5fa8c4864572d17f5fa8c4Fix 4 is worth +5.4 % on the Turing-only box and +2.0 % on the mixed one,
where only half the layers sit on Turing.
Losslessness. Speculative decoding must reproduce the unspeculated output.
It does, before and after this PR: on the 27B, k=3 equals k=0 byte for byte at
all ten prompt lengths from 19 to 13004 tokens. Acceptance length is
bit-identical (2.963 on the 27B, 3.030 on Flash-Next), so the draft path is not
involved in the speedup.
Per-device gate, verified on the mixed node. Each rank writes its own
capability and guard state to a file during layer construction (INFO from
non-primary PP ranks is filtered, so a missing log line proves nothing):
auto=Trueon all four ranks shows the wrapper would have been armedeverywhere before this change. Volta behaviour is unchanged.
Test Plan
Environment: checkout at
origin/main4f19ef7; unit tests and linting runagainst that tree. Runtime measurements run on a 1Cat-vLLM 1.5.0 wheel
deployment with the same four changes applied by hand (see Limitations).
Hardware: 2x Quadro RTX 8000 (sm75) + 3x Tesla V100 (sm70), CUDA 12.8.
Test Result
Counter-checks (the change reverted, the tests kept):
_sm70_current_device_is_voltaasking device 0 instead of the currentaccelerator:
test_mixed_node_answers_for_the_current_devicefails, theother four pass.
(7, 0):test_sm70_baseline_defaults_follow_any_visible_device[homogeneous-sm75-is-pre-ampere]fails, the other 24 pass.
test_gdn_prefill_backend_resolve.pyfail.pre-commit (
--filesover the five changed files): ruff check, ruff format,typos, mypy-local, SPDX headers, root lazy imports, forbidden imports,
torch.cuda-call check, config-docstring check, attention-backend docs and the
boolean-ops check all Passed.
pre-commit run mypy-3.10 --hook-stage manual:Passed.
On the hardware, with fix 2 removed and no environment override, so that the
box is exactly what a Turing-only user finds today: the boot log contains zero
"Auto-setting VLLM_SM70_*" lines and the model answers with garbage at all ten
prompt lengths, at k=0 and at k=3 (excerpts under Purpose). With fix 2 the
same command line auto-sets all nine defaults, reaches 73.42 tok/s and matches
the hand-configured reference byte for byte at both k values, 10 of 10 prompt
lengths.
Not a duplicate
Checked on 2026-09-08 against every open PR. Five touch one of our three
files:
qwen_gdn_linear_attn.py. It adds aqwen_gdn_full_forward_directvariantfor the
output is Nonecase insideforward; this PR changes whether thewrapper is armed at all, in
__init__. Different lines, orthogonal concerns.qwen_gdn_linear_attn.pyelsewhere and contain nooccurrence of
maybe_sm70_qwen_gdn_full_forwardorauto_sm70_qwen_gdn_full_forward.vllm/config/vllm.pyand contain no occurrence ofsm70_flash_v100_baselineor_any_participating_device_is_capability.There is no open issue or PR mentioning Turing, sm75 or RTX 8000 anywhere in
the repository. Issue #412, whose first item became #514, is closed; fix 2 is
a follow-up to the function that PR introduced and is a different problem from
#412 itself (that one was heterogeneous device order, this one is a
Turing-only box never qualifying at all).
Limitations
Runtime numbers come from a 1.5.0-based deployment, not from this tree.
The compiled extensions here are a prebuilt 1.5.0 wheel; building current main
from source on this hardware is a multi-hour CUDA build. The four changes were
applied to both trees. For the code that carries the measurement the two trees
are identical:
forward,_full_forward,qwen_gdn_full_forward,_sm70_qwen_gdn_full_forward_enabled,_qwen_gdn_run_recurrent_coreandQwen3_5GatedDeltaNet.forward_cudaare byte-identical between them, and thefix 4 gate is byte-identical as well. Fix 2 differs in one helper name only
(
_any_visible_device_has_capabilitythere,_any_participating_device_is_pre_amperehere, the latter introduced by #514); in both measured topologies visible and
participating devices coincide.
One consequence of fix 2 is untested. On current main the widened gate also
admits, for a Turing-only box, the Qwen3.8 dual-compile lane and the hybrid PLE
defaults. Both sit behind
_is_sm70_qwen38_nomtp_dual_compile_contract, whichrequires TP=4 with PP=1, no speculation and the exact Qwen4Exp shape. We cannot
exercise that: this machine has two Turing cards, so TP=4 on Turing is not
reachable here, and the 1.5.0 base predates the lane entirely. If you would
rather keep those two Volta-only, say so and we will add the condition.
The byte-identity claims above are made at 260 generated tokens or below.
The 27B is reproducible well past that (the same question three times in one
server process, twice booted, all six identical at 13004 tokens of context and
1200 tokens of output), but at that length different baseline combinations
produce different -- individually correct -- texts, so a hash is no longer a
useful equality test between configurations. Flash-Next is not reproducible
there at all: k=0 twice, same code, two boots, gives three different hashes,
which is the known batch-invariance behaviour of RMSNorm, matmul and split-KV
attention kernels. The long-context quality runs (three questions of 30
sentences each behind 13004 tokens of unrelated context, including a
deliberately misspelled term as a hallucination probe) were therefore read and
judged by hand, not hashed.
AI assistance: this change was developed with Claude (Anthropic) as a coding
assistant. Every changed line was reviewed by me and the test runs above were
executed on my hardware; I can defend the change end to end.
🤖 Generated with Claude Code