我在海光 DCU 上修改 vLLM kernel 时,最浪费时间的一次并不是编译报错。

那次编译正常结束,服务能够启动,接口也能返回结果,性能测试甚至给出了一个看起来可以解释的变化。后来重新核对运行环境,我才发现一个更麻烦的问题:源码、构建产物和服务实际加载的文件并不是同一套状态。此前围绕性能做出的判断,只能全部作废。

这件事改变了我的调试顺序。现在每次改完 ROCm kernel,我不会立刻看吞吐,而是先回答一个更基础的问题:

正在海光 DCU 上执行的,究竟是不是我刚刚修改、编译并准备测试的那份代码?

本文记录的是我在 gfx936 架构海光 DCU 上实际使用过的一套 vLLM 增量编译与运行时验真方法。重点不是介绍某个更快的算子,而是把从 .cu 源码到真实 kernel dispatch 之间容易藏住旧状态的环节逐一拆开。

实验环境

本文涉及的实测环境如下。后文的时间和二进制信息只对这套环境负责,不外推为其他 DCU、DTK 或 vLLM 版本的固定结果。

项目 实测环境
加速卡 海光 DCU,gfx936 架构,设备名显示为 BW
DTK 26.04 系列环境
Python 3.10
PyTorch 2.10.0
构建工具 CMake、Ninja、hipify、ccache 4.5.1
vLLM ROCm 扩展 vllm._rocm_C

在这份 vLLM 源码中,_rocm_C 由三个文件构成:

1
2
3
csrc/rocm/torch_bindings.cpp
csrc/rocm/skinny_gemms.cu
csrc/rocm/attention.cu

skinny_gemms.cuattention.cu 会先经过 hipify,再由 DTK 的编译器面向 gfx936 生成目标代码,最后与算子注册代码一起链接为 _rocm_C.abi3.so

一个 kernel 在运行前至少有六种“身份”

只看源码目录,很容易误以为修改已经生效。实际链路要长得多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
skinny_gemms.cu
|
| hipify
v
skinny_gemms.hip
|
| clang++ --offload-arch=gfx936
v
cmake-build-rocm/_rocm_C.abi3.so
|
| cmake --install / 手工安装
v
Python 环境中的 vllm/_rocm_C.abi3.so
|
| 服务进程加载
v
/proc/<pid>/maps 中的已加载共享库
|
| shape、dtype、layout 等运行时条件
v
本次请求实际选择的 kernel 分支

海光 DCU 上 vLLM kernel 从源码到真实 dispatch 的六层身份链路

这里任何一层没有对齐,都会出现一种很危险的状态:程序能跑,但跑的不是你以为的实现。

因此,“编译成功”最多只能证明第三层生成了文件。它不能证明文件被安装到了当前 Python 环境,更不能证明已经启动的 vLLM 进程重新加载了它,最后也不能证明目标请求真的命中了新 dispatch。

我实际跑通的增量编译

在已有构建目录的前提下,我修改 csrc/rocm/skinny_gemms.cu 后使用的是下面两条命令:

1
2
cmake --build --preset rocm-release --target _rocm_C
cmake --install cmake-build-rocm --component _rocm_C

这套环境里有一个不太显眼的差异:仅放置 CMakeUserPresets.json 时,当前镜像的构建流程没有正确识别;换成实际使用的 CMakePresets.json 后才稳定工作。Preset 中至少要明确以下信息:

1
2
3
4
VLLM_TARGET_DEVICE=rocm
PYTORCH_ROCM_ARCH=gfx936
ROCM_PATH=/opt/dtk
CMAKE_HIP_COMPILER_LAUNCHER=ccache

一次真实的增量构建日志可以压缩为下面几行:

1
2
3
4
5
6
7
8
9
[1/4] ... hipify.py ... skinny_gemms.cu attention.cu
skinny_gemms.cu -> skinny_gemms.hip [ok]
attention.cu -> attention.hip [skipped, already hipified]
Successfully preprocessed all matching files.
Total number of unsupported CUDA function calls: 0
Total number of replaced kernel launches: 19

[2/3] clang++ ... --offload-arch=gfx936 ... skinny_gemms.hip
[3/3] ... -o _rocm_C.abi3.so

这几行比最后一句 build success 更有用。它们同时证明了四件事:

  1. 修改过的 .cu 被重新 hipify;
  2. 未修改的 attention.cu 没有被无意义地重新转换;
  3. 编译命令确实以 gfx936 为目标架构;
  4. 最终发生了共享库重链接。

同一次本地实验还记录了生成扩展的状态:

1
2
3
4
size: 21018624 bytes
sha256: 0e3280734c80bf7d33e101c04eecd9cdee0844feb3447b2141a3326ad6ef7549
torch: 2.10.0
LLMM1_registered: True

这个哈希只用于标识当时那一份二进制,并不代表其他正确构建也应该得到相同哈希。

共享目录会拖慢配置阶段

我还对比过同一套源码在两种位置执行 CMake configure 的时间:

构建位置 实测 configure 时间
共享目录 约 111 秒
容器本地 /root 13.5 秒

共享目录与容器本地目录的 CMake configure 时间对比

两者相差约 8.2 倍。这个结果并不说明 /root 在任何机器上都会快 8.2 倍,它说明的是:当源码树、CMake 探测和大量小文件操作都落在高延迟共享存储上时,配置阶段本身就可能成为迭代瓶颈。

我的做法是把临时源码 overlay 和 cmake-build-rocm 放在容器本地盘,编译完成后只把需要保存的日志、源码补丁和最终 .so 复制回持久化目录。这样既保留证据,也避免每次 configure 都在共享目录上重复做元数据访问。

四种“看起来成功”的失败状态

下面四种情况我都在实际调试中遇到过。它们之所以麻烦,是因为服务不一定报错。

1. import vllm 成功,但 ROCm 扩展并不存在

一次重新安装 wheel 后,Python 包恢复了,import vllm 也正常,但默认安装中没有 _rocm_C.abi3.so。如果只用 import vllm 验证环境,就会把“Python 包存在”误当成“自定义 ROCm 扩展已安装”。

更可靠的检查是直接导入扩展并确认目标算子完成注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python3 - <<'PY'
import importlib.util
import os
import torch

spec = importlib.util.find_spec("vllm._rocm_C")
print("extension:", None if spec is None else spec.origin)
if spec is None:
raise SystemExit("vllm._rocm_C is missing")

import vllm._rocm_C # noqa: F401
op_name = os.environ.get("DCU_VERIFY_OP", "LLMM1")
print("registered:", op_name, hasattr(torch.ops._rocm_C, op_name))
PY

这里的 LLMM1 是我当时验证的算子名。换成其他工程时,应将 DCU_VERIFY_OP 设为自己真正需要测试的注册名。

2. wheel 重装后,只复制了当前修改的一个文件

另一轮实验中,重装 wheel 会把 site-packages 恢复为 wheel 默认状态。我当时只复制了眼前正在修改的 Python 文件,却漏掉了候选实现继承的其他已修改文件。服务仍能启动,benchmark 也能完成,但那不是完整候选,结果没有比较价值。

从那以后,我不再按“这次手改了几个文件”决定同步范围,而是按完整源码差异决定:

1
git diff --name-only <基准版本>...HEAD

所有会影响运行时的差异文件都要安装、计算哈希并进入备份与恢复清单。对于 Python 文件,还应在启动服务前执行 python -m py_compile,避免把传输损坏、编码异常或残缺文件留到几十分钟后的模型加载阶段才发现。

3. 磁盘上的 .so 更新了,旧进程仍在使用原映射

覆盖 _rocm_C.abi3.so 不会让已经运行的 Python 进程自动卸载旧共享库。更换二进制后必须完整重启服务;如果使用了 CUDAGraph,还应重新完成图捕获,不能沿用旧进程中的图状态。

我遇到过一次更隐蔽的情况:前一轮服务退出后,一个 VLLM::EngineCore 进程仍然存活并占用显存。下一轮服务虽然启动了,但环境已经不再干净。

在启动新服务前,我现在至少做两项只读检查:

1
2
ps -eo pid,ppid,cmd | grep -E 'vllm|VLLM::EngineCore' | grep -v grep
ss -ltnp | grep ':8000 '

确认旧服务、EngineCore、benchmark driver 和监听端口都已清理,再通过正常的服务管理方式重新启动。启动后还可以查看目标进程的映射:

1
2
PID=$(pgrep -n -f 'VLLM::EngineCore')
grep '_rocm_C.abi3.so' "/proc/${PID}/maps"

它至少能回答“当前进程从哪个路径加载了扩展”。如果路径不符合预期,后面的性能数据没有继续分析的必要。

4. 文件名一样,二进制来源却不同

有一次清理现场时,我记录到运行环境中的 _rocm_C 哈希前缀是:

1
7aee47576aa8...

而准备作为对照的已知扩展哈希前缀是:

1
f2c5393af460...

两个文件都叫 _rocm_C.abi3.so,目标算子也都能运行,但它们不是同一份二进制。没有哈希记录时,这种差异很容易被文件名掩盖。

我现在会同时记录构建目录和实际安装位置的哈希:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BUILD_SO=cmake-build-rocm/_rocm_C.abi3.so
INSTALLED_SO=$(python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("vllm._rocm_C")
if spec is None:
raise SystemExit(1)
print(spec.origin)
PY
)

sha256sum "$BUILD_SO" "$INSTALLED_SO"
cmp -s "$BUILD_SO" "$INSTALLED_SO" \
&& echo "binary match" \
|| echo "binary mismatch"

仅有哈希还不够:必须证明请求命中了新 dispatch

哈希一致只能证明服务加载了目标扩展,不能证明某个请求选择了新 kernel。vLLM 中的 dispatch 往往同时受 shape、dtype、layout、序列阶段和缓存配置影响。条件少命中一个,程序就可能安静地回退到原路径。

我的做法是在候选分支里保留一个受环境变量控制的诊断 marker,只在真正选择候选 kernel 的那一行打印:

1
2
3
4
5
6
7
if os.getenv("VLLM_DCU_KERNEL_DIAG") == "1":
logger.warning(
"DCU_KERNEL_DIAG selected=%s shape=%s dtype=%s",
selected,
tuple(x.shape),
x.dtype,
)

marker 的关键不是“功能已启用”,而是 selected=True 必须来自实际 dispatch 结果。我的自动化 runner 会从服务日志提取 marker,并把“出现预期 shape、关键参数正确、selected=True”作为 benchmark 有效的前置条件。

这一点对海光 DCU 上的 ROCm kernel 尤其重要。很多实验的 fallback 仍然是可用且性能不错的 vendor 或 Triton 路径,因此“服务没有报错”完全不能证明新 kernel 被执行。

我现在使用的七道验真门

下面这张表是我最终固定下来的检查顺序。前一项没有通过,就不进入下一项。

顺序 要证明什么 最低证据
1 源码状态完整 完整 diff 清单,不只看当前脏文件
2 编译目标正确 日志出现 --offload-arch=gfx936 和目标源文件重编译
3 安装的是新二进制 build .so 与 import 实际路径的 SHA256 一致
4 算子注册成功 直接 import vllm._rocm_C,目标 op 为 True
5 进程状态干净 无残留 EngineCore、旧服务、旧 driver 和端口占用
6 真实请求命中 服务日志出现带真实 shape 的 selected=True marker
7 结果可用于比较 输出、请求数和失败数满足预先约定,再讨论性能

这套流程看起来比“编译后直接跑 benchmark”多了几步,但多数检查只需要几秒。真正耗时的是跳过它们之后,花几个小时分析一组其实来自旧二进制、半套源码或 fallback 路径的数据。

一段只读验真脚本

下面是我把常用检查压缩后的版本。它不修改文件,也不停止进程,适合在服务启动前后各执行一次。算子名可通过环境变量替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/env bash
set -euo pipefail

export DCU_VERIFY_OP="${DCU_VERIFY_OP:-LLMM1}"

python3 - <<'PY'
import importlib.util
import os
import torch
import vllm

print("vllm:", vllm.__file__)
spec = importlib.util.find_spec("vllm._rocm_C")
print("_rocm_C:", None if spec is None else spec.origin)
if spec is None:
raise SystemExit("missing vllm._rocm_C")

import vllm._rocm_C # noqa: F401
name = os.environ["DCU_VERIFY_OP"]
ok = hasattr(torch.ops._rocm_C, name)
print("op:", name, "registered=", ok)
if not ok:
raise SystemExit("target op is not registered")
PY

SO=$(python3 - <<'PY'
import importlib.util
print(importlib.util.find_spec("vllm._rocm_C").origin)
PY
)

ls -l "$SO"
sha256sum "$SO"

echo "--- active vLLM processes ---"
ps -eo pid,ppid,cmd | grep -E 'vllm|VLLM::EngineCore' | grep -v grep || true

如果构建目录中的 .so、Python 实际 import 的 .so、进程映射中的 .so 和 dispatch marker 能形成一条闭合证据链,我才会把后续结果写进实验结论。

这套方法的边界

本文有三点需要明确:

  1. 111 秒 -> 13.5 秒 是特定容器与共享存储条件下的观测,不是海光 DCU 的固定性能指标;
  2. 21018624 字节和文中的 SHA256 属于一次具体构建,只用于说明如何记录二进制身份;
  3. 这套流程证明“测试对象正确”,并不自动证明 kernel 更快或数值正确,性能与正确性仍需单独验证。

不过,对我而言,这一步已经成为海光 DCU kernel 开发中最值得保留的工程习惯。HIPify、DTK 编译器、CMake 构建树、Python 安装目录、长期运行的 vLLM 进程和运行时 dispatch,每一层都可能保留旧状态。把它们逐层验明,远比盯着最后一行 build success 更可靠。

我现在改完 kernel 后,第一反应不再是看数字涨了多少,而是先确认:源码对不对,二进制对不对,进程对不对,这个请求到底有没有走到它。多花的只是几分钟,省下的往往是一整轮错误实验。