Qwen3.8-27B 的线性注意力用哪个内核:vLLM 按显卡代次三选一

2026-08-24

Qwen3.8-27B 的 64 层里有 48 层是线性注意力,走的是 Gated DeltaNet。这个结构层面的事实,前面几篇已经从多个框架印证过了。

但「用什么算法」和「用哪份代码算」是两件事。vLLM 里这一支的预填充阶段有三套不同的内核实现,跑起来用哪一套,取决于你的显卡是哪一代、CUDA 是什么版本,以及一个配置字段的具体数值。

这篇讲这个选择逻辑,以及它带来的一个容易被误判成故障的现象。

这篇的依据

来源是 vLLM 主干仓库的 vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py,采集时间 2026-08-24

我们没有安装 vLLM、没有跑过任何内核、没有做过任何性能测量。 下面讲的是源码里写的选择条件和日志文本,不包含任何关于哪个内核更快的说法——那需要实测,我们没测。

三个候选

选择逻辑写在 _resolve_gdn_prefill_backend 这个函数里,返回值的类型标注就把候选列全了:

Literal["triton", "flashinfer", "cutedsl"]

三个:Triton、FlashInfer、CuteDSL。

函数的文档字符串把条件写得很清楚,翻译过来是这样。

FlashInfer 被选中,需要满足: 配置里请求的是 flashinferauto;平台是 CUDA;并且满足以下之一——Hopper 架构(SM90),无额外约束;或者 Blackwell 架构(SM10.x)且 head_k_dim == 128 且 CUDA 运行时主版本不低于 13。

CuteDSL 被选中,需要满足: 配置里显式请求 cutedsl(文档字符串标注为「仅限主动选用」);且是 Blackwell 架构且 head_k_dim == 128

其余情况一律落到 Triton。 代码最后一行就是无条件返回 triton,非 CUDA 平台在函数开头就直接返回它了。

那个 128 正好对上

条件里反复出现的 head_k_dim == 128,取的是这个字段:

head_k_dim = getattr(
    vllm_config.model_config.hf_text_config, "linear_key_head_dim", None
)

linear_key_head_dim——Qwen3.8-27B 的这个值正好是 128(这几个 linear_ 开头字段的含义见 线性注意力参数)。

所以对这个模型来说,那两条带 128 约束的分支是满足的。如果换一个 linear_key_head_dim 不等于 128 的模型,即使显卡够新,也只能落回 Triton。

这是个挺有意思的观察:一个看起来纯粹描述模型结构的配置字段,实际上会影响你能用上哪一套内核。模型配置和硬件能力在这里交汇了。

一个会被误判成故障的现象

选完之后有一段日志逻辑,其中一条警告值得单独拎出来:

FlashInfer GDN prefill is JIT-compiled; first run may take a while. Set --gdn-prefill-backend triton to skip JIT.

FlashInfer 的这个内核是即时编译的,首次运行可能要等一会儿。

这条信息的实用价值很高。设想一下:你在一张 Hopper 卡上第一次起 vLLM 跑这个模型,发现第一个请求慢得离谱。这时候很容易怀疑是模型太大、是显存不够、是配置写错了——实际上可能只是在编译内核

源码同时给出了规避办法:加 --gdn-prefill-backend triton 跳过 JIT。

注意这个规避是有取舍的:跳过 JIT 意味着不用 FlashInfer 那套内核。至于两者的实际性能差别有多大,源码没说,我们也没测,所以本文不给建议。能确定的只有「首次慢可能是 JIT,不是故障」这一条。

顺带一提,那个配置项的名字是 gdn_prefill_backend,默认值是 "auto",从 vLLM 的附加配置里读。日志会把请求值和实际生效值都打出来:

Using %s GDN prefill kernel (requested=%s, head_k_dim=%s).

排查时先看这行日志,它一次性告诉你三件事:实际用了哪个内核、你请求的是什么、以及那个决定性的 128 有没有对上。

为什么预填充要单独优化

值得解释一下为什么「预填充」这个阶段会有专门的内核。

大模型推理分两个阶段。预填充是把你输入的提示词一次性全部读进去,算出初始状态;解码是之后逐个 token 往外吐。两个阶段的计算特征完全不同——预填充要处理一整段序列,是批量的、并行度高的;解码每次只处理一个 token,是串行的、受内存带宽限制的。

对线性注意力来说,这个差别更明显。预填充可以把整段序列切成块并行处理(源码里那个 ChunkGatedDeltaRule 的类名就点明了这一点,chunk 就是分块),而解码只能一步步递推状态。

所以两个阶段值得用不同的内核。三选一的那套逻辑管的是预填充,解码那边有另一套分支——这也是为什么这个文件里方法名会分成 forward_*_forward_core_decode_* 两大类。

对使用者来说,这个区分带来一个实际现象:长提示词和长输出的性能特征是不一样的。 喂进去一份十万字的文档(预填充为主)和让模型写一篇长文(解码为主),两者的瓶颈不在同一处,能用上的优化也不同。这也是为什么各家的性能数字通常会把「首 token 延迟」和「后续吐字速度」分开报——它们背后是两套不同的代码路径。

不止预填充:解码路径也分了好几条

除了预填充的三选一,这个文件里还有一大堆按平台和场景分的执行路径。类里能看到 forward_cudaforward_hipforward_xpuforward_cpu 四个平台分支,解码路径下面还细分出好几条,名字里带着 aiternon_specspec_fused_normfused_norm_packed 之类的后缀。

这些后缀透露了分支的依据:是不是在做投机解码、要不要把归一化融合进去、走不走 ROCm 那套 AITER 内核。

其中有两个常量值得记一下:

MAX_FUSED_GDN_MTP_TOKENS = 8
FUSED_GDN_STATE_DTYPES = (torch.float32, torch.bfloat16)

第一个说明 GDN 与 MTP 的融合解码路径有 token 数上限,超过 8 就不走那条快路。这也从侧面说明 MTP 和线性注意力在实现层是有专门协同的,不是各跑各的。

第二个说明融合路径只支持两种状态数据类型:fp32 和 bf16。用别的类型就会退回通用路径。

文件顶部还有一段条件导入,如果 ROCm 的 AITER Triton 内核可用,就把两个融合内核引进来——注释说明是为了避免每次调用时的导入开销。

这些分支说明了什么

一个线性注意力层,光是预填充就有三套内核、解码还有更多分支,加起来这个文件有七万多字节。

这反映了推理框架里一个普遍现实:算法只有一个,实现有很多个。 同一个 Gated DeltaNet,在 Hopper 上、在 Blackwell 上、在 ROCm 上、在 CPU 上,最优的写法各不相同。框架要做的是在运行时把这些条件判断清楚,选一条能走的路。

对使用者的实际启示有三条:

第一,报错和性能问题可能来自内核而不是模型。 尤其是首次运行慢、或者换了张卡表现就不同,先看看是不是走了不同的内核分支。

第二,日志比猜测可靠。 那行 Using ... GDN prefill kernel 是免费的信息,起服务时扫一眼就有。

第三,升级框架版本可能改变行为。 这些选择条件是硬编码在源码里的,新版本加了新内核、改了阈值,你的实际执行路径就跟着变了。遇到「同样的配置换个版本表现不一样」,这类分支逻辑是值得怀疑的地方之一。

小结

  • vLLM 里 GDN 的预填充内核有三套:Triton、FlashInfer、CuteDSL
  • 选择依据是显卡架构(Hopper / Blackwell)、CUDA 运行时版本、以及 linear_key_head_dim 是否等于 128
  • Qwen3.8-27B 的 linear_key_head_dim 正好是 128,满足带该约束的分支
  • FlashInfer 那套是 JIT 编译的,首次运行慢是预期行为,可用 --gdn-prefill-backend triton 跳过
  • 配置项名为 gdn_prefill_backend,默认 auto;日志会打印实际生效的内核
  • 解码路径还按平台和投机解码场景细分,融合路径有 8 token 上限和 fp32/bf16 的类型限制

更多拆解在 Qwen3.8-27B 专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。