RDNA3 · CUDA · 投机解码 · 实测于 2× RX 7900 XT 与一张 A100

Triton unified-attention 中为段缓冲设的那道闸门
耗掉 128 个 workgroup 里的 120 个

代码中读作 max_seqlen_q;对照不投机基线实测,在 Radeon 上损失 71 %,在 A100 上损失 61 %

启用 vLLM 文档中记录的 MTP 助手后,gemma-4-31B 在 1K 上下文下解码快 36.9 %,在 32K 下却慢 70.8 %。原因既不在草稿模型,也不在硬件或接受率。投机解码将 max_seqlen_q = 2。某条 or 链中的一句把这一长度误判为预填充而非解码,导致内核从 128 个 workgroup 跌至 8 个。

2× RX 7900 XT · gfx1100 · TP=2 以及一张 A100 80G · CUDA gemma-4-31B + 官方 MTP 助手 实测 2026-08-01 与 2026-08-26

摘要

vLLM 文档标明支持该助手并给出了示例命令行,却对上下文长度只字未提。在四个深度上的实测表明,吞吐变化单调且发生变号:+36.9 %、−14.0 %、−32.4 %、−70.8 %。到 8K 时,投机解码便已成为净亏损。

闸门是 Triton unified-attention 后端 use_3d 条件里的 max_seqlen_q > 1。3D 内核将长 KV 序列切分成段并并行归约。投机的第二行 query 让它失去了走该路径的资格,启动网格从 1 × 8 × 16 塌缩为 1 × 8。代码注释将此子句归因于批次中的预填充请求,但硬性约束其实来自缓冲区几何结构:段缓冲按序列分配,却按 token 索引。这两个维度唯有在一行 query 时才完全重合。

三个看似更合理的解释经实测均被排除。不是 KV 头匮乏:该模型拥有 16 个头,不开启投机时解码一切正常。不是 query 行开销:直接调用内核,在 32K 下从一行增至两行,在一个构造中仅耗费 0.08 %,在第二个构造中耗费 0.16 %。也不是分块常量:将上游针对 Blackwell 调优的分块移植过来,性能反而下降了 8.7 %。

这一论断针对的是内核而非特定硬件。它预测,任何被路由至该内核的 CUDA 系统,随着上下文增长都会复现相同的塌陷,而缺少该闸门的后端则不会。租用的一张 A100 对这两种情况均进行了测试:在被强制的默认路径上,30K 下为 −28.2 %,50K 下为 −61.1 %;而换用 FlashInfer 时塌陷完全消失。两项预测均得到证实。

1这条阶梯长什么样

基准测试评估了 gemma-4-31B-it-qat-w4a16-ct,运行参数为 TP=2、batch 1 及贪婪解码,并搭配官方的 gemma-4-31B-it-assistant,设置 num_speculative_tokens: 1。在同一深度下,用生成 64 个 token 的耗时减去生成 8 个 token 的耗时,以此差分将解码与预填充隔离开来。

图 1 · 投机在各个上下文长度上值多少 可由仓库重算
图 1. 取自 benchmarks/speculative-decoding/。四个深度上单调, 而且在 1K 与 8K 之间穿过零 —— 这个交叉点没有测出来。 把鼠标停在任意一个点上可以看到两个速率。MTP 那一组在二十五天之后作为另一次对比的 stock 基线又测了一遍:三个长深度重现到 0.45 %,1K 差 6.0 % —— 也就是说,承载这个结论的那一端,正是重现得最好的那一端。

vLLM 内部计数器在 32K 下记录的数值是 Mean acceptance length: 1.12,即起草的 78 个 token 中仅接受了 9 个。由 1K 处的加速比倒推,可得该深度下的接受率约为 0.46,这表明接受率同样随上下文深度而衰减。被拒的草稿确实带来实打实的开销:每次拒绝都要付出助手的一次前向传递与目标模型的一次验证计算。然而,助手模型仅仅只有四层,单纯的接受率下降根本解释不了 −70.8 % 的跌幅。第 5 节将揭示真正的原因所在。

2那一句

vllm/v1/attention/ops/triton_unified_attention.py
use_3d = not (
    seq_threshold_3D is None
    or ...
    or max_seqlen_q > 1          # 投机让它变成 2
    or num_seqs > seq_threshold_3D
    or is_batch_invariant
)

这道子句决定的,是内核获准调动这块 GPU 的多大比例:

子句上的代码注释将该闸门归因于批次中的预填充请求。但真正的硬性约束在于缓冲区的几何结构,这直接决定了真正的修复方案该如何设计。段缓冲区按序列分配,形如 [seq_threshold_3D, num_heads_q, segments, headdim];但在寻址时却按 token 索引:segm_output[token, head, segm_idx, :]。当只有一行 query 时,这两个维度刚好重合。到了两行 query,缓冲区就会越界,这道闸门因此将多行 query 全部拒之门外;而 max_seqlen_q > 1 区分不了投机与预填充,顺带把投机解码也拦截了。

3只剩一个解释的那个对照

最直观的解释是,两行 query 理所当然需要更多计算。为了验证这一点,基准测试直接调用 unified_attention,使用与该模型完全一致的张量形状,既不加载模型权重也不启用投机,对 query 行数进行扫描:

图 2 · 同一个 kernel,只改 query 行数,不加载模型 可由仓库重算
图 2. 每一个点是两行 query 相对同一次扫描自己那个一行的时间,单位百分比; 两个独立构造,分别跑的。8K 和 32K 上,从一行到两行在两个构造里都是免费的。 1K 上反而更快,39 % 和 34 %,因为在那里退回 2D kernel 已经不再付出代价, 却省下了分段的开销 —— 这也是为什么这道门并不荒唐,只是太粗。 两个构造在 16K 上不一致,−0.3 % 对 −10.4 %, 而动的是那个一行的格子,它是每组里最先测的;那里两行、四行、八行三个格子在两个构造之间只差 2.2 %。 在 32K,也就是结论所在的深度,连一行的格子都只差 0.2 %。构造之间的绝对水平在 8K 上最多差 9 %, 所以这里每一个点都是同一次扫描内部的比值。把鼠标停上去可以看到四个 query 长度。

另外两项解释也经过测试并被排除。KV 头匮乏:混合 SSM 那篇中的塌陷源于仅有 4 个 KV 头,而当前模型拥有 16 个头,不开启投机时解码毫无问题。分块机制:上游代码针对 head_size == 256 and max_seqlen_q > 1 包含一个 tuned_large_head 块,该逻辑仅对 Blackwell 开放,其余条件当前硬件均完全契合。将其中 BLOCK_M = 32 移植过来后,32K 下的性能反而变慢了 8.7 %。

用均值就看不见这件事 原始 trace 未入库 32K 下 kernel_unified_attention 的单次调用耗时,取自保存在测试机上的 torch-profiler trace。由于仓库中仅入库了汇总数据与 trace 文件名,这是本页唯一无法重算的区块:
绝大多数调用耗时几乎不变,少数调用耗时激增。这种分化源于该模型交替出现的注意力结构:滑动窗口层看到的 KV 较短,不受影响;全局层则必须扫描完整的 32K,完全丧失了分段并行能力。若直接援引 9.2× 的均值,两类分布的真实情况都会被掩盖。hipGraphLaunch 计数从 63 增至 324,证实计算图重放机制完好无损。

4另一个厂商

针对内核提出的论断,能对尚未实测过的硬件给出可检验的预测。如果性能塌陷确实是因为执行路径从 3D 跌落到 2D,那么任何被路由至同一内核的 CUDA 系统,随着上下文加深都应当复现这一现象;而不含该闸门的后端则不会复现。由于 gemma-4 的注意力头维度不规则,只要 FA4 不可用,vLLM 就会强制调用 TRITON_ATTN,而 A100 恰恰不支持 FA4。只需花费一小时的实例租用成本,这项预测便可得到验证。

图 3 · 同一个模型在一张 A100 80G 上 可由仓库重算
图 3. 取自 benchmarks/cuda-a100/gemma4-mtp-backend-matrix.json, vLLM 0.28.0,每格一次运行 —— 是一次探测,不是一次 campaign。 灰色标记是这台机器 32K 上的 −70.8 %,用作比例尺。在被强制的默认路径上, MTP 是随深度加深的净亏;在 FlashInfer 上,同一个 MTP 在 30K 上是正的, 而 50K 上残留的 −8.7 % 是草稿模型的经济性问题,不是那种病理性掉落。 30K 的两个 FlashInfer 数字来自软件栈相同的两台不同 Colab VM; 这里其他每一个比值都在同一个会话内计算,而 50K 那四个格子全部来自同一个会话。

5把投机放回这条路径之后值多少

通过手动加宽缓冲区并抬高阈值,在 32K 下性能从 8.85 tok/s 恢复至 32.42,投机解码终于跑赢了基线解码。但这并非长久之计:这种改法无法表达「允许投机的两行 query,但排除预填充的四千行」;而且在 30K 提示词下,贪婪解码输出在第 35 个 token 处便与 stock 分叉,当时并未将三种潜在成因逐一拆解。

vllm#45450 自六月便已开启,此前一直因合并冲突而搁置,它恰恰提供了前一段所期望的结构性修复。该 PR 将闸门调整为 max_seqlen_q > decode_query_len,在将预填充继续拒之门外的同时,按 token 为段缓冲区分配尺寸。

图 4 · 同一条阶梯,走 vllm#45450 的准入 可由仓库重算
图 4. stock 对该 PR 的机制,两边都开着 MTP,同一台机器同一次会话。 对照图 1 里的不投机基线,#45450 这条路径在试过的每个深度上都是净正的;stock 那条不是,三个长深度上都在基线之下。 手工加宽那次实验在 32K 上落在该 PR 自己数字的 0.5 % 以内, 这就是两者在做同一件事的证据。

输出分叉的疑虑同样得到了澄清。在内核层面对 18 个用例进行固定输入对比,结果表明两条路径在全部 18 个固定输入用例中均呈现逐位确定性(bit-deterministic);两者的计算结果最多相差一个 bf16 ULP。在单行 query 下是这样(此即现行线上运行行为),在两行和四行下亦完全如此。第 35 个 token 处的分叉,其实是既有的 2D/3D 归约顺序结合律重组,在生产环境中仅凭 batch size 的变化本就会触发这一切换,并非准入逻辑本身的缺陷。

这个补丁要先有什么,才谈得上起作用

上述所有测量结果,仅在模型确实由 Triton unified-attention 内核提供服务时才成立。vLLM 并不总是将执行路由至该处;一旦路由至其他后端,该补丁便处于闲置状态。图 4 中同一组 A/B,做五次的对照实验,正是区分这两种情形的五轮运行,每轮包含十一个档位,每格两轮,k=3:

在运行阶梯测试前,探针便能指出系统属于哪种情况。当 3D 路径处理步长超过一个 token 的步骤时,inject_45450.py 会在每个 worker 进程中打印一次 PROBE_3D_SPEC_ACTIVE。这要求同时满足三个条件:安装了补丁、该内核处于激活状态、且启用了投机解码。在全部五次运行中,探针输出与最终结果完全吻合:凡是打印了探针信息的运行,打补丁的一组均拉开了差距;凡是保持静默的运行,没有任何一个档位的变动超过 8.8 %,完全在正常的运行间波动范围之内。读者若应用了该补丁而在日志中未见输出,并非操作有误。这纯粹是因为引擎将该模型路由到了其他后端,此时真正需要调整的是后端选择,而非补丁本身。

6谁报了什么

同样的 3D 跌落至 2D 现象最早见于 vllm#48076(2026-07-09),当时是在单张运行相同架构但不同量化格式的 B200 上测得的。该报告涉及的触发条件是同一条 or 链中的另一子句:num_seqs > seq_threshold_3D。那是一个 batch 大小阈值,其提议的修复方案尝试根据 SM 数量推导该阈值。该修复触及不到这里的触发条件。由于 max_seqlen_q > 1 的判定完全独立于该阈值,无论阈值如何计算,投机解码依然会跌回 2D。本文的新发现,在于指出了第二个触发条件、揭示了其根本成因,并补充了 RDNA3 上的实测数据。

上述发现于 2026-08-01 发布至该 issue 讨论区,随后于 2026-08-26 提交至 vllm#52049,后者是一份在相同模型上记录到相同症状的 CUDA 侧报告。该报告提交者证实,显式指定 FlashInfer 可以避开此问题,这与我们的机理分析完全相符:本文所追踪的性能塌陷特属于该 Triton 内核,显式指定后端便绕过了强制路由。鉴于 ROCm 尚不支持 FlashInfer,在当前硬件上,切实的权宜之计依然是依上下文深度控制投机解码的开关。

不属于该内核所特有的,是在深层上下文中投机解码出现亏损这一现象本身。2026-08-29 的实测显示,Qwen3.8-27B 同样出现性能亏损。无论在 ROCm 的 ROCM_ATTN 上(其后端文件导入了 chunked_prefill_paged_decode,并未导入任何经本补丁修改的文件),还是在 CUDA 的 FLASH_ATTN 上,情况均是如此。这两个后端均未使用该 Triton 内核,本补丁在它们上面也未改变任何行为。它们虽表现出相同的负收益符号,背后却是本文尚未查明的不同机理。若将这两项结果视作前述论证的又一佐证,那将大错特错;它们恰恰证明,前述论断并不能涵盖这些场景。

7没有被确立的部分

交叉点

1K 至 8K 之间未测。A100 侧仅测了 30K 和 50K,只框出 FlashInfer MTP 收益转负的区间,未定位具体交叉点。

重复

ROCm 阶梯每格一进程。MTP 组二十五天后的复测在三处长深度吻合至 0.45 %,但不投机的那一组无复测,且未记录逐格接受率。A100 矩阵跨两台 VM 每格仅跑一次,会话间方差未量化。

采样

全部测试均采用 temperature=0,即两模型最易一致的情形。采样按理会降低接受率并恶化投机,但在两家硬件上均未实测。

每边一个模型

仅实测了 gfx1100(TP=2、无点对点通信的 VFIO 客户机、单 checkpoint)与 A100(单卡、单 checkpoint)。内核级论断依赖两台机器的一致结果,而非单次测量。

8此后发生的变化

2026-08-26 · 找到了修复,并在两个厂商上验证 vllm#45450 自六月便已开启,且独立收敛到了相同的方案。其准入机制在同一天于两台机器上均得到验证:在 A100 上,k ∈ (1, 2, 4) 下贪婪解码输出逐位一致且稳定提速约 2×;在当前机器上则表现为图 4 中的阶梯。A100 的实测结果已提交至该 PR。
2026-08-26 · 分叉问题已关闭 第 5 节中三种未能区分的成因已区分开来:通过 18 组固定输入内核对比,确认各路径均满足逐位确定性;在包括现行发布版本在内的所有 query 长度下,两条路径的差值均在一个 bf16 ULP 之内。手动加宽实验中看似正确性风险的现象,实为批次大小在生产环境中本就会触发的归约顺序结合律重组。
2026-08-29 · "没有重复"这句说过头了 docs/speculative-decoding-on-rdna.md §1 曾述及其中每张图表均为单次运行且无进程级复测。实际上 MTP 组存在一次复测:vllm#45450 对比实验中的 stock 基线即为相同配置下的再次实测,且在 8K、16K 与 32K 处吻合在 0.45 % 以内。相关文档现已更正,verify_doc_figures.py 亦锁定了这一偏差范围。
仍然开着 截至 2026-08-29,vllm#45450 仍处于开启且冲突状态,vllm#48076 及其提议的修复亦然。在其中任一方案合入之前,当前硬件上的实用建议依然不变:短提示词可开启助手,而在 8K 处应将其关闭,因其在该长度下已转为净亏损。
2026-08-29 · "五周之后"和本页自己的日期对不上 MTP 组两次运行的日期分别为 2026-08-01 与 2026-08-26,两轮相隔二十五天,而非五周。该说法此前分布于中英文双语共六处,现均已更正。同一天早些时候,loader 那篇亦针对同一词句进行了修正;两次 campaign 之间的间隔为三十天(同样并非五周),已在 测量那篇中更正。此外,图 2 的中文说明曾将各 query 长度间的离散度表述为「不超过 1.3 %」(给出了上界),而英文原文表述为「约」。

数据。ROCm 阶梯、直接调用内核的扫描与正确性用例见 benchmarks/speculative-decoding/;A100 矩阵、日志及在 vLLM 0.28 上显式覆盖 FlashInfer 所需的条件见 benchmarks/cuda-a100/。完整长文见 speculative-decoding-on-rdna.md。每次调用约 2 MB 的 Profiler 原始 trace 留存在测试机上,导出分布与 trace 文件名见 trace-unified-attention.json。

助手模型为 google/gemma-4-31B-it-assistant,0.90 GiB 且无访问限制。其 head_dim 与 global_head_dim 与目标模型完全一致,支持共享 KV cache 分配。