本文的实验对象是 Qwen3.5-27B,运行在一张海光 DCU 上:ISA 为
gfx936,具有 80 CU、64 GiB HBM 和 wave64 执行模型;推理框架为
赛事版 vLLM,基于 v0.18.1 修改。
最近 Kimi K3 发布,2.8 万亿参数,其中就就用到了混合注意力机制。但"混合注意力"这个词对很多人来说还停留在架构论文里
的一个标签——它到底怎么跑?GDN 层和 Full Attention 层在推理时
分别做什么?省下的 KV cache 换来了什么、又没换到什么?
本文不打算对比各家方案(K3 的 KDA 和 Qwen 的 GDN 在门控粒度上
各有取舍),而是选一个更基础的角度:拿同样采用混合注意力的
Qwen3.5-27B,跟着一个 token 走一遍真实推理,看它在海光 DCU 上
究竟经历了什么、瓶颈落在哪里。
一、一个 token 要穿过 64 层:3:1的编排长什么样
Qwen3.5-27B 不是一个把所有 Transformer block 都换成线性注意力的模型。
它在 64 层中保留了 16 层 Full Attention,其余 48 层使用 Gated DeltaNet
(GDN,一种线性注意力)。按从 0 开始的层号计算,Full Attention 位于第3, 7, 11, …, 63层,编排是一段严格重复的节律:
GDN → GDN → GDN → Full Attention 重复 16 组推理阶段只有前向传播——权重已经训练好、冻结了,你只是拿它算答案。
前向的关键性质是逐层串行:一个 token 进来,要依次穿过 64 层,
第 i 层算完的结果正是第 i+1 层的输入。在 vLLM 里就是一个朴素的for循环:
# vllm/model_executor/models/qwen3_next.pyhidden_states=self.embed_input_ids(input_ids)forlayer_idx,layerinenumerate(self.layers):# 64 层,一层接一层hidden_states,residual=layer(positions=positions,hidden_states=hidden_states,residual=residual,)hidden_states既是入参又是返回值——第 i 圈没跑完,第 i+1 圈就没有
输入。Prefill 和 Decode 都是这同一段代码,区别只在一次喂进循环的
token 数量:Prefill 一次喂进整段 prompt,Decode 每次只喂一个新 token。
两类层的核心分野在于持久状态:
| 层类型 | Prefill | 单步 Decode | 持久状态 |
|---|---|---|---|
| Full Attention(16 层) | O(S²)attention | O(S)读取历史 KV | KV cache,随上下文增长 |
| GDN(48 层) | O(S)state scan | 近似O(1)状态更新 | recurrent state,固定大小 |
一句话:Full Attention 用"越堆越高的 KV"换精确记忆,GDN 用"定长
状态"换恒定开销。但序列混合器只是每层的一部分——两类层仍然都要
执行 projection、Norm 和 FFN。“48 层 GDN"不等于"48 层不再读取模型
权重”。这个区别是理解后续所有分析的起点。
本文的主线,就是带着一个 token 依次穿过这两套序列混合器,看它在
Prefill 与 Decode 中分别撞上 DCU 的哪一堵物理墙。
二、GDN 的"固定状态"到底是什么:从权重文件到运行时
上一节说 GDN 维护一块"固定大小的状态矩阵"。但这块矩阵不是模型文件里
的某个静态张量——它是推理过程中被动态读写的运行时数据。要理解 GDN 省了
什么、没省什么,得先看清权重文件里存了什么、运行时又算了什么。
权重文件里存的是投影矩阵
打开模型的.safetensors或.bin,GDN 层里存的是这样几组权重:W_k、W_v(把 hidden state 投影成 key 和 value)、W_α、W_β
(把 hidden state 投影成两个门控值)、以及W_o(输出投影)。这些矩阵
在训练时学出,推理时冻结不变。它们决定了"如何从 token 的表示算出后续
一切"。
运行时:每个 token 都重新算一遍门控
推理时,每来一个新 token,GDN 层做五件事:
- 投影:
k_t = W_k · x_t,v_t = W_v · x_t; - 算门控:
α_t = sigmoid(W_α · x_t),β_t = sigmoid(W_β · x_t); - 更新状态:用 delta rule 改写状态矩阵
S; - 读出输出:
o_t = S_t · k_t; - 输出投影:
W_o · o_t,传给下一层。
其中第 3 步是 GDN 的核心,状态更新公式为:
St=St−1⋅αt(I−βt ktkt⊤)+βt vtkt⊤S_t = S_{t-1} \cdot \alpha_t\bigl(I - \beta_t\, k_t k_t^\top\bigr) + \beta_t\, v_t k_t^\topSt=St−1⋅αt(I−βtktkt⊤)+βtvtkt⊤
两个门控值各有分工:α_t(衰减门控)控制"忘掉多少旧记忆"——
趋近 0 时几乎清空历史,趋近 1 时完整保留;β_t(写入强度)控制
“新信息以多大力度覆盖旧关联”。关键在于,α 和 β 不是常数,它们
是每个 token 经过投影和 sigmoid 实时算出来的,值取决于当前 token 的
内容。处理一段否定句时,α 可能会升高以主动重置状态,避免逻辑误判。
公式里的SSS就是 vLLM 的 cache manager 在 HBM 上分配的一块
固定大小的 buffer。它不逐个保存历史 token,而是把整段历史压缩进
这个尺寸不随长度增长的矩阵里。无论上下文是 4K 还是 32K,GDN 每层要
维护的状态大小几乎不变——代价是它对历史的记忆是有损压缩的,而不是
逐 token 精确保留。
两类状态,两种代价
把 GDN 和 Full Attention 的持久状态放在一起看:
Full Attention 的 KV cache:每读到一个新 token,就把它的 K/V
追加进缓存。生成第 S 个 token 时,attention 要回看前面全部 S−1 个
token 的 K/V。上下文越长,保存和读取的东西越多——这是"记住每一个
历史 token 的原始信息"的代价。GDN 的 recurrent state:不保存历史 token 本身,而是用 delta
rule 把每个新 token "融进"固定大小的状态矩阵。上下文再长,状态大小
不变——但历史是被压缩过的,不是精确保留。
Qwen3.5 让 48/64 层走后者、16/64 层留前者,本质上是在"状态成本"和
"记忆保真度"之间做了个 3:1 的折中。
混合注意力首先降低的是序列状态成本,不是模型参数的读取成本。
记住这句话,下面两节讲的 Decode 带宽墙和 Prefill 瓶颈迁移,都从
这里出发。
三、Decode:GDN 省下了 KV,但 27B 权重每步都要重读一遍
要知道一个 token 撞的是哪堵墙,先要知道墙在哪里。在当前
BW/gfx936+ DTK 26.04 + PyTorch 2.10 + rocBLAS 的软件栈下,我们用
一段 2 GiB 流式读探针和 BF16 方阵扫描,测出这套环境的可持续实测
标尺:
| 当前软件栈下的可持续标尺 | 实测值 |
|---|---|
| 持续纯读带宽 | 约1230 GB/s |
| BF16 方阵 GEMM(最佳 shape) | 约425 TFLOPS |
| 对应 Roofline 脊点 | 约345 FLOP/byte |
其中带宽来自 2 GiB float4 数组(远大于 cache)的连续读,425 TFLOPS
是 4096–16384 方阵扫描中最佳 shape(12288 方阵)的持续吞吐——换一个
shape 未必能达到。脊点由前两者直接相除得到。这个 345 FLOP/byte 的
脊点,是判断一个 kernel 是"算力受限"还是"带宽受限"的分水岭。
并发为 1 时,Decode 的线性层大多退化成N=1的 GEMV——一个权重矩阵
乘一个向量。每读一个 BF16 权重元素(2 字节)大约只做 2 次浮点运算,
算术强度约为1 FLOP/byte,比 345 的脊点低了两个多数量级。具体到
整模型:Qwen3.5-27B 每生成一个 token,文本路径大约只做了几十 GFLOP
的计算,却要读掉约 54 GB 的 BF16 权重:
权重读取下界 = 约 53.8 GB / 1.23 TB/s ≈ 43.7 ms/token 计算下界 = 计算量 / 425 TFLOPS ≈ 亚毫秒/tokenDecode 一个 token 的时间,几乎完全由"把 27B 权重搬过一遍"决定。
短 prompt、强制生成 128 tokens 的实测里,每 token 时间的中位数就落在
约 44.9 ms——和 43.7 ms 的读取下界几乎贴在一起(这个粗算没有区分
KV、activation 和写流量,只支持方向性判断)。
这正是第二节那句判断的落地:
GDN 编排显著降低了长上下文的状态成本,但单请求 Decode 仍然首先被
27B 稠密权重流死死压在 DCU 的 HBM 带宽墙上。
一个直接的推论:既然瓶颈是"搬权重",那么在 Decode 上真正有效的优化,
是让这条所有层共有的权重读取路径跑得更贴近带宽上限——而不是去动
Prefill 里的序列混合器。我们确实观察到,按固定 shape 离线挑选最快的
GEMM 实现,能在 Decode 上吃到最明显的收益,却几乎不改变首 token 延迟
(TTFT);反过来,几种"看起来更专用"的自定义 GEMV,在这块 wave64
设备的真实 shape 上反而更慢。原因不难理解——当持续带宽已经是主限制
时,额外的花样并不能凭空造出新带宽。
到这里,Decode 的故事讲完了:它撞的是 HBM 带宽墙,而且这堵墙和上下文
长短关系不大,短 prompt 就已经贴住了。但请注意,上面全程说的是"每次
只喂一个 token"的 Decode。一旦换成 Prefill——一次把成千上万个 token
喂进同一段前向——算术强度会彻底翻转,token 撞上的也会是一堵形状完全
不同的墙。
四、Prefill:3:1编排把平方项往后推,但没有消除它
Prefill 一次喂进的不是一个 token,而是整段 prompt,所以 GEMV 变回了
真正的矩阵乘 GEMM,算术强度一下子回到脊点右侧——Prefill 基本不再
被权重带宽卡住。它的成本换了个来源:注意力那部分随序列长度按平方
增长的计算。
TTFT 随长度爬升
固定输入、只测首 token 的热态实测里,TTFT 随 prompt 长度的增长是
这样的:
| Prompt 长度 | TTFT 中位数 | 相邻倍率 |
|---|---|---|
| 4K | ~0.9 s | - |
| 8K | ~2.1 s | ~2.3× |
| 16K | ~5.3 s | ~2.5× |
| 32K | ~15 s | ~2.8× |
如果 64 层全是 Full Attention,长度翻倍时会有更大比例的工作按平方增长。
Qwen3.5 只在 16 层保留这个平方项,所以整条曲线明显低于纯O(S²);但
相邻倍率从 ~2.3× 一路爬到 ~2.8×,说明 Full Attention 正在一点点接管
长上下文的成本。
瓶颈迁移:从 GEMM 到 Full Attention
把 16K 和 32K 的 kernel 时间分解开,这次"瓶颈迁移"看得非常直接:
| Kernel 组 | 16K 占比 | 32K 占比 | 16K → 32K 增长 |
|---|---|---|---|
| Projection / MLP GEMM | ~50% | ~36% | 约 2× |
| GDN | ~8% | ~6% | 约 2× |
| Elementwise / KV update | ~3% | ~2% | 约 2× |
| Full Attention | ~39% | ~56% | 约 4× |
两个长度下 GPU 几乎满载(busy 都在 99% 以上),所以这不是 CPU 调度
或 launch 空洞的问题,而是实打实的计算结构在起作用:
所以,"混合注意力适合长上下文"更准确的说法是:
GDN 把 48/64 层的平方项换成了线性项,显著推迟了 Full Attention
成为主瓶颈的位置;但剩下的 16 层,终究会在足够长的上下文里重新主导
TTFT。
两类状态共享一块显存
Prefill 的瓶颈不只是计算,还牵涉显存。Full Attention 要保存 K/V,
GDN 要保存 recurrent state——在 vLLM 里,这两类状态共同受 cache
manager、page 约束和显存预算影响。
一个直观的例子:KV 的数据精度一变,可用容量会跟着一起变。在本文当前
软件路径下,用 INT8 存 KV 时每个 attention page 对齐到约 1552 tokens,
整机可服务的 KV 容量约 54K tokens;换成 BF16,page 缩到约 784 tokens,
容量近乎减半。所以在 Qwen3.5 上切换 KV 精度,从来不是一个局部的
“每元素从 2 字节变 1 字节”,它会同时牵动 16 层 Full Attention 的 KV
流量、长 Prefill 是否命中专用的低精度 kernel、最大可服务的 prompt
长度、以及 KV cache 与 GDN state 共享的那一整块显存预算。
也正因如此,INT8 和 BF16 在 Prefill 和 Decode 上各有胜负,并不是
"INT8 一定更快"那么简单:BF16 Decode 省掉了 INT8 的读取转换与反量化,
上下文越长,它在 16 层 Full Attention 上的每 token 优势越明显;但 INT8
有专用的低精度 Prefill 路径和近两倍的容量,所以首 token延迟和"到底
能不能塞下 32K prompt"这两件事上,反而是 INT8 明显更好。在本文的配置
里,只有 INT8 能容纳 32K prompt。
(需要说明的是:本文并没有做完 BF16/INT8 的完整生成质量对比,因此不能
把上面的性能结论外推成"INT8 没有精度损失"。KV 精度在这里被当作一个
系统容量与吞吐的权衡来对待,而不是一个纯粹的数值精度问题。)
顺带一提,单纯把max_num_batched_tokens从 4096 加大到 8192 并不能
让长 Prefill 更快——它只改变了工作的分段方式,并没有减少那 16 层对累计
历史的回看,反而会压缩可用的 KV 容量。这里也要提醒别把两个数字混为
一谈:4096 是 Prefill 的调度 chunk,决定一次送多少输入进计算;
1552 则是 INT8 KV/GDN 状态对齐后的cache block,决定缓存怎么分配。
二者是两套不同的粒度。
五、wave64 的寄存器台阶:Full Attention 在 DCU 上的资源几何
上一节讲到,长 Prefill 的瓶颈最终落在 16 层 Full Attention 的平方项上。
这一节看这个瓶颈在gfx936上的具体形状——它不是一堵均匀的墙,而是
有台阶的。
长 Prefill 的主力 Full Attention kernel 用 256-thread 的 workgroup。
在 warp32 的 GPU 上,这通常被理解成 8 个 warp;但在gfx936上,它
实际是4 个 wave64。
这个 kernel 每线程用掉约 230 个向量寄存器(VGPR),没有 spill。结合gfx936每个 CU 的寄存器预算,这样的占用大约只能同时容纳 3 个
workgroup。想跨到 4 个 workgroup 的下一个 occupancy 台阶,每线程的
VGPR 需要压到约 192 以下。
profiler 给出的信号也指向同一个方向:这个 kernel 的显存单元并不算忙、
几乎不因 DRAM 而 stall、L2 命中率接近 95%——它不像被 DRAM 拖住的
kernel,更像一个卡在寄存器占用率台阶上的 wave64 kernel。当我们在保持
tile、wave 数和计算逻辑都不变的前提下,主要通过减少中间累加器的存活
范围和临时向量的分配,把每线程 VGPR 从 230 压到 188、跨过那个台阶后,
这一族 attention kernel 明显变快;但如果反过来机械地加到 8 waves,在
wave64 上会生成 512-thread 的 workgroup,实测大幅回退。
这正是本文最想强调的一条 DCU 特性:
在 wave64 DCU 上,一个 Full Attention kernel 的 thread 数、wave 数
和 VGPR 数是同一个资源方程里的三个变量。只盯着 tile 大小,或者机械
照搬 warp32 上"多加几个 warp"的经验,很容易跨错台阶、把性能调反。
不过要注意,这个优化只作用在 16 层 Full Attention 的 Prefill kernel
上。它不会加速 48 层 GDN、不会加速所有层的 projection/FFN、也不会加速
Decode 的权重 GEMV。所以哪怕单个 kernel 提速可观,落到包含 Decode 的
完整请求里,收益也会被混合编排的其他路径大幅稀释——这不是玄学,而是3:1层占比给每类优化划死的上限。
六、3:1的规律不能给你并行,但能给你可预知性
既然GDN → GDN → GDN → Full Attention是一段严格重复 16 次的节律,
一个很自然的想法是:能不能借这份规律,把算力和显存分配得更聪明?
答案是能,但要先分清一件事——这份规律带来的不是并行,而是可预知性。
先说为什么不是并行。前向是一条严格的串行依赖链:第 i 层的输出就是
第 i+1 层的输入,所以你没法让同一个 token 的 3 个 GDN 层和后面那 1 个
Full Attention 层同时开跑。批处理也一样——整批请求会一起走到同一层,
不存在"请求 A 在算 attention、请求 B 同时在算 GDN"这种天然重叠。
但"层序是写死的、可以提前算清楚"这一点,本身就是红利。它体现在三处:
1. 显存可以按 16:48 静态切分。
既然永远只有 16 层需要保存 KV,48 层只需固定大小的 recurrent state,
HBM 就可以在启动时一次切好:KV 池按 16 层预算,state 池按 48 层预算,
不必在运行时动态去猜。这正是混合模型省显存的根——KV 预算只有同规模
纯 Full Attention 模型的约 1/4,省下来的容量直接变成"能服务更长的
prompt"。
2. 下一步要读什么,是确定的,因此可以提前预取。
因为节律固定,算到某一层时,你已经确切知道几层之后就是一个 Full
Attention 层。于是可以在 GDN 层(偏计算)执行期间,提前把接下来
attention 层要用的数据往片上搬。这是唯一能把"3 层偏计算"和"1 层偏
带宽"部分重叠起来的路子——注意它是层间预取,不是层内并行,收益
上限依旧受那条串行依赖链约束。
3. 每类优化的收益天花板,可以直接从占比算出来。
规律让你不必猜就知道:调 Full Attention kernel 最多只影响 16/64 层;
调 GDN 的 state kernel,影响的还不到 48/64——因为 GDN 层里大头其实是
qkvz、ba、output 这些 projection GEMM,真正的 recurrent state kernel
只占其中一小块。这就解释了前面反复出现的现象:单个 kernel 在微基准里
提速很可观,落到完整请求却只剩很小一点。层占比先天就给每类局部优化
封了顶。
收束:token 走过的路
把这一路的观察收拢起来,可以得到一张对应表——它讲的是哪条编排路径,
撞上了哪堵 DCU 的墙:
| 编排路径 | 撞上的 DCU 边界 | token 的实际经历 |
|---|---|---|
| 64 层稠密 projection/FFN,每 token 重读 | HBM 持续带宽 | Decode 时间几乎等于"搬一遍 27B 权重" |
| 48 GDN + 16 Full Attention | 线性项与平方项随 S 分化 | 到 32K,Full Attention 升为第一热点 |
| 256-thread Attention workgroup | wave64 下的 VGPR occupancy 台阶 | 压低 VGPR 跨过台阶才提速,加 wave 反而更慢 |
| Full KV + GDN recurrent state | cache manager / page / 总显存共同约束 | KV 精度一变,容量、kernel、page 全跟着变 |
看待混合模型性能的正确顺序是:
- 先认清 token 此刻走在哪条路径上。64 层共有的权重路径、16 层
Full Attention、48 层 GDN state,收益上限完全不同。 - 再判断它撞上了哪个 DCU 资源边界。Decode GEMV 撞 HBM 带宽;
长 Prefill attention 撞 wave64 下的 VGPR/occupancy 台阶。 - 最后回到完整编排里核对收益。一个 kernel 在微基准里再快,也只
说明局部路径成立;它能否真的改变 TTFT、TPOT 或显存上限,取决于它
在3:1编排里占多大的份额。
一句话收束这一节:
3:1的规律不能让你把串行的层并行掉,但它让三件事从运行时的猜测
变成编译期就能算清的账:哪块显存该留给谁、下一步该预取什么、优化
某个 kernel 的天花板有多高。混合注意力真正好调度的地方,是它的
可预知性,而不是它的并行性。
七、结论:混合注意力重排了 DCU 上的瓶颈,而不是消灭瓶颈
跟着一个 token 在海光gfx936DCU 上走完这一遍,可以把观察归纳成
四点。
第一,GDN 降低的是序列状态成本,不是权重成本。
48 层 GDN 让大部分层不再保存随上下文增长的 KV,但 27B 稠密权重仍要在
每个 Decode token 里被重读一遍。所以短上下文的 Decode,依旧贴着 HBM
带宽墙。
第二,3:1编排把 Full Attention 瓶颈往后推,没有把它删掉。
上下文从 16K 涨到 32K,GDN 和 GEMM 大约翻倍,Full Attention 却涨了
约 4 倍,到 32K 占去一半以上的 kernel 时间。上下文越长,剩下 16 层的
平方项越难被 48 层的线性路径盖住。
第三,wave64 改变了 kernel 的资源几何。
256 个 thread 是 4 个 wave,不是 8 个 warp。把 VGPR 压下去、跨过
occupancy 台阶,比继续抠 page-table 算术更有效;机械地加到 8 waves
则会大幅回退。这是 warp32 经验不能直接平移的地方。
第四,混合模型的 cache 是一个系统问题,不是单算子问题。
KV 精度直接改变 Full Attention 的 KV 字节量、page 尺寸、Prefill
kernel 路径和最大 prompt 容量,并间接改变 KV 与 GDN state 共享的总
显存预算。它并不会把 GDN 的 recurrent state 也量化掉,所以也不能只看
单个 attention kernel 的快慢来做决定。
而这四点背后,还有一条贯穿始终的线索——那段3:1的规律。它不给你
免费的并行,却给你可预知性:显存可以静态分账,下一步可以提前预取,
每类优化的天花板可以提前算清。
最终,这一趟 token 之旅给出的不是一份普适的"大模型优化清单",而是
一个对 Qwen3.5 与海光 DCU 更具体的认识:
要理解混合注意力在 DCU 上的性能,不能只数 Full Attention 和 GDN 各有
多少层。要跟着一个 token,看它如何穿过两套序列混合器、两类持久状态
和全模型稠密权重,再看这些路径分别落在 HBM 带宽墙,还是 wave64 的
资源台阶上——混合注意力做的是把这些瓶颈重新排布,而不是让它们
消失。
实验口径与参考资料
本文数据来自 Qwen3.5-27B、单张海光gfx936DCU、并发 1 的在线服务
实验。文中出现的时间、带宽、占比均为同一环境下的实测量级,用于说明
瓶颈所在,不作为精密硬件规格;不同阶段的数字只在明确的同环境对比或
分层 profiler 口径下比较,不把历史 kernel 路径与当前路径拼接成结论。
为聚焦"混合注意力在 DCU 上经历了什么"。
进一步阅读:
- Williams et al.,Roofline: An Insightful Visual Performance Model;
- FlashAttention:
github.com/Dao-AILab/flash-attention; - vLLM:
github.com/vllm-project/vllm; - Flash Linear Attention:
github.com/fla-org/flash-linear-attention; - ROCm HIP:
github.com/ROCm/HIP。