海光 DCU 上 Qwen3.5 推理性能工程:从 Prefill/Decode 热点到端到端收益
海光 DCU 上 Qwen3.5 推理性能工程:从 Prefill/Decode 热点到端到端收益
摘要
在大模型推理优化中,最容易被误读的一件事,是把某个 kernel 的局部加速比直接当成服务吞吐的提升。一个 kernel 即使在独立测试中快了 1.08 倍,完整服务也可能只快几个百分点,甚至几乎没有变化。
本文以海光 DCU gfx936 路径上的 Qwen3.5-27B BF16 推理为例,使用本地已经完成的 profile、kernel microbench 和同容器端点 A/B 数据,回答三个问题:
- Prefill 和 Decode 分别被什么算子限制;
- 为什么 BF16 的 M=1 GEMM/GEMV 会成为 Decode 的主要性能墙;
- 一个局部优化要经过哪些验证,才能判断它确实改善了完整推理服务。
本文只讨论 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、输出文本、失败数 |
测量分成三层:
- Profile:回答时间消耗在哪里;
- Standalone/CUDAGraph microbench:回答目标 kernel 的局部上限;
- 端点 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 推理性能链路:
- Prefill 的主要矛盾可以落在大 GEMM,q=4096 profile 中 aten::mm 占到 67.67%;
- Decode 的主要矛盾是大量 Linear/GEMV,Linear 在该 profile 中占 Decode 的 88.33%;
- BF16 M=1 kernel 的小幅局部改善,经过重复调用后可以传递到端点,RPB2 在三档同容器 A/B 中带来 2.38%-4.03% 的吞吐提升;
- 局部加速并不保证端到端收益,106 个 standalone 正向且 exact 的尾部大 GEMM 形状接入后只有 +0.0198%;
- 正确性、dispatch 命中、重复性和测量协议,和 kernel 本身的耗时一样重要。
对海光 DCU 做推理优化,最值得复用的不是某一个固定的 block 参数,而是一套验证顺序:
先用 profile 找到真实时间墙,再用真实形状做 kernel microbench,最后用同容器端点 A/B 验证它是否改善了完整服务。
只有三层证据方向一致,局部的“快”才有资格被称为服务层的“快”。
本文中的性能数字来自本地固定环境的已有测量记录,仅用于说明该模型、软件栈和 DCU 路径下的工程观察。不同 DCU 型号、驱动、DTK、编译缓存、请求分布和系统负载可能得到不同结果;独立 kernel 加速比不应直接外推为通用服务吞吐。
更多推荐


所有评论(0)