我在海光 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` 由三个文件构成:
```text
csrc/rocm/torch_bindings.cpp
csrc/rocm/skinny_gemms.cu
csrc/rocm/attention.cu
```
`skinny_gemms.cu` 和 `attention.cu` 会先经过 hipify,再由 DTK 的编译器面向 `gfx936` 生成目标代码,最后与算子注册代码一起链接为 `_rocm_C.abi3.so`。
## 一个 kernel 在运行前至少有六种“身份”
只看源码目录,很容易误以为修改已经生效。实际链路要长得多:
```text
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/
/maps 中的已加载共享库
|
| shape、dtype、layout 等运行时条件
v
本次请求实际选择的 kernel 分支
```

这里任何一层没有对齐,都会出现一种很危险的状态:程序能跑,但跑的不是你以为的实现。
因此,“编译成功”最多只能证明第三层生成了文件。它不能证明文件被安装到了当前 Python 环境,更不能证明已经启动的 vLLM 进程重新加载了它,最后也不能证明目标请求真的命中了新 dispatch。
## 我实际跑通的增量编译
在已有构建目录的前提下,我修改 `csrc/rocm/skinny_gemms.cu` 后使用的是下面两条命令:
```bash
cmake --build --preset rocm-release --target _rocm_C
cmake --install cmake-build-rocm --component _rocm_C
```
这套环境里有一个不太显眼的差异:仅放置 `CMakeUserPresets.json` 时,当前镜像的构建流程没有正确识别;换成实际使用的 `CMakePresets.json` 后才稳定工作。Preset 中至少要明确以下信息:
```text
VLLM_TARGET_DEVICE=rocm
PYTORCH_ROCM_ARCH=gfx936
ROCM_PATH=/opt/dtk
CMAKE_HIP_COMPILER_LAUNCHER=ccache
```
一次真实的增量构建日志可以压缩为下面几行:
```text
[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. 最终发生了共享库重链接。
同一次本地实验还记录了生成扩展的状态:
```text
size: 21018624 bytes
sha256: 0e3280734c80bf7d33e101c04eecd9cdee0844feb3447b2141a3326ad6ef7549
torch: 2.10.0
LLMM1_registered: True
```
这个哈希只用于标识当时那一份二进制,并不代表其他正确构建也应该得到相同哈希。
### 共享目录会拖慢配置阶段
我还对比过同一套源码在两种位置执行 CMake configure 的时间:
| 构建位置 | 实测 configure 时间 |
| ---------------- | ------------------: |
| 共享目录 | 约 111 秒 |
| 容器本地 `/root` | 13.5 秒 |

两者相差约 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 扩展已安装”。
更可靠的检查是直接导入扩展并确认目标算子完成注册:
```bash
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 也能完成,但那不是完整候选,结果没有比较价值。
从那以后,我不再按“这次手改了几个文件”决定同步范围,而是按完整源码差异决定:
```bash
git diff --name-only <基准版本>...HEAD
```
所有会影响运行时的差异文件都要安装、计算哈希并进入备份与恢复清单。对于 Python 文件,还应在启动服务前执行 `python -m py_compile`,避免把传输损坏、编码异常或残缺文件留到几十分钟后的模型加载阶段才发现。
### 3. 磁盘上的 `.so` 更新了,旧进程仍在使用原映射
覆盖 `_rocm_C.abi3.so` 不会让已经运行的 Python 进程自动卸载旧共享库。更换二进制后必须完整重启服务;如果使用了 CUDAGraph,还应重新完成图捕获,不能沿用旧进程中的图状态。
我遇到过一次更隐蔽的情况:前一轮服务退出后,一个 `VLLM::EngineCore` 进程仍然存活并占用显存。下一轮服务虽然启动了,但环境已经不再干净。
在启动新服务前,我现在至少做两项只读检查:
```bash
ps -eo pid,ppid,cmd | grep -E 'vllm|VLLM::EngineCore' | grep -v grep
ss -ltnp | grep ':8000 '
```
确认旧服务、EngineCore、benchmark driver 和监听端口都已清理,再通过正常的服务管理方式重新启动。启动后还可以查看目标进程的映射:
```bash
PID=$(pgrep -n -f 'VLLM::EngineCore')
grep '_rocm_C.abi3.so' "/proc/${PID}/maps"
```
它至少能回答“当前进程从哪个路径加载了扩展”。如果路径不符合预期,后面的性能数据没有继续分析的必要。
### 4. 文件名一样,二进制来源却不同
有一次清理现场时,我记录到运行环境中的 `_rocm_C` 哈希前缀是:
```text
7aee47576aa8...
```
而准备作为对照的已知扩展哈希前缀是:
```text
f2c5393af460...
```
两个文件都叫 `_rocm_C.abi3.so`,目标算子也都能运行,但它们不是同一份二进制。没有哈希记录时,这种差异很容易被文件名掩盖。
我现在会同时记录构建目录和实际安装位置的哈希:
```bash
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 的那一行打印:
```python
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 路径的数据。
## 一段只读验真脚本
下面是我把常用检查压缩后的版本。它不修改文件,也不停止进程,适合在服务启动前后各执行一次。算子名可通过环境变量替换。
```bash
#!/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 后,第一反应不再是看数字涨了多少,而是先确认:源码对不对,二进制对不对,进程对不对,这个请求到底有没有走到它。多花的只是几分钟,省下的往往是一整轮错误实验。