Skip to main content

01 - 在租用 GPU 上执行实测

前置:无。

本篇回答:手上没有 N 卡,想租一台机器跑一轮性能实测,机器怎么挑、活怎么派过去、哪些地方会「看起来没问题,其实已经错了」。

你想给一个模型做一轮性能测试,看看它到底慢在哪。手上是台 Mac,没有 N 卡。于是去租一台带 GPU 的机器 —— 按小时算钱,几毛钱一小时,跑完就销毁。

听起来很简单:开机、把代码传上去、跑、把结果拷回来。但从「点下租用」到「拿到一份能信的数字」中间有六步,其中四步各埋着一个坑,而且都不报错:

上面一排是花钱的机时,下面四个虚线挂着的是白花的机时。四个坑分别在第 1.1、3.1、3.2、2.4 节。

下面按这条线走一遍。

一、选机器

1.1 先看依赖,再看价格

机器列表默认按价格排序,很容易顺手挑最便宜那台。但价格该排最后看。

卡在前面的是 CUDA 版本。深度学习的 Python 包(PyTorch、flash-attn 这些)装的不是源码,是已经编译好的二进制。它编译时针对哪个 CUDA 大版本,那一刻就定死了。机器上的 CUDA 版本对不上,import 那一行就直接报错 —— 不是「慢一点」,是根本起不来。

顺带说一个常见误解:CUDA 向后兼容、驱动新一点总能跑。这话对驱动成立,对这些编译好的包不成立。

所以第一步是打开项目的依赖清单,找包名里的 cu 后缀。以 sglang-omni 为例:

# 包名后缀直接写着目标 CUDA 主版本,这类命名本身就是「不兼容」的信号:
# 如果真的跨版本兼容,就不需要为每个 CUDA 版本单独发一个包
"flashinfer_python[cu13]==0.6.17"
"nixl-cu13>=1.1.0" # 注释里写明:通用的 nixl / -cu12 wheel 在 cu130 上会挂
"mooncake-transfer-engine-cuda13>=0.3.10" # 泛化版本会拉 cu12,导致 import mooncake 失败

三条全钉死在 CUDA 13,而不少平台的默认镜像还停在 12.x。这一条看走眼,要在装依赖上耗掉几小时机时才发现。

两条范围的长度差,就是「驱动兼容」和「wheel 兼容」的全部区别这台机器 13.2另一台 12.4驱动的向后兼容范围很宽 —— 新驱动能跑老 CUDA 编译出来的东西,这半句是对的cu13 的 wheel 只认这一段很窄 —— wheel 里装的是编译好的二进制,编译时就把大版本钉死了CUDA 11CUDA 12CUDA 13
12.4 那条竖线落在上面那条里、却落在下面那条外 —— 驱动这关过了,wheel 这关没过,于是 import 直接崩。这就是「驱动新一点总能跑」这句话失效的地方。

1.2 能不能直接用项目给的镜像

「装环境」是整件事里最耗时、最容易失败的一段 —— CUDA、flash-attn、各种通信库,手工装一遍能装半天。

所以如果项目提供了官方 Docker 镜像,能不能直接用上它,比每小时几分钱的差价重要得多。按这个能力,租赁平台分三类:

看的是「你从这里开始」这一格落在栈的第几层 —— 越靠下,你能动的越多。第三类你已经在最上面那层了,下面两层都碰不到,所以官方镜像用不上。

第三类是很多按量计费平台的形态。便宜,但环境得你自己从头搭一遍。

1.3 CPU 核数别省

租 GPU 的时候一般没人看 CPU 配置。但性能排查要回答的恰恰是「到底是 GPU 算得慢,还是 CPU 这边喂数据喂得慢」。

如果租到一台 CPU 核数很少的机器,很可能测出「瓶颈在 CPU」的结论 —— 而那是这台机器核少,不是你的代码有问题。换台机器结论就变了,整轮实测白做。

多花几分钱选核多的。这条只在做性能实测时要考虑,平时跑训练或者部署服务不用管。

1.4 一张机器卡片该看哪几栏

上面三条听着都对,但真打开机器列表 —— 一屏几十台、每台十几个参数,到底看哪几栏并不显然。

下面是实际租下来那台的卡片截图:

租赁平台的实例卡片:1x RTX 4090,Max CUDA 13.2,32/128 CPU,VRAM 24 GB,磁盘 42/120 GB,下行 1446 Mbps,0.407 美元每小时
顶部两条橙色告警跟机器本身无关,是余额提醒 —— 按量计费平台余额归零会直接停机并可能删实例,长任务开跑前先确认余额够覆盖整轮。

一屏十几个参数,真正参与决策的只有五栏:

卡片上的栏位这台的值为什么看它
Max CUDA13.2一票否决。依赖钉死在 CUDA 13,低于这个数直接换机器(1.1)
CPU32 / 128 核核太少会测出「瓶颈在 CPU」,那是机器的锅不是代码的(1.3)
VRAM24 GB模型加上运行时的缓存得放得下,放不下就不是「慢」而是根本跑不起来
Disk42 / 120 GB存储费按天收,申请多少收多少,这次实际只用了 41 G(第四节)
Network1446 Mbps ↓38 G 的模型和数据集要现下,带宽决定这段是 3.5 分钟还是半小时

剩下那些(主板型号、PCIe 代次、端口数、好评率)只在同价位机器之间二选一时才有用。

$0.407/hr 乘 2.2 GPU 小时不到一美元;而 CUDA 版本挑错一次,重开一台加重装依赖就是两三个小时。先按依赖清单筛,筛完剩下的再比价格。

二、把活派到机器上

2.1 平台的命令行只送你到门口

机器开好了,接下来要把代码传上去、跑起来、看进度。这时会发现平台给的命令行工具好像少了点什么:有 createstopdestroy,有查状态、传公钥,就是没有一条能让你进到机器里

这不是平台偷懒,是两条路本来就分开:

两条箭头的区别只在终点停在哪:平台命令行的箭头停在机器的外壳上,管开关;SSH 的箭头穿进机器里面,管干活。前者叫控制面,后者叫数据面 —— 名字记不住无所谓,记住平台命令行送你到门口为止就行。aws 的命令行同样开不了 EC2 的 shell。

平台的命令行管的是机器的开关,SSH 管的是机器里的活。前者叫控制面,后者叫数据面 —— 名字记不住无所谓,记住是两条路就行。aws 的命令行同样开不了 EC2 的 shell,gcloud compute ssh 说到底是帮你把参数拼好、再去调用系统自带的 ssh

有些平台提供 execute 实例ID 命令 这样的一次性接口,看着像能替代 SSH。它适合开机后跑一句 nvidia-smi 确认显卡在,但顶不了实测的活:命令一结束进程就没了,服务常驻不了,任务跑到一半也看不到进度。

2.2 SSH 连不上时,先分清卡在哪一步

「服务器上有我的公钥就能登录」—— 这个理解漏了一半。公钥登录其实分四步,前两步在服务器那边,后两步在你这边:

前两步在服务器那边,后两步在你这边。日志里那句 Server accepts key 只代表第 2 步过了,跟最后能不能登进去是两回事。

于是会出现一个很迷惑的组合:

debug1: Server accepts key: ...        ← 服务器认这把钥匙
Permission denied (publickey). ← 但还是把你拒了

看到 Server accepts key 却还是被拒,说明卡在最后一步 —— 问题在你本地的私钥,不在服务器上。 这时候往服务端反复贴公钥是白费力气。

最常见的原因是私钥设了密码(passphrase)。签名得先把私钥解开,解开要输密码,而自动化的环境弹不出输入框,于是签不出来。两条解法:

# 解法一:把私钥交给常驻的 ssh-agent 保管,只需输一次密码
# 之后所有 SSH 调用都向它要签名,不用再输
# macOS 上加 --apple-use-keychain 可存进钥匙串,之后开机自动加载
ssh-add ~/.ssh/id_ed25519
# 解法二:给临时机器单独生成一把没有密码的钥匙
# -f 指定新文件名,不覆盖已有的;-N "" 就是「空密码」
ssh-keygen -t ed25519 -f ~/.ssh/id_vast -N "" -C "rented-gpu"

生成完还要在配置里写死用哪把,否则客户端会挨个试一遍:

cat >> ~/.ssh/config <<'EOF'
Host gpu
HostName ssh8.example.com
Port 21078
User root
IdentityFile ~/.ssh/id_vast
IdentitiesOnly yes # 不加这条,客户端会把 ~/.ssh 下别的钥匙挨个先试一遍,
# 其中带 passphrase 的那把会在非交互环境下直接失败
ServerAliveInterval 30 # 每 30 秒发心跳,防止空闲连接被中间的 NAT 或防火墙掐断
ServerAliveCountMax 6
EOF

2.3 长任务要放进 tmux

SSH 一断开,远端那个登录会话就结束了。会话里正在跑的程序会收到一个叫 SIGHUP 的信号(意思是「你挂着的那个终端没了」),然后跟着退出。一个要跑几十分钟的压测,扛不住任何一次网络抖动。

tmux 这类工具(学名叫「终端复用器」)解决的就是这件事:它自己在远端常驻,你的程序挂在它下面,SSH 只是临时连上去看一眼,断了不影响下面的程序。

差别是 run.sh 挂在谁下面。左边它挂在登录 shell 上,SSH 一断,sshd → shell → run.sh 整条链一起收到 SIGHUP(意思是「你挂着的那个终端没了」)然后退出。右边它挂在 tmux server 下面,那条链上根本没有 SSH,断线断的只是左半边。

# -d 是 detached:会话在远端后台建立,本地 ssh 命令立即返回
# tee 同时写日志文件,这样即使会话被误杀,已产出的部分仍在磁盘上
ssh gpu "tmux new -d -s bench 'cd /workspace/repo && bash run.sh 2>&1 | tee runs/latest/run.log'"

# capture-pane 打印会话当前的屏幕内容,只读,不影响会话本身
ssh gpu "tmux capture-pane -p -t bench | tail -30"

# 会话还在就说明任务没结束;这比解析日志内容更可靠
ssh gpu "tmux has-session -t bench 2>/dev/null && echo RUNNING || echo DONE"

2.4 先取回结果,再销毁机器

实例一销毁,磁盘跟着回收,没有回收站。所以取回之后、销毁之前,核对一次文件数量:

# rsync -a 保留时间戳等属性,-z 传输时压缩(JSON 和日志的压缩比很高)
rsync -az gpu:/workspace/repo/runs/ ./runs/

R=$(ssh gpu 'find /workspace/repo/runs -type f | wc -l')
L=$(find ./runs -type f | wc -l)
[ "$R" = "$L" ] && echo "一致,可以销毁"

三、四个「看起来没问题」的坑

这四个的共同点是:要么不报错,要么报的错指向了错误的方向。 它们不是某个平台特有的,换个平台、换个项目照样遇得到。

3.1 参数被当成一串字符存下来了

注册 SSH 公钥的时候,文档常写成这样:

# ❌ 传进去的是路径字符串本身,不是文件内容
# 平台接口不检查格式,照单收下,还回你一个 success: True
vastai create ssh-key ~/.ssh/id_ed25519.pub
# ✅ $(cat ...) 是命令替换,shell 会先执行 cat 再把输出作为参数
vastai create ssh-key "$(cat ~/.ssh/id_ed25519.pub)"

写错的后果是机器的 authorized_keys 里存了一串无效内容,SSH 登不进去,只能销毁重建。

这类问题的通用判法:写完回读一次,比对内容,不要看返回码。 公钥正确的样子是以 ssh-ed25519 AAAA 或者 ssh-rsa AAAA 开头,存错了一眼能看出来。

3.2 日志十几分钟不动,但任务没死

程序的输出直接打到终端上时是一行一刷;一旦接给了别的程序(比如用管道给 tee),就变成攒够几 KB 才写一次。

这个设计本身是为了省事 —— 逐行写会产生大量零碎的小写操作。但对一个跑几十分钟、输出又不多的任务,效果就是日志十几分钟只有开头一行,看着完全像卡死了。

同一个任务,日志真正落盘的时刻加了 -u每行立刻写这 14 分钟一行不动默认攒够几 KB 才写一次010203040 分钟
下面那条不是任务死了,是日志还堵在缓冲区里没写出来。所以判断死活要看别的东西:显卡占用率、结果文件、服务端日志,任何一样在动就说明还活着。
# ❌ 输出用管道给了 tee,Python 就切换成「攒够几 KB 再写」
python -m benchmarks.run 2>&1 | tee run.log

# ✅ -u 关掉这个攒批,输出立刻可见;等价于设环境变量 PYTHONUNBUFFERED=1
python -u -m benchmarks.run 2>&1 | tee run.log

判断任务死没死,别看日志有没有新行,看有没有东西在产出 —— 显卡占用率、结果文件、服务端日志,这三样里任何一样在动,任务就还活着。

3.3 第一次启动特别慢,之后就正常了

torch.compile 会把同一个计算试很多种写法,每种实测一遍,挑最快的留下。这个过程叫 autotune,开销全部落在第一次执行上。日志里长这样:

AUTOTUNE addmm(2x5120, 2x1280, 1280x5120)
triton_mm_1205 0.0236 ms 100.0% BLOCK_K=128, BLOCK_M=16, BLOCK_N=64, num_stages=5, num_warps=4
triton_mm_1213 0.0236 ms 100.0% BLOCK_K=64, BLOCK_M=16, BLOCK_N=128, num_stages=5, num_warps=8
...
SingleProcess AUTOTUNE benchmarking takes 1.3776 seconds for 20 choices

triton_mm_1205 这个编号说明已经试到第一千两百多种写法了。单轮一秒多,累计上百轮就是十分钟量级。

第一次执行 vs 之后每一次,按真实比例画第一次autotune:把同一个计算试上千种写法,每种实测一遍,挑最快的留下约 10 分钟,其中真正在算你要的东西不到 1%第 2 次起约 2 秒 —— 就是左边那条几乎看不见的细线今天跑一半明天接着跑,上面那条要整根重新付一遍
这个比例决定了怎么租机器:停机再启动,编译缓存没了就要把这十分钟重付。所以宁可一次做完,也别分成两天。

这个数字直接影响你怎么租机器。 如果打算今天跑一半、明天再跑一半,停机再启动要把这十分钟重新付一遍。所以宁可一次做完。

3.4 profiler 挂不上去

采样式的性能分析工具(py-spygdb 这类)要「趴」到正在跑的进程上去看它在干嘛。在租来的容器里跑,通常是这个结果:

py-spy dump --pid 5429
# Error: Permission denied (os error 13)

容器里明明是 root,还是不行。原因是要连过两道关,而这两道都不在你登录之后能改的范围内:

两道关是「与」的关系,缺一不可,而且都只在创建实例那一刻定得下来 —— 机器已经开起来就加不上了。所以这件事要在开机之前想清楚,或者一开始就走下面那条虚线。

所以这件事得在开机之前就想清楚,创建实例的时候把权限一起申请了。已经开起来的机器加不上。

要是平台不给这个权限,换个思路:用被测程序自己提供的观测接口 —— 很多推理框架内置了按请求统计的 profiler,走 HTTP 访问就行,不需要任何特殊权限。

四、这一轮花了多少钱

一次完整的五层性能排查,2.2 GPU 小时,约 0.83 美元。时间花在这些地方:

表里六段按真实时长排开,横轴单位是分钟10 分钟20 分钟30 分钟拉容器镜像5 分钟不可省装环境30 秒复用项目 CI 脚本下模型和数据集3.5 分钟可预热到持久盘服务冷启动10 分钟分段租会重复付全量并发扫描35 分钟实际测量其余各层25 分钟实际测量
绿色两条是真正在测东西的时间,其余四条都是为了让测量开始而付的钱。六段合计约 79 分钟,整轮计费 2.2 GPU 小时约 0.83 美元 —— 差额是写脚本、看结果、排查这些没单列的时间,它们同样在计费。

真正的风险不是算力费,是存储费和机器丢失。

停机(stop)会保留磁盘、不收 GPU 的钱,听起来很适合「今天跑一半明天接着跑」。但有两件事:

  • 停机不等于占住了机器。 市场型平台上停机后 GPU 就放回池子了,明天再启动,那台可能已经被别人租走
  • 存储费按天累积。 120 G 的盘按每 GB 每月 0.20 美元算,是每天 0.8 美元。放着不用三天,就超过全部算力费

加上 3.3 说的那十分钟编译开销要重新付,结论是:同一天内分两三段做完,做完立刻销毁。

另外别申请超出需要的容量。上面那次实际只用了 41 G(镜像 3 G,模型和数据集缓存 38 G),申请 120 G 已经很宽裕了。