双 V100 从源码部署 1Cat-vLLM:CUDA 12.8 + sm_70 踩坑与实测

手上有台双 V100(SXM2 16GB × 2)的机器,想跑 1Cat-vLLM——一个专门针对 SM70 / Volta 优化过的 vLLM 分支。它推荐的环境是 Python 3.12 + CUDA 12.8 + PyTorch 2.10,看起来照着装就行。

结果真正耗掉时间的,全是跟这个项目本身无关的环境问题:glibc 2.41 把 CUDA 12.8 的编译器识别直接干掉了、git 不认 NFS 挂载的属主、这台 NAS 上根本装不了 venv。三个坑都绕过去之后,编译 22 分钟,跑通。

这篇记录三件事:怎么装起来的、卡在哪儿、以及互联和编译的实测数字。

一、环境

项目 值
GPU Tesla V100-SXM2-16GB × 2(compute capability 7.0)
互联 NVLink 2.0,6 条 bonding,单条 25.781 GB/s
驱动 580.x(对外报 CUDA 13.0 能力)
工具链 CUDA 12.8(机器上另有 13.0)
glibc / GCC 2.41 / 14.2
代码目录 挂在 NAS 上(<NAS>),这也是后面两个坑的根源

为什么停在 CUDA 12.8

V100 是 sm_70。驱动 580 能跑 CUDA 13.0,但 CUDA 13.0 移除了 7.5 以下的离线编译,也就是根本生成不出 sm_70 的 cubin。

真正的分界线是 CUDA 12.9——它是最后一个还支持 Volta 的版本。这台机器上只有 12.8 和 13.0,所以停在 12.8。顺带一提,PyTorch 官方 wheel 也是同样的规律:cu128 停在 torch 2.11,cu126 一直更新到现在,cu129 还有 2.12/2.13,但 cu130 就不带 sm_70 了。

驱动版本和运行时的关系容易搞混:驱动 580 报 CUDA 13.0,只是说它最高支持到 13.0,对 12.x 的运行时是向后兼容的。真正会被版本卡住的只有编译这一步。

二、部署

最终收敛成一条命令:

1
tools/rebuild-sm70-v100.sh

它内部做的事情,也就是手动部署的完整步骤:

1
2
3
4
5
6
7
8
9
10
# 1. 建环境(3.12;放在本地盘,原因见坑 3)
uv venv --python 3.12 ~/.venv

# 2. 装构建依赖,走 cu128 wheel
uv pip install -r requirements/build/cuda.txt --torch-backend=cu128

# 3. 源码编译安装(本项目必须源码编译:
# 它的 TurboMind / FlashAttention-V100 / FlashQLA 都是自定义扩展,
# 上游的预编译 wheel 不带这些)
uv pip install -e . --no-build-isolation --torch-backend=cu128

第 3 步要带上这几个环境变量,原因分别对应后面三个坑:

1
2
3
4
5
6
XDG_CONFIG_HOME=<构建shim目录>            # 坑 2
TMPDIR=<本地盘临时目录> # 坑 3
NVCC_PREPEND_FLAGS="-I<shim>/glibc-c23/include" # 坑 1
CUDA_HOME=/usr/local/cuda-12.8
TORCH_CUDA_ARCH_LIST=7.0 # 只编 sm_70
MAX_JOBS=6 # 内存约束,见性能一节

TORCH_CUDA_ARCH_LIST=7.0 很关键:只编一个架构,编译时间和内存都大幅下降,而且这个仓库的 setup.py 本来就用它来判断要不要编某些架构相关的扩展。

注意这个项目没有增量编译。setuptools 每次调用都会新建一个 *.build-temp 目录,所以改完源码重建就是完整重来一遍,没有任何对象文件复用。

三、三个坑

坑 1:glibc 2.41 把 nvcc 卡死在编译器识别

报错在 CMake 检测 CUDA 编译器的时候,连项目文件都还没开始编:

1
2
3
4
/usr/include/x86_64-linux-gnu/bits/mathcalls.h(79): error: exception
specification is incompatible with that of previous function "cospi"
(declared at line 2601 of .../crt/math_functions.h)
extern double cospi (double __x) noexcept (true);

原因是 glibc 2.41 在 C++17 下把 __THROW 定义成了 noexcept (true),于是 cospi / sinpi 这一组 C23 数学函数被声明成不抛异常;而 CUDA 12.8 的 crt/math_functions.h 里把它们声明成了不带异常规格。两份声明冲突。

几个显然的修法都不行:

  • 命令行 -D 覆盖那个开关没用,因为 glibc 的头文件是先 #undef 再 #define,命令行定义会被抹掉。
  • -U_GNU_SOURCE 能解决,但会连带把 libstdc++ 弄坏(cwchar 里 fwide 找不到声明),因为 __USE_GNU 变成 0 了。
  • 官方修复在 CUDA 12.9,这台机器没有。

最后用的办法:做一个只关掉 glibc 那两处 C23 声明的 shadow 头文件,用 NVCC_PREPEND_FLAGS 把它的目录加进 nvcc 的搜索路径。整个改动就是从系统头文件做一次机械替换,只有两行不同,且删掉的只是 host 侧那几个 *pi 函数的声明——CUDA 侧的 device 函数一律不受影响,构建过程中也没有任何代码调用它们。

脚本会先探测后使用:先编一个只有 #include <math.h> 的两行文件,如果编得过就完全不加 shim。所以将来换成 CUDA 12.9,这段逻辑自动失效,不用改。

顺带说:不要把这个 glibc 头文件提交进仓库。脚本改成每次按需生成了,既能跟着系统版本走,也避免把别人的头文件带进版本库。

坑 2:git 不认 NAS 属主,而 setuptools 会吃掉 GIT_*

报错很直白:

1
2
fatal: detected dubious ownership in repository at '<仓库路径>'
git introspection failed

NAS 上所有文件被统一映射成另一个 uid,当前用户不是属主,git 就拒绝操作。这在两个地方炸:先是 setuptools_scm 取不到版本号,然后是 CMake 用 FetchContent 拉 cutlass 和 vllm-flash-attn 时,checkout 同样被拒。

麻烦的是常见的那种环境变量注入法在这里失效。设 GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 看起来是标准做法,但 setuptools 的版本后端在调 git 之前会跑一个 no_git_env(),把所有以 GIT_ 开头的环境变量全部剥掉,只留一个很小的白名单。我直接调构建后端验证过:变量在 Python 里可见,git 那一步还是照样失败。

最后的解法是绕开 GIT_*:用 XDG_CONFIG_HOME 指一个专门的配置目录,里面放一份 safe.directory 配置。这个变量不在剥离名单里,能活到 git 那一步。配置用 directory = *,因为后面 FetchContent 还会克隆到别的路径,逐个列举不现实——但这份配置只在构建时通过环境变量生效,没有动全局的 ~/.gitconfig。

坑 3:这台 NAS 上装不了 venv

这个坑最费时间,因为症状是”卡住”而不是报错。uv 往 NAS 上的 venv 装包时,会卡在一个 NFS RPC 上不动,实测吞吐只有 76 KiB/s,杀掉重试两次都一样。后来发现 uv 是逐文件写临时文件再改名,而故障点其实在元数据延迟上:

测试 结果
顺序写 1 GiB(buffered) 114 MB/s
顺序写 1 GiB(oflag=direct) 23.6 MB/s
串行创建 2000 个小文件 117 s(约 17 个/秒)
并行创建 4000 个小文件(-P 24) 66 s(约 61 个/秒)
删除一个装好的 5.2 GB venv 约 8 分钟

带宽没问题,但每次元数据操作往返约 60 ms。venv 安装、几万个对象文件的编译、卸载,全是海量小文件操作,全都会被这个延迟放大。nfsstat 显示重传为 0,所以是延迟问题不是丢包。

解法是把 venv 放本地盘,仓库里用软链指过去:

1
<仓库>/.venv -> <本地盘>/venvs/1cat-vllm-v100

这样 source .venv/bin/activate 的用法完全不变。顺带一个好处:uv 的缓存和 venv 在同一个文件系统上,会走硬链接而不是复制,所以之后依赖安装都是一秒钟以内完成。

另外把构建的 TMPDIR 也移到了本地盘——默认的 /tmp 是 16 GiB 的 tmpfs,是吃内存的,整个编译过程的对象文件都堆在上面并不合适。

坑 4(教训):构建会覆盖正在运行的 .so

这条不算坑,算事故预演。验证阶段我在同一台机器上跑了一次重建,而另一个任务正用这个环境起着服务——构建最后一步会就地覆盖 vllm/*.so,而那个进程正把这些文件 mmap 着。就地覆盖一个已映射的文件,可以直接把对方干掉。

发现后立刻把构建整组 kill 掉,.so 时间戳没变、服务没受影响。之后在脚本里加了保护:只要检测到有别的进程正在使用这个环境,就拒绝构建并打印出 PID,要强行编译必须显式设 ONECAT_FORCE=1。

1
2
87221 <venv>/bin/python -c import time; time.sleep(180)
rebuild-sm70-v100: the above process(es) are running from <venv>; stop them first, or set ONECAT_FORCE=1 to build anyway

四、性能

互联(实测,硬件层面)

TP2 在 V100 上能不能跑得动,瓶颈基本全在 NVLink 上,所以先把这块测了:

测试 结果
拓扑 NV6,6 条 bonding,无 replay / CRC 错误
单条 link 速率 25.781 GB/s
单向理论峰值 6 × 25.781 ≈ 154.7 GB/s
P2P copy(256 MB) 143.8 / 144.0 GB/s(双向对称)
利用率 约 93%
NCCL all-reduce(256 MB) 103.6 GB/s busbw,12 条 channel,NVL/PIX

P2P 拷贝打到理论峰值的 93%,双向对称、错误计数全零,这是很健康的状态。all-reduce 的 103.6 GB/s 明显低于单向拷贝带宽——这在 V100 上属于正常范围,ring 算法下还有优化空间(调 NCCL_MAX_NCHANNELS,或者用仓库自带的自定义 all-reduce)。

编译成本(实测)

项目 值
目标架构 只编 sm_70
并发 MAX_JOBS=6
耗时 22 分 17 秒(手动)/ 30 分 08 秒(跑脚本)
产物 11 个扩展库,约 477 MB
nvcc 单进程峰值内存 约 2 GB;6 并发约 13 GB

两次耗时的差异是机器负载,不是配置差异。MAX_JOBS=6 是被内存逼出来的:这台机器 30 GiB 内存,nvcc 单进程峰值能到 2 GB 左右,并发调高有 OOM 风险,峰值内存比核心数更值得关注。

依赖部分有 190 个包,冷缓存时下载才是大头,第一次约 40 分钟下了 12 GB 进 uv 缓存。

容量(观察)

用 Qwen3.8-27B-INT4 起了一个 TP2 服务,两张 16 GB 卡各占约 14.4 GB(权重 + KV cache)——在 V100 这种小显存卡上,TP2 是能扛住 27B 量级 INT4 的。

没测的

这篇里没有端到端吞吐(tok/s)的数据。测的时候机器上一直有别人在跑推理任务,不想去抢卡影响对方的延迟测量。等空下来补上,包括首 token 延迟和不同并发下的 decode 速率。

五、小结

回头看,真正花时间的三件事没有一件跟这个 vLLM 分支本身有关:

  • glibc 2.41 × CUDA 12.8——需要用 shadow 头文件绕,且命令行覆盖无效;
  • NFS 属主 × git——GIT_* 会被 setuptools 剥掉,得改用 XDG_CONFIG_HOME;
  • NFS × venv——60 ms 的元数据延迟足以让安装永久卡死,venv 必须放本地盘。

踩完之后整个部署就收敛成一条脚本,而且会自己判断需不需要打补丁、在这个环境被占用时拒绝构建。写在最后的一条经验:在有共享存储的机器上部署之前,先量一下小文件元数据延迟,它比顺序带宽更能决定你会不会卡住。