DCU使用技术报告(下篇):gfx936 DCU上的Qwen3.5-27B rocBLAS/hipBLASLt调优、vLLM工程化与踩坑实战

系列文章下篇。前两篇分别讲了基线/Profile 和算子优化,这一篇讨论最后一道门槛:怎样把候选接进 vLLM,同时守住正确性、服务稳定性、部署复现和比赛边界。

0. 先说结论

在大模型推理项目里,kernel benchmark PASS可以合入服务 中间隔着很长一段路。

我们最终采用的优化都有几个共同特征:

  • 分派条件只依赖设备、dtype 和张量形状;
  • 不支持的情况完整回退原路径;
  • 先以默认关闭的环境变量接入;
  • 微基准、前五条吞吐、SLA、输出一致性和精度逐级验证;
  • 运行日志能证明候选实际命中;
  • 部署包和 site-packages 可以逐文件核对。

相反,局部再快,只要出现请求失败、长桶回退、输出异常、backend 没恢复,或者收益不到测量噪声,我们就不会保留。

这套方法看起来比“改完直接跑总分”慢,实际节省了很多时间。因为真正贵的不是写几十行 Triton,而是启动 27B 模型、做三档吞吐、跑精度,再发现候选从一开始就没有正确命中。

1. 我们给项目划的边界

性能优化最容易滑向另一个方向:为了分数改调度、改输出长度、减少请求,甚至根据数据集选择路径。我们在项目里明确不碰下面这些东西:

  • 模型权重和模型结构;
  • 有效计算和 Attention 数学;
  • 采样参数与最大输出 Token;
  • max_num_seqsmax_num_batched_tokens 等官方调度设置;
  • 数据集、请求顺序和请求覆盖;
  • 根据 prompt 内容或数据集名称做分派。

允许使用的专用信息只有:

GPU 架构
dtype
张量 shape/stride/contiguous
q_len 与当前 kv_len
是否存在 bias
既有 pipeline 状态

例如长 KV Q20 的分派只看 q_len>=3072max_kv_len>8192 和 stage 2,不知道请求来自 8K-16K 还是 16K-32K 数据集。

2. 默认关闭、严格保护、原路回退

一个候选第一次接入服务时,我们会给它单独的环境变量,默认值设为关闭。例如当前仍在验证的 GDN QKVZ hipBLASLt 候选:

VLLM_CSCC_QWEN35_GFX936_GDN_QKVZ_HIPBLASLT=0

它的 shape guard 等价于:

def is_target_shape(n, m, k, dtype, bias):
    return (
        n == 1
        and m == 16384
        and k == 5120
        and dtype == torch.bfloat16
        and bias is None
    )

实际分派还要同时满足:

flag enabled
device == gfx936
input contiguous
weight contiguous

任何条件不满足都进入原来的 ROCm GEMM 分派。这样做有两个好处:一是候选不会污染其他模型和 Prefill 形状;二是出现问题时只需要关闭一个环境变量,不必重新换整套代码。

候选通过完整验证后,是否改成默认开启要单独决策。Gate/Up Triton、Q10 和 Q20 是在吞吐与精度门禁通过后才进入默认路径;正在验证的 hipBLASLt 候选仍然默认关闭。

3. 为什么我们没有全局切换 hipBLASLt

Profile 显示通用 GEMM 占比超过一半,很容易得出“把 backend 全切到 hipBLASLt”的结论。我们先用三类 Decode 形状做了离线对比:

形状 当前路径 hipBLASLt 相对速度
Decode Down 5120x1x17408 150.944 us 401.968 us 0.3755x
Shared Output 5120x1x6144 53.030 us 141.597 us 0.3745x
GDN QKVZ 16384x1x5120 239.817 us 209.085 us 1.1470x

结论非常清楚:hipBLASLt 不是“整体更快”,只是当前库在 GDN QKVZ 这个固定形状上更合适。若全局切换,两个高频路径会慢到原来的约 2.7 倍。

于是我们只为 16384x1x5120 做 scoped dispatch。更接近服务调用方式的测试中:

GPU kernel speedup       1.1347x
含 backend 切换 wall time 1.1339x
数值 allclose            PASS
重复执行确定性           PASS
backend 恢复             PASS

按当时 Profile 占比估算,整段 GPU kernel 时间理论提升约 1.0167x。这不算巨大,但大于噪声门槛,值得进入服务验证。

需要强调当前状态:这个候选已经通过离线 scoped gate 和服务启动/图捕获,但尚未完成稳定的前五条 A/B,因此还不能写成最终采用。

4. 全局 backend 是共享状态,必须保证恢复

PyTorch 提供:

torch.backends.cuda.preferred_blas_library(...)

在 HIP 构建里 API 仍位于 torch.backends.cuda 命名空间。我们只在原 backend 为 cublas 时临时切到 cublaslt,完成一次 F.linear 后恢复:

original_backend = torch.backends.cuda.preferred_blas_library()

torch.backends.cuda.preferred_blas_library(backend="cublaslt")
try:
    output = torch.nn.functional.linear(x, weight)
finally:
    torch.backends.cuda.preferred_blas_library(backend="cublas")

finally 不是装饰。如果 F.linear 抛异常而 backend 没恢复,后续所有线性层都可能被悄悄切到 hipBLASLt,前面两个 0.37x 的慢路径也会跟着生效。

我们为此专门加了测试:

  • 精确目标 shape 应命中;
  • N/M/K、dtype、bias 任一变化都应拒绝;
  • 原 backend 不是 cublas 时不进入候选;
  • 正常返回后调用顺序必须是 cublaslt -> cublas
  • 异常路径也必须由 finally 恢复。

服务启动时出现过下面的警告:

torch.backends.cuda.preferred_blas_library is an experimental feature

这条警告本身不是失败。真正的门禁是图捕获能否完成、API 能否启动、正式请求是否报错,以及 backend 是否影响其他形状。

5. 从离线 PASS 到服务 PASS,要过五道门

我们的候选验证顺序是固定的。

5.1 第一关:单算子数值和计时

离线脚本至少检查:

  • 与当前服务路径的误差;
  • 多次执行是否确定;
  • 交替运行候选和对照,避免测试顺序偏差;
  • 多轮中位数,而不是只取一次最好结果;
  • 覆盖服务会出现的 shape family。

Attention 分块候选要求逐位一致。GEMM 若累加顺序不同,可以先用 allclose,但进入服务后必须继续核对输出与精度。

5.2 第二关:服务启动和运行时命中

能 import 不代表能跑,能启动也不代表候选命中。日志必须出现类似:

CSCC Qwen3.5 dedicated prefill attention kernel selected.
CSCC Qwen3.5 gfx936 gate/up decode Triton GEMV selected.
CSCC Qwen3.5 gfx936 GDN QKVZ decode hipBLASLt selected.
Application startup complete.

同时不能有 Traceback、图捕获失败或服务进程退出。

5.3 第三关:单请求冒烟

先跑 8K-16K 第一条,要求:

failed=0
API 仍然存活
输出长度与对照一致
服务日志无新错误

单请求只用于排除崩溃,不用于判断性能。

5.4 第四关:固定前五条吞吐和 SLA

正式筛选始终是前五条,不用三条,也不随机抽样。每个候选写入独立 RESULT_ROOT,比较:

output_throughput
p99_ttft_ms
p99_tpot_ms
failed
output_lens
逐请求生成文本

三档加权公式为:

weighted = throughput_4_8 * 0.20
         + throughput_8_16 * 0.50
         + throughput_16_32 * 0.30

低于 1% 默认按噪声处理;出现失败请求、长桶明显回退或 SLA 恶化则直接拒绝。小幅正收益先复跑,达到稳定门槛才进入完整测试。

5.5 第五关:精度

吞吐通过后才运行精度脚本。Q10 和 Q20 采用前的本地前五条精度门禁结果为:

数据集 结果
HotpotQA 80.00
GovReport 37.01
Retrieval Multi Point(重算) 100.00(5/5)
Keyword Aggregation(重算) 100.00(5/5)

Q20 与 Q10 的四项结果完全一致。这里必须把话说严谨:这是本地 all 5 门禁,不应该冒充完整官方评测成绩。它证明候选没有在当前固定样本上破坏精度,完整吞吐和完整精度仍应在最终提交前执行。

6. 输出吞吐不是单纯的“每 Token 更快”

这次项目里有一个很容易误判的案例。

早期 GDN QKVZ Triton 候选让 TPOT P99 改善约 6.26%,单算子更是达到 1.58x。但候选前五条总输出为 775 Token,对照是 887 Token;最终输出吞吐反而低 0.43%,只有三条文本逐字一致。

输出 Token 数会受生成内容和 EOS 位置影响。一个数值路径稍有变化,可能让模型更早停止,也可能让输出变长。单看 tok/s 有时会被输出长度变化误导,所以我们同时核对:

总生成 Token
每条 output_lens
生成文本
TPOT
TTFT
总 benchmark duration

Gate/Up Triton 的部分文本也并非全部逐位一致,但精度门禁没有下降,且吞吐与 TPOT 收益足够大,因此进入后续验证。不同候选需要根据数值变化和收益大小做风险判断,不能只用一个指标盖章。

7. 部署时最容易出错的不是代码,而是文件组合

我们有一次部署 GDN 候选,只复制了:

envs.py
utils.py
cscc_qwen35_gemv.py

服务加载后才发现安装目录里的 platforms/rocm.py 还是旧版本,没有 on_gfx936。候选甚至还没执行,就在导入分派函数时失败。

另一轮更隐蔽:GDN 文件部署成功,但专用 Prefill Attention 文件没有复制。结果 TPOT 明显改善,TTFT 却退回原始慢路径。如果只看加权吞吐,很容易把“部署不完整”误判成“GDN 候选拖慢 Prefill”。

后来每个候选包都列出完整依赖,并用下面三步验证:

python3 -m py_compile <全部修改文件>
cmp -s "$SRC/$F" "$SITE/$F" || exit 1
cd /tmp && python3 -c "import vllm; print(vllm.__file__)"

服务日志中的命中标记则是第四步。文件一致、import 正确、运行时命中,这三件事缺一不可。

8. TunableOp 和 rocBLAS 工具为什么会“长时间没反应”

PyTorch TunableOp 的:

max_tuning_duration=5
max_tuning_iterations=10

不等于整个 Python 脚本五秒结束。它通常限制单个算子或单轮候选搜索,而一个脚本可能包含多种 backend、多个 shape、初始化、正确性检查和正式计时。我们曾经等了十多分钟,终端只显示开头几行,看起来像卡死。

rocblas-gemm-tune 也会长时间沉默。五分钟 timeout 只能说明“搜索还没完成”,不能证明方向无效。后来给 Gate/Up Prefill 形状 30 分钟窗口,工具实际约八分半返回 solution 20981。随后复测发现它没有赢默认 solution,这才是放弃的依据。

工具长时间无输出时,我们现在会做三件事:

  1. 看 GPU 是否持续有负载;
  2. 看进程 CPU 时间和状态是否变化;
  3. 给明确 timeout,但把 timeout 解释为“本轮没完成”,不是“候选失败”。

9. hipprof、后台进程和 Web 终端的生存问题

性能报告最后往往会漏掉这些细节,但它们确实占了我们不少时间。

9.1 旧数据库必须处理

同名 profile 输出会报:

File retained_xxx.db already exists

要么换唯一名称,要么确认旧结果已经备份后再删除。不要反复执行同一条命令,错误不会自己消失。

9.2 Profile 导出要优雅停止

session-client --stop 后,hipprof 可能还要整理数据库和导出 CSV。我们曾看到命令停在 hipprof_pid=... 几分钟,最后正常得到:

144M .db
19K  hipkernel.csv
1.8K hiptrace.csv

只要进程仍在做导出,就不要马上 kill -KILL

9.3 WebShell 断线不一定是容器崩了

SCNet E-Shell 偶尔提示实例停止并自动 logout。重新连接后,vLLM、EngineCore 和 96% GPU 显存占用仍然存在,API 也能响应。这类情况更像 WebSocket/PTY 中断。

长服务和 benchmark 后来都改为 nohup,日志和 PID 写到文件。浏览器断开不会让整轮实验报废,也不会因为看不到终端1而贸然杀进程。

9.4 清卡时只杀目标进程

服务、EngineCore、hipprof 和 standalone GEMM 测试不能同时抢一张卡。清理时按明确 PID 或进程组处理,先 TERM 等待释放显存,再用 KILL 处理残留。不要用会扫到无关 Python 进程的宽泛命令。

10. 当前保留栈与候选状态

截至本文整理时,已经通过吞吐和本地精度门禁、保留在项目中的优化包括:

优化 状态
Qwen3.5 专用 Prefill Attention 已保留
长 KV Chunk-aware 双阶段流水 已保留
Q10 Query 复用 已保留
长 KV 完整 chunk Q20 复用 已保留
gfx936 Gate/Up Triton GEMV 已保留
GDN QKVZ scoped hipBLASLt 离线与启动门禁通过,服务 A/B 待完成

保留栈的干净 8K-16K Profile 中:

通用 GEMM          56.57%
Gate/Up GEMV       25.78%
Prefill Attention   7.13%
Gated DeltaNet      3.89%
Decode Attention    2.76%

下一步如果继续优化,优先级应该放在线性层的固定形状选择、合法的算子融合和减少 backend/launch 开销,而不是继续微调已经降到 7% 的 Prefill Attention。

11. 一套可以复用到其他 DCU 项目的清单

如果换成另一张 DCU、另一个 vLLM 版本或另一个模型,我会按下面顺序重新来一遍,而不是复制现成阈值:

  1. 记录设备 gfx、显存、DTK、PyTorch/HIP 和 vLLM 版本;
  2. 确认仓库、安装包和运行 import 路径;
  3. 固化官方调度和 benchmark 参数,建立独立结果目录;
  4. 丢弃编译预热,获取可重复基线;
  5. 只采正式推理窗口的 HIP Profile;
  6. 按 kernel 名、调用次数和累计 GPU 时间映射到源码;
  7. 为固定 shape 写离线微基准,覆盖完整 shape family;
  8. 候选默认关闭,严格 guard,保留原 fallback;
  9. 依次过启动、冒烟、前五条、三档权重、SLA 和精度门禁;
  10. 记录失败方向,避免下一轮又从头试一遍。

12. 最后一点体会

这次做 DCU 推理优化,最容易上瘾的是看一个 kernel 从 0.50 ms 变成 0.33 ms。真正难的却是后面的判断:它一层有多少次调用,是否命中 CUDA Graph,是否改变生成路径,是否只在某个 chunk 上有效,部署到另一个容器后会不会悄悄回退。

我们留下来的并不是一组“神奇参数”,而是一条比较笨但可靠的路线:Profile 找热点,真实形状做微基准,局部候选用 guard 接入,服务结果决定去留,精度最后否决。

DCU、ROCm、Triton 和 vLLM 的版本都会变化,今天的最佳 BLOCK_M 明天可能不再成立。但这条验证路线可以复用。对性能工程来说,这比某一个 solution index 更值钱。


系列导航

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

参考阅读: 海光破壁:从海光专属栈到通用 AI 模型栈的开荒

建议标签: DCUROCmHIPvLLMQwen3.5性能工程大模型部署

Logo

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

更多推荐