DCU使用技术报告(中篇):gfx936 DCU上的Qwen3.5-27B Triton-ROCm Attention与GEMV深度优化实战

系列文章中篇。上篇解决了环境、基线和 Profile 的可信度问题;这一篇进入算子层,记录专用 Prefill Attention、Q10/Q20 Query 复用、Gate/Up Triton GEMV,以及一批看起来合理但最终没有采用的实验。

0. 先给结果,不绕弯子

我们最终保留的主要算子优化有三类:

  1. Qwen3.5 固定形状的专用 BF16 Prefill Attention,并对长 KV chunk 使用双阶段流水;
  2. Prefill Query 分块复用:基础 Q10,以及只命中长 KV 完整 chunk 的 Q20;
  3. gfx936 上固定形状的 Gate/Up Decode Triton GEMV。

实测中,Gate/Up GEMV 相对原 rocBLAS 路径带来了约 14.91% 的三档前五条加权吞吐提升;Q10 Query 复用在它的对照基础上再提升约 6.91%;长 KV Q20 又带来约 1.81% 的增量加权收益,16K-32K 单档提升 3.47%

同时,我们也放弃了不少方向:全局 ROCm Attention 后端、FP8 KV Cache、Page-aware 地址优化、Gate/Up+SwiGLU 融合、多个 rocBLAS tuned solution、Down GEMV、lm_head GEMV。它们有的局部更快,有的微基准很好看,但到了服务层不是回退,就是收益小到不值得承担数值和维护风险。

1. 先理解 Qwen3.5-27B 在这张卡上的固定形状

这套模型不是纯 Full Attention 堆叠。实际 Decode 中,我们关注到几类高频线性形状:

路径 形状(M x N x K 每个 Decode Token 的调用特征
MLP Gate/Up 34816 x 1 x 5120 64 层重复
MLP Down 5120 x 1 x 17408 64 层重复
GDN QKVZ 16384 x 1 x 5120 48 个 Gated DeltaNet 层重复
GDN Output 5120 x 1 x 6144 48 个 Gated DeltaNet 层重复
lm_head 248320 x 1 x 5120 每个 Token 一次

Full Attention 的固定参数则是:

query heads = 24
KV heads    = 4
head dim    = 256
GQA ratio   = 6
dtype       = BF16

这些形状很重要。通用 vLLM 必须照顾很多模型、dtype、batch 和可选功能,而比赛负载在单并发 Decode 下反复出现同一组 N=1 GEMV。只有把分派条件收紧到设备、dtype、形状、bias 和内存连续性,我们才有资格删掉通用路径中的开销。

2. 专用 Prefill Attention:先把 GQA=6 这件事做好

项目初期,kernel_unified_attention_2d 一度占 GPU kernel 时间的 47.77%。我们试过调 Prefill/Decode tile,也试过切完整 ROCm Attention 后端,效果都不理想。

完整 ROCm Attention 后端确实让 TTFT 有过明显改善,但 Decode TPOT 分别恶化约 84.0%/172.7%/242.1%,三档吞吐下降约 23.0%/30.6%/27.1%。这说明“Prefill 可能更快”和“整个后端适合服务”是两回事。

后来我们把问题缩到 Qwen3.5 的固定 Prefill 形状,单独写了专用 Triton kernel:

  • 只处理单序列 BF16 Prefill;
  • 固定 24Q/4KV/head_dim=256/GQA=6
  • 保留 QK、因果 mask、在线 Softmax、PV 和 FP32 累加;
  • Decode 继续走原来的 Triton unified attention;
  • 不支持的形状和功能全部回退。

通用 kernel 喜欢按 2 的幂组织 block,但 GQA 比例是 6。专用版本使用 BLOCK_M=32,每个工作组处理 5 token x 6 heads = 30 个有效行,末尾两行屏蔽。这样既覆盖 6:1 的映射,又避免通用映射中重复或无效的输出工作。

这次优化后的 Profile 很直观:同工作负载 GPU kernel 总时间从约 183.3 s 降到 134.7 s,Attention 从 69.3 s 降到 20.3 s,下降约 70.6%。GEMM 绝对时间仍在 81.6 s 左右,所以它的占比反而升到 60.61%

这就是性能优化经常出现的“热点迁移”:不是 GEMM 变慢了,而是 Attention 被压下去后,GEMM 成了新的主角。

3. 为什么双阶段流水不能全局打开

专用 kernel 之后,我们尝试用 tl.range(..., num_stages=2) 做 KV tile 软件流水,并延迟 V tile 加载,减少 K 和 V 同时存活的时间。

一开始很容易产生一个直觉:既然阶段 2 能重叠访存和计算,那就所有 Prefill 都开。实际不是这样。短 KV、尾 chunk 和长 KV 完整 chunk 的资源条件完全不同:

  • 短 KV 没有足够迭代去摊薄流水启动成本;
  • 尾 chunk 的 Query 工作量小,增加寄存器和 stage 可能得不偿失;
  • 长 KV 完整 chunk 才有稳定的访存重叠空间。

最终采用的分派是:

q_len >= 3072 and max_kv_len > 8192 -> stage 2
其他 Prefill 形状                         -> stage 1
Decode                                   -> 原路径

这个条件不根据“数据集是 16K 还是 32K”判断,而是看当前 kernel 的真实 q_lenkv_len。一条 32K 请求的前部 chunk 仍可能走 stage 1,进入长 KV 区间后再切换到 stage 2。

4. Q10 Query 复用:真正减少 K/V 重复读取

Page-aware 实验失败后,我们重新看了一遍 kernel 的数据复用关系。

旧配置是:

BLOCK_Q = 5
BLOCK_M = 32
num_warps = 4

一个 program 只服务 5 个 Query token。对于同一个 K/V tile,不同 Query program 会重复加载。于是我们把每个 program 覆盖的 Query 翻倍:

BLOCK_Q = 10
BLOCK_M = 64
num_warps = 4

它没有改变 Attention 数学,只是让同一批 K/V tile 服务更多 Query。真实形状微基准结果如下:

q_len x kv_len Q10 相对旧路径
4096 x 8K 1.6220x
4096 x 16K 2.0015x
4096 x 32K 1.9750x
3072 x 16K 1.9693x
512 x 32K 1.4811x

五组输出全部逐位一致。

服务层第一次 8K-16K 前五条只提升 1.11%,第五条 TTFT 还有波动。我们没有立刻宣布成功,而是在同一服务上复跑:稳态吞吐从对照的 11.574609 提升到 12.319985 tok/s,增幅 6.44%。随后三档结果为:

对照 Q10 提升
4K-8K 12.400507 12.606487 1.66%
8K-16K 11.574609 12.319985 6.44%
16K-32K 10.174793 11.403174 12.07%

20%/50%/30% 加权,11.319844 -> 12.102242 tok/s,提升约 6.91%。收益随上下文增长而扩大,和“完整 chunk 越多,K/V 复用越充分”的预期一致。

5. Q20 不是 Q10 的简单放大版

Q10 有效后,我们继续测试:能不能把每个 program 的 Query 数再翻倍?

候选包括:

Q20 / M128 / 4 warps
Q20 / M128 / 8 warps
Q21 / M128 / 4 warps

结果很有代表性。Q20/W8 在长 KV 完整 chunk 上很好:

形状 相对 Q10
4096 x 16K 1.4446x
4096 x 32K 1.4408x
3072 x 16K 1.4917x

但它在 4096 x 8K512 x 32K 上回退。全形状几何平均虽然达到 1.1959x,却没有通过“任一案例不得明显回退”的门禁。

根因也不难理解:BLOCK_M=128 会扩大 Query、Softmax 状态和 Attention 累加器,寄存器压力显著增加。长 KV 时减少 K/V 重复加载的收益足够大;短 KV 或小尾块没有那么多加载可省,资源成本就暴露出来了。

我们没有全局打开 Q20,而是把它限制到:

基础 Q10 已开启
q_len >= 3072
max_kv_len > 8192
pipeline stage == 2

其他情况继续走 Q10。服务稳态结果为:

Q10 Q20 混合分派 增量
4K-8K 12.606487 12.629447 0.18%
8K-16K 12.314362 12.505853(两轮合并) 1.56%
16K-32K 11.403174 11.798635 3.47%

增量加权吞吐约提升 1.81%。更关键的是,16K-32K 五条 TTFT 都降低约 0.8 s,而 4K-8K 基本不动,说明分派确实只在预期区域生效。

Profile 也给出了同方向证据:专用 Prefill Attention 从 7.631 s 降到 6.248 s,局部约 1.221x,折算为整段 GPU kernel 时间约 1.56% 的收益。

6. Gate/Up GEMV:这次手写 Triton 确实赢了 rocBLAS

专用 Attention 把瓶颈推向线性层后,最明显的固定形状是:

N=1, M=34816, K=5120, BF16, no bias

这是每个 Decode Token 在 64 层中重复的 MLP Gate/Up 投影。通用 GEMM 库需要覆盖大量矩阵形状,而 N=1 更像 GEMV。我们写了 gfx936 专用 Triton 路径,使用 FP32 累加,并加上严格保护:

device == gfx936
dtype == BF16
N == 1, M == 34816, K == 5120
bias is None
x/weight contiguous

单算子从约 0.501 ms 降到 0.330 ms,约 1.52x。三档前五条服务结果为:

原路径 Gate/Up Triton 提升
4K-8K 10.8661 12.4005 14.12%
8K-16K 9.8869 11.5746 17.07%
16K-32K 9.1149 10.1748 11.63%

加权吞吐 9.8511 -> 11.3198 tok/s,提升约 14.91%。TPOT P99 分别从 69.121/70.674/72.134 ms 降到 57.742/59.200/60.705 ms

后来我们又扫了六组 Triton 配置。当前 M8/K256/W40.320097 ms,最快候选 M32/K128/W80.310118 ms,只快 3.22%,而且不再逐位一致。按当时 17.54% 的热点占比估算,整机上限只有约 0.55%。我们没有为了这半个点继续改。

7. 为什么同一个 Triton GEMV 模板不能到处套

Gate/Up 成功后,最自然的想法是把模板复用到其他 N=1 线性层。结果给了我们一盆很有价值的冷水。

形状 rocBLAS Triton 结果
Down 5120x1x17408 0.152983 ms 0.161243 ms 0.9488x,放弃
lm_head 248320x1x5120 1.901581 ms 2.129770 ms 0.8920x,放弃
GDN Output 5120x1x6144 0.051207 ms 0.055234 ms 0.9271x,放弃

形状不同,权重规模、归约长度、输出行数、cache 行为和并行度都不同。N=1 只是共同点,不是性能保证。

GDN QKVZ 的早期 Triton 版本单算子倒是很亮眼:0.241954 -> 0.153135 ms,达到 1.58x。但服务稳态总输出从 887 变为 775,只有 3/5 文本逐字一致,吞吐还低 0.43%。即使 TPOT 改善了 6.26%,我们仍然撤销。比赛看的是完整服务结果,不是单个 kernel 的秒表。

8. Page-aware:微基准赢了,服务却输了

这次失败很值得单独写。

服务的 Attention page size 是 784,KV tile 是 32。一个 tile 最多跨一个 page,于是我们尝试把每个 lane 的整数除法、取模和重复块表读取,改成每个 tile 只算一次页号,再用比较和减法映射偏移。

微基准结果不错:

8K  : 1.0356x
16K : 1.1197x
32K : 1.1240x

输出逐位一致,按权重估算约 1.1044x。但进入 8K-16K 前五条后:

吞吐      11.574609 -> 11.339427 tok/s,下降 2.03%
TTFT P99  6591.610  -> 8501.262 ms,上升 28.97%
TPOT P99  59.200    -> 59.144 ms,几乎不变

问题在测试覆盖。首版微基准偏向 q_len=512 尾块,没有充分覆盖主导流量的 q_len≈4096 完整 chunk。省下的整数地址计算,抵不过完整块上的额外指令和寄存器压力。

这次之后,我们给所有 Prefill 微基准加了一条硬规则:必须覆盖 4096/3072/512 三类 Query 长度,并同时覆盖短、中、长 KV。

9. rocBLAS 调优:跑了八分半,solution 还是没赢默认路径

通用 GEMM 已占 Profile 的一半以上,我们当然试过 rocBLAS tuning。

Gate/Up Prefill 形状为:

M=34816, N=4096, K=5120, BF16, compute=FP32

rocblas-gemm-tune 一开始五分钟没有任何输出,我们误以为卡住。后来把权限和输出缓冲问题处理好,给它 30 分钟超时,实际约 8 分 34 秒返回:

solution_index = 20981

随后交替测默认 solution 0 和 20981:

solution 0     4317.90 us
solution 20981 4327.15 us

调优器找到的 solution 并没有赢,差异甚至在噪声范围内。

其他形状也类似:

形状 tuned 相对默认
Decode Down 5120x1x17408 +0.02%
GDN QKVZ 16384x1x5120 约 0%
Prefill Down 5120x4096x17408 +1.05%
Prefill GDN Output 5120x4096x6144 +0.13%

我们把 5% 设为局部门槛,这几组全部不进入服务。不是 tuning 工具没用,而是当前库的默认启发式已经选到了几乎相同的实现。把一个 1.05% 的单 GEMM 收益接进 vLLM,最后很可能连测量噪声都盖不住。

10. Gate/Up 与 SwiGLU 融合:理论正确,收益不够

Gate/Up GEMV 后面紧跟 SiluAndMul。融合后可以少写一次 34816 维中间结果、少读一次,并减少一次 kernel launch。这个方向从结构上完全说得通。

实际测试中:

当前 GEMV + 原生 SiluAndMul: 0.339072 ms
最快融合 kernel:             0.320438 ms
局部加速:                    1.0581x

最大绝对误差是 0.03125,allclose 和确定性通过,但不逐位一致。按 Gate/Up 路径 25.37% 的占比估算,整机理论收益约 1.39%

我们最后没有接入。原因不是 1.39% 没价值,而是它不足以覆盖跨层数值变化、共享 MLP 接口改造和完整精度验证的风险。性能优化不能只算省了几次访存,还要算工程成本和出错半径。

11. 中篇小结:形状感知比“全局最优”更重要

这一阶段留下来的经验可以归纳为四条:

  1. 专用 kernel 必须建立在固定、反复出现的真实形状上。 Gate/Up 赢了,不代表 Down 和 lm_head 也会赢。
  2. 微基准必须覆盖服务中的完整 shape family。 Page-aware 的失败,本质上是测试只看了容易赢的一部分。
  3. 局部最优不等于服务最优。 GDN QKVZ Triton 单算子 1.58x,最终却改变输出并拖慢吞吐。
  4. 允许按张量形状回退,往往比寻找一个全局参数更有效。 Q20 只有在长 KV 完整 chunk 上才值得启用。

下篇会继续讲这些候选如何进入工程:环境变量为什么默认关闭、如何保证异常情况下恢复 BLAS backend、怎样做吞吐和精度门禁,以及我们在部署、hipprof、E-Shell 和容器重建上踩过的坑。


系列导航

  • 上篇:环境、基线、分块负载与干净 Profile
  • 中篇:专用 Prefill Attention、Query 复用与 GEMM 筛选(本文)
  • 下篇:默认关闭、严格回退、精度门禁与可复现部署

建议标签: DCUROCmHIPTritonvLLMGEMVAttention优化

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐