海光 DCU 上 Qwen3.5 推理性能工程:从 Prefill/Decode 热点到端到端收益

摘要

在大模型推理优化中,最容易被误读的一件事,是把某个 kernel 的局部加速比直接当成服务吞吐的提升。一个 kernel 即使在独立测试中快了 1.08 倍,完整服务也可能只快几个百分点,甚至几乎没有变化。

本文以海光 DCU gfx936 路径上的 Qwen3.5-27B BF16 推理为例,使用本地已经完成的 profile、kernel microbench 和同容器端点 A/B 数据,回答三个问题:

  1. Prefill 和 Decode 分别被什么算子限制;
  2. 为什么 BF16 的 M=1 GEMM/GEMV 会成为 Decode 的主要性能墙;
  3. 一个局部优化要经过哪些验证,才能判断它确实改善了完整推理服务。

本文只讨论 DCU 推理性能工程。文中所有性能数字均来自本地固定环境的已有测量记录;独立 kernel 数据、profile 数据和端到端服务数据分别标注,不把它们混成一个结论。


1. 先把推理拆成 Prefill 和 Decode

一次生成请求通常可以分成两个阶段:

  • Prefill:一次处理较长的输入,把提示词转换成初始状态;
  • Decode:之后逐 token 生成,每一步通常只处理一个新 token,同时读取此前保存的状态。

这两个阶段看起来属于同一个模型,硬件行为却很不一样。

Prefill 的 query 维度较大,矩阵乘法更接近大 GEMM,Attention 也能够在较大的 token 块上并行。Decode 的 query 维度通常是 1,很多矩阵运算退化成 M=1 的 GEMM 或 GEMV:计算量不大,但每一步都要读取很大的权重,并且要承担大量 kernel launch 和运行时调度开销。

可以用一个简化的时间模型表示完整请求:

request time = prefill GEMM
             + prefill attention
             + other prefill linear layers
             + decode linear layers
             + decode state access
             + sampling and runtime overhead

因此,“Attention 很快”或者“某个 GEMM 很快”都只说明了总时间中的一个部分。真正值得关注的是:这个部分在目标 workload 中占多大比例,以及它是否会在每个生成 token 中反复出现。


2. 实验环境与测量协议

本文引用的本地测量使用以下环境和口径:

项目 配置
加速卡 海光 DCU,gfx936-class 路径
模型 Qwen3.5-27B
权重计算类型 BF16 原始权重
PyTorch 2.10.0
推理框架 vLLM 本地运行环境
Standalone warmup 10 次
Standalone measured iterations 40 次
端点测试 固定请求集、并发 1、同一容器 A/B
主要指标 output throughput、mean/p99 TPOT、TTFT、输出文本、失败数

测量分成三层:

  1. Profile:回答时间消耗在哪里;
  2. Standalone/CUDAGraph microbench:回答目标 kernel 的局部上限;
  3. 端点 A/B:回答完整服务实际得到多少收益。

这三层不能互相替代。Profile 适合定位问题,不适合直接当吞吐成绩;microbench 适合筛选实现,不适合直接代表服务;端点 A/B 才能验证全链路效果。

本文中的 profile、RPB2 端点和 Tail-M 反例来自不同的历史实验对象:它们用于解释不同层次的性能现象,不是同一个源码组合的叠加结果,文中的提升数字不能相加。


3. 一次长上下文 profile 看到了什么

本地 profile 记录了一个 page832、prefix caching 关闭、all-Tail-Q vendor attention 的官方 8K prompt 路径请求的阶段耗时:

q=4096 prefill total: 2720.145 ms
q=1685 prefill total:  464.801 ms
decode total:        2586.460 ms
decode average:        43.108 ms/token

3.1 Prefill:Attention 之后,大 GEMM 成为主要时间桶

在 q=4096 的 Prefill 中,主要时间项如下:

时间项 耗时
Attention 238.718 ms
aten::mm 1840.596 ms
aten::mm 占 q=4096 Prefill 67.67%

这组数据给出了一个很直接的判断:当 Attention 已经被压低之后,继续在 Attention 的边界参数上做小范围调整,收益会受到总时间占比限制。此时更值得研究的是大 GEMM 的数据访问、tile 组织和实际调用形状。

这里的 aten::mm 不是一个抽象的“模型耗时”,而是一批真实矩阵乘法调用的汇总。要继续优化,必须把它拆成具体的 M、N、K 形状、dtype、调用频率和是否被其他算子覆盖,而不是只看总桶名称。

3.2 Decode:Linear/GEMV 占据绝大多数时间

同一份 profile 的 Decode 统计如下:

时间项 每 token 耗时
Linear 总计 38.078 ms
LLMM1 23.470 ms
rocBLAS GEMV 14.607 ms
Linear 占 Decode 88.33%

每个 Decode token 中观察到:

LLMM1:        176 launches/token
rocBLAS GEMV: 129 launches/token

129 次 GEMV 的来源可以按模块归纳为:

64  MLP down projection
48  GDN output projection
16  attention output projection
1   lm_head

这说明 Decode 的优化重点和 Prefill 不同。Decode 不是单纯寻找一个“更快的 Attention”,而是要处理大量小矩阵运算:每一步都重复执行,单次节省很小,但乘上层数和生成 token 数后可能形成可观的端到端差异。


4. 为什么 DCU 上的 BF16 M=1 运算特别难

当矩阵乘法的 M 维度为 1 时,计算并行度相对有限,而权重矩阵仍然很大。此时 kernel 往往更接近受权重读取和访存效率限制,而不是受理论 FLOPS 限制。

一个简化的 M=1 计算可以写成:

y[j] = sum_k x[k] * W[j, k]

对每个输出行都要遍历 K 维权重。性能会受到以下因素共同影响:

  • 权重加载是否连续、是否能够合并访问;
  • 一个 block 处理多少输出行;
  • K 维归约时 wave 内线程如何协作;
  • BF16 读取和 FP32 累加之间的转换开销;
  • 输出写回是否造成额外同步;
  • kernel launch 和图 replay 是否已经成为主要成本。

因此,M=1 kernel 的优化通常不是简单地把线程数调大。线程过多会增加归约和同步成本,线程过少又无法充分隐藏权重读取延迟。真正有效的配置往往只适合一组固定的模型形状。

本文中的 RPB2 实验保持 BF16 原始权重,并保留已验证的 FP32 pair accumulation,只调整同一 LLMM1 实现的输出行分块方式。这里的 RPB 可以理解为 rows per block:一个 block 负责的输出行数量不同,权重读取和归约的组织也会随之变化。


5. BF16 M=1 GEMM 的本地 standalone 结果

在相同 DCU、相同 BF16 输入和相同编译扩展下,对比 RPB4 与 RPB2:

权重形状 RPB4 RPB2 加速比 最大差异
14336 x 5120 0.114160 ms 0.106480 ms 1.0721x 0
16384 x 5120 0.131040 ms 0.120960 ms 1.0833x 0
34816 x 5120 0.272480 ms 0.250400 ms 1.0882x 0

测试条件为 warmup 10 次、正式测量 40 次。三种形状的输出都与参考路径逐位一致,重复运行结果稳定。根据权重矩阵字节数除以实测 kernel 时间估算,有效权重带宽约为 1.38-1.42 TB/s;这是该 kernel 的有效带宽估算,不是 DCU 峰值 HBM 带宽的直接测量。

这组数据说明 RPB2 的收益是真实的局部执行收益,但它还没有回答服务层问题。Standalone 只测了目标矩阵乘法,完整 Decode 还包括其他 Linear、状态读取、采样和运行时开销。


6. 从 kernel 到服务:同容器 A/B 才是关键一关

为了判断局部收益能否传递到完整推理,本地在同一容器、同一模型、同一请求集和相同运行条件下进行了 A/B。基线配置和 RPB2 都使用 BF16 原始权重,端点指标如下:

档位 Baseline output tok/s RPB2 output tok/s 吞吐变化 Baseline p99 TPOT RPB2 p99 TPOT
4K-8K 18.8369 19.5961 +4.03% 46.9627 ms 44.9923 ms
8K-16K 14.5837 15.0306 +3.06% 47.5920 ms 45.5662 ms
16K-32K 11.7495 12.0286 +2.38% 49.8507 ms 47.8545 ms

端点结果与 standalone 的关系很典型:局部 kernel 是 1.07x-1.09x,服务层不是同样的 7%-9% 变化,而是 2.38%-4.03%。原因是 RPB2 只影响一部分重复执行的 Linear 工作,其余时间仍由其他算子和运行时组成。

正确性和稳定性检查如下:

4K-8K:   2644 vs 2644 output tokens,10/10 文本 byte-identical
8K-16K:  1768 vs 1768 output tokens,10/10 文本 byte-identical
16K-32K: 1877 vs 1877 output tokens,10/10 文本 byte-identical
failed:  0

TTFT 基本不变:

档位 Baseline p99 TTFT RPB2 p99 TTFT
4K-8K 2049.317 ms 2047.251 ms
8K-16K 4620.050 ms 4614.045 ms
16K-32K 7044.241 ms 7037.622 ms

这符合优化位置的预期:RPB2 主要作用在 Decode 的 M=1 Linear,首 token 的 Prefill 路径没有发生明显变化,因此 TTFT 基本保持不变,而 TPOT 得到改善。


7. 为什么 1.08x kernel 不会自动变成 1.08x 服务

可以用一个粗略的 Amdahl 模型理解这个现象。假设目标 kernel 占总时间的比例为 f,局部加速为 s,理想情况下整体加速上限近似为:

service speedup ≈ 1 / ((1 - f) + f / s)

当 s = 1.08 时,如果目标 kernel 只覆盖总时间的一部分,整体收益必然小于 8%。实际服务还会受到更多因素影响:

  • 不同层的矩阵形状不完全相同;
  • 某些调用仍然走原有实现;
  • kernel launch、graph replay 和状态访问无法被目标 kernel 消除;
  • Prefill 的大 GEMM 与 Decode 的小 GEMM 位于不同时间阶段;
  • 测量中还存在请求长度、缓存和系统调度带来的波动。

所以端到端 A/B 的正确问题不是“这个 kernel 快了几倍”,而是:

它覆盖了多少真实调用?
这些调用占总时间多少?
它是否改变了其他路径的行为?

RPB2 的本地结果之所以有意义,正是因为它同时满足了三点:目标调用在 Decode 中出现很多次、局部收益在多个真实形状上重复出现、输出和请求行为保持一致。


8. 一个反例:Standalone 有收益,端点几乎不变

同一批本地实验还提供了一个很有代表性的反例。对五类大 GEMM 的 145 个 shape、M 组合进行筛选后,有 106 个形状同时满足:

BF16 输出 hash 与默认路径一致
Standalone CUDAGraph speedup >= 1.01x

单独看这些形状,它们都像是值得接入的优化。接入完整服务后,固定中档请求集的结果却只有:

output throughput: 16.583027 -> 16.586315 tok/s
increment:         +0.0198%
texts:             10/10 exact
tokens:            1809 -> 1809
failed:            0 -> 0

另一个保持精确输出的 gate/up GEMM 方案,在稳态同容器 A/B 中的平均收益也只有 +0.3250%。这并不说明 standalone 测量错误,而是说明这些调用在完整请求中的时间占比、调用覆盖率或可重叠程度不足以形成明显的服务收益。

这个反例非常重要:

Standalone speedup 是局部上限,端点 A/B 才是产品层收益。

如果只根据 microbench 选择方案,很容易把几十个小的正收益叠加成一个看起来很大的预测,但实际运行时它们可能互相覆盖、被其他时间项淹没,或者只命中很少的真实请求。


9. 正确性和稳定性应该怎样验

性能优化不能只记录一个耗时数字。对 DCU 上的 BF16 kernel,至少要同时检查以下内容。

9.1 Kernel 层

  • 输出是否为 finite;
  • 输出 shape 和 stride 是否正确;
  • 与参考路径的最大绝对差异或 hash;
  • 重复运行是否稳定;
  • 不同 batch、不同 M 和边界形状是否仍然正确。

9.2 服务层

  • 每个请求是否完成;
  • 输出 token 数是否一致;
  • 生成文本是否逐字节一致,或是否符合预设精度标准;
  • mean/p99 TPOT 是否改善;
  • TTFT 是否出现回归;
  • 是否有失败请求、超时或异常重试。

9.3 测量层

  • 两种配置是否使用同一模型与同一扩展;
  • 是否在同一容器、同一请求集下比较;
  • 是否把编译、首次加载和缓存建立时间单独处理;
  • 是否采用足够的 warmup;
  • 是否重复运行确认方向,而不是只相信一次结果。

尤其要避免把“代码路径存在”当成“kernel 已命中”。最可靠的做法是在运行时记录真实 dispatch、输入形状和选中的实现,再将 marker 与性能结果一一对应。


10. 一套可复用的 DCU 推理优化流程

第一步:先区分 Prefill 和 Decode

分别测 TTFT、TPOT 和阶段耗时。不要用 Decode 的优化方法解释 Prefill,也不要用 Prefill 的大 GEMM 结论代替 Decode 的 M=1 分析。

第二步:用 profile 找时间墙

先确认是大 GEMM、GEMV、Attention、状态读写还是运行时开销。只有知道时间占比,才知道局部加速是否有足够的物理预算。

第三步:还原真实调用形状

记录 M、N、K、dtype、batch、调用次数和输出写回方式。泛化矩阵的 microbench 只能作为参考,不能替代真实形状。

第四步:在 standalone 中筛选

先用固定 warmup 和多次迭代测局部耗时,同时检查正确性和确定性。对明显低于门槛的实现,不要急着接入服务。

第五步:做同容器端点 A/B

保持模型、数据集、并发、采样设置和运行环境一致,分别记录吞吐、TPOT、TTFT、输出长度和失败数。候选必须在多个档位或多个真实形状上重复出现正向信号。

第六步:保留负结果

一个 standalone 正向但端点无收益的方案,同样是有价值的工程结论。它可以帮助后续工作避免重复测试,并让性能预算更接近真实服务,而不是停留在 kernel 表格上。


11. 结论

本地实测数据呈现出一条比较清晰的 DCU 推理性能链路:

  1. Prefill 的主要矛盾可以落在大 GEMM,q=4096 profile 中 aten::mm 占到 67.67%;
  2. Decode 的主要矛盾是大量 Linear/GEMV,Linear 在该 profile 中占 Decode 的 88.33%;
  3. BF16 M=1 kernel 的小幅局部改善,经过重复调用后可以传递到端点,RPB2 在三档同容器 A/B 中带来 2.38%-4.03% 的吞吐提升;
  4. 局部加速并不保证端到端收益,106 个 standalone 正向且 exact 的尾部大 GEMM 形状接入后只有 +0.0198%;
  5. 正确性、dispatch 命中、重复性和测量协议,和 kernel 本身的耗时一样重要。

对海光 DCU 做推理优化,最值得复用的不是某一个固定的 block 参数,而是一套验证顺序:

先用 profile 找到真实时间墙,再用真实形状做 kernel microbench,最后用同容器端点 A/B 验证它是否改善了完整服务。

只有三层证据方向一致,局部的“快”才有资格被称为服务层的“快”。


本文中的性能数字来自本地固定环境的已有测量记录,仅用于说明该模型、软件栈和 DCU 路径下的工程观察。不同 DCU 型号、驱动、DTK、编译缓存、请求分布和系统负载可能得到不同结果;独立 kernel 加速比不应直接外推为通用服务吞吐。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐