编译成功不等于生效:海光 DCU vLLM 增量编译验真
我在海光 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 | csrc/rocm/torch_bindings.cpp |
skinny_gemms.cu 和 attention.cu 会先经过 hipify,再由 DTK 的编译器面向 gfx936 生成目标代码,最后与算子注册代码一起链接为 _rocm_C.abi3.so。
一个 kernel 在运行前至少有六种“身份”
只看源码目录,很容易误以为修改已经生效。实际链路要长得多:
1 | skinny_gemms.cu |

这里任何一层没有对齐,都会出现一种很危险的状态:程序能跑,但跑的不是你以为的实现。
因此,“编译成功”最多只能证明第三层生成了文件。它不能证明文件被安装到了当前 Python 环境,更不能证明已经启动的 vLLM 进程重新加载了它,最后也不能证明目标请求真的命中了新 dispatch。
我实际跑通的增量编译
在已有构建目录的前提下,我修改 csrc/rocm/skinny_gemms.cu 后使用的是下面两条命令:
1 | cmake --build --preset rocm-release --target _rocm_C |
这套环境里有一个不太显眼的差异:仅放置 CMakeUserPresets.json 时,当前镜像的构建流程没有正确识别;换成实际使用的 CMakePresets.json 后才稳定工作。Preset 中至少要明确以下信息:
1 | VLLM_TARGET_DEVICE=rocm |
一次真实的增量构建日志可以压缩为下面几行:
1 | [1/4] ... hipify.py ... skinny_gemms.cu attention.cu |
这几行比最后一句 build success 更有用。它们同时证明了四件事:
- 修改过的
.cu被重新 hipify; - 未修改的
attention.cu没有被无意义地重新转换; - 编译命令确实以
gfx936为目标架构; - 最终发生了共享库重链接。
同一次本地实验还记录了生成扩展的状态:
1 | size: 21018624 bytes |
这个哈希只用于标识当时那一份二进制,并不代表其他正确构建也应该得到相同哈希。
共享目录会拖慢配置阶段
我还对比过同一套源码在两种位置执行 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 扩展已安装”。
更可靠的检查是直接导入扩展并确认目标算子完成注册:
1 | python3 - <<'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 | ps -eo pid,ppid,cmd | grep -E 'vllm|VLLM::EngineCore' | grep -v grep |
确认旧服务、EngineCore、benchmark driver 和监听端口都已清理,再通过正常的服务管理方式重新启动。启动后还可以查看目标进程的映射:
1 | PID=$(pgrep -n -f 'VLLM::EngineCore') |
它至少能回答“当前进程从哪个路径加载了扩展”。如果路径不符合预期,后面的性能数据没有继续分析的必要。
4. 文件名一样,二进制来源却不同
有一次清理现场时,我记录到运行环境中的 _rocm_C 哈希前缀是:
1 | 7aee47576aa8... |
而准备作为对照的已知扩展哈希前缀是:
1 | f2c5393af460... |
两个文件都叫 _rocm_C.abi3.so,目标算子也都能运行,但它们不是同一份二进制。没有哈希记录时,这种差异很容易被文件名掩盖。
我现在会同时记录构建目录和实际安装位置的哈希:
1 | BUILD_SO=cmake-build-rocm/_rocm_C.abi3.so |
仅有哈希还不够:必须证明请求命中了新 dispatch
哈希一致只能证明服务加载了目标扩展,不能证明某个请求选择了新 kernel。vLLM 中的 dispatch 往往同时受 shape、dtype、layout、序列阶段和缓存配置影响。条件少命中一个,程序就可能安静地回退到原路径。
我的做法是在候选分支里保留一个受环境变量控制的诊断 marker,只在真正选择候选 kernel 的那一行打印:
1 | if os.getenv("VLLM_DCU_KERNEL_DIAG") == "1": |
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 |
|
如果构建目录中的 .so、Python 实际 import 的 .so、进程映射中的 .so 和 dispatch marker 能形成一条闭合证据链,我才会把后续结果写进实验结论。
这套方法的边界
本文有三点需要明确:
111 秒 -> 13.5 秒是特定容器与共享存储条件下的观测,不是海光 DCU 的固定性能指标;21018624字节和文中的 SHA256 属于一次具体构建,只用于说明如何记录二进制身份;- 这套流程证明“测试对象正确”,并不自动证明 kernel 更快或数值正确,性能与正确性仍需单独验证。
不过,对我而言,这一步已经成为海光 DCU kernel 开发中最值得保留的工程习惯。HIPify、DTK 编译器、CMake 构建树、Python 安装目录、长期运行的 vLLM 进程和运行时 dispatch,每一层都可能保留旧状态。把它们逐层验明,远比盯着最后一行 build success 更可靠。
我现在改完 kernel 后,第一反应不再是看数字涨了多少,而是先确认:源码对不对,二进制对不对,进程对不对,这个请求到底有没有走到它。多花的只是几分钟,省下的往往是一整轮错误实验。


