手上有台双 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 | # 1. 建环境(3.12;放在本地盘,原因见坑 3) |
第 3 步要带上这几个环境变量,原因分别对应后面三个坑:
1 | XDG_CONFIG_HOME=<构建shim目录> # 坑 2 |
TORCH_CUDA_ARCH_LIST=7.0 很关键:只编一个架构,编译时间和内存都大幅下降,而且这个仓库的 setup.py 本来就用它来判断要不要编某些架构相关的扩展。
注意这个项目没有增量编译。setuptools 每次调用都会新建一个 *.build-temp 目录,所以改完源码重建就是完整重来一遍,没有任何对象文件复用。
三、三个坑
坑 1:glibc 2.41 把 nvcc 卡死在编译器识别
报错在 CMake 检测 CUDA 编译器的时候,连项目文件都还没开始编:
1 | /usr/include/x86_64-linux-gnu/bits/mathcalls.h(79): error: exception |
原因是 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 | fatal: detected dubious ownership in repository at '<仓库路径>' |
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 | 87221 <venv>/bin/python -c import time; time.sleep(180) |
四、性能
互联(实测,硬件层面)
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 必须放本地盘。
踩完之后整个部署就收敛成一条脚本,而且会自己判断需不需要打补丁、在这个环境被占用时拒绝构建。写在最后的一条经验:在有共享存储的机器上部署之前,先量一下小文件元数据延迟,它比顺序带宽更能决定你会不会卡住。