要不要上多卡,判断顺序往往应该反过来:先在单卡上跑出一组可重复的数据,把延迟和显存分开记账。因为“跑不动”通常是两类完全不同的问题——一类是显存峰值顶到了卡的上限,一类是单次请求耗时太长,前者可能靠切分缓解,后者切分未必有用,反而多出卡间通信。先测单卡,是为了给后面的切分留一个参照点:不切之前多少、切了之后多少,否则连变好变坏都说不清。
适用场景:单卡能启动推理,但不确定余量够不够、也不知道瓶颈在哪一类。操作动作:固定输入规模跑基线,延迟按前处理/推理/后处理分段计时,显存记录基线占用与峰值占用,两类数据分表记,不混成一句“太慢”。验证方式:同一输入重复运行多次,看各项数据是否落在同一量级,首次运行(含权重加载或编译)单独标记。风险边界:显存够而延迟高时,多卡切分通常解决不了单请求延迟,还会引入通信与调试成本;没有单卡基线就谈不上切分收益。
先把推理跑在单卡上并固定输入规模
基线数据要可重复,第一件事是把“输入规模”定义清楚。对视频/时序类推理,常见的规模维度是窗口长度(帧数或秒数)、分辨率、批量大小、并发请求数;对文本或多模态输入,则是序列长度、图像张数与尺寸。建议每次只记录其中一个主维度作为规模标识,其余保持默认值,例如“窗口=16 帧、批量=1、分辨率固定”。
固定做法是:同一段样本、同一分辨率、同一份配置,反复跑同一入口。下面是一段通用骨架,把 preprocess / infer / postprocess 换成 LingBot-VA 2.0 实际暴露的调用方式即可;如果只提供一体式接口、拆不开内部阶段,就只记总耗时,并在记录里注明阶段为简化模式。
import time, json
def run_once(payload):
t0 = time.perf_counter()
pre = preprocess(payload) # 前处理:解码、缩放、切窗
t1 = time.perf_counter()
out = infer(pre) # 推理调用
t2 = time.perf_counter()
res = postprocess(out) # 后处理:后融合、还原尺寸
t3 = time.perf_counter()
return res, {
"pre_s": round(t1 - t0, 4),
"infer_s": round(t2 - t1, 4),
"post_s": round(t3 - t2, 4),
"total_s": round(t3 - t0, 4),
}
跑之前建议先单独加载一次模型、再进入正式计时循环,把权重加载和首次编译的开销排除在基线之外,或者至少给第一次运行打上 first_run=true 标记,后面统计时不和稳态数据混在一起。
分别记录延迟与显存两类数据
延迟的计时点建议固定成三段:前处理、推理、后处理。用 time.perf_counter() 这类单调时钟,不要用会跳变的挂钟时间。三段分开之后,你会看到一些很常见的情况:推理本身不慢,但前处理里的解码或缩放占了大头;或者后处理里的张量拷贝把总时间拉长。这些在只看总耗时时是看不出来的。
显存观测有两种常见做法,选一种并从头到尾保持不变,不要中途换口径:
- 进程外采样:用
nvidia-smi定时轮询,优点是简单、不侵入代码,缺点是采样有间隔,短时峰值可能被漏掉。 - 进程内读取:用推理框架自带的显存统计接口(例如一次性重置峰值、运行后读取峰值占用),优点是能拿到框架视角的分配情况,缺点是只覆盖该框架管理的内存。
# 进程外采样示例,仅用于观测,不改变推理进程行为
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu \
`--format`=csv -lms 500 > gpu_mem.csv
每次运行至少记这几类字段,两类数据在同一行里但分区存放,方便后面分开看:
| 字段 | 记录内容 | 用途 |
|---|---|---|
| run_id / 时间 | 自增编号 | 对照与复现 |
| 输入规模 | 窗口长度、批量、分辨率 | 横向对比的唯一自变量 |
| 延迟 | pre_s / infer_s / post_s / total_s | 判断慢在哪一段 |
| 显存 | 基线占用、峰值占用 | 判断离上限还有多少余量 |
| 运行标记 | first_run、是否报错、设备与配置指纹 | 剔除不可比数据 |
只改一个变量再看数据怎么动
对照实验的原则是一次只动一个变量。先固定批量,只把窗口长度从 8 拉到 16、32;再固定窗口,只把批量从 1 加到 2、4。每次变化都重新采一遍延迟和显存,填进同一张对照表。
| 窗口 | 批量 | infer_s | total_s | 显存峰值 | 是否报错 |
|---|---|---|---|---|---|
| 8 | 1 | ||||
| 16 | 1 | ||||
| 32 | 1 | ||||
| 16 | 2 |
看趋势比看单点更有意义:窗口翻倍时显存是缓升还是陡升、延迟是近似线性还是有明显拐点,拐点位置往往就是你单卡的安全边界。遇到报错时,把完整 stderr、退出码、当时的输入规模和显存观测值原样留下来,不要只写一句“挂了”;显存相关的报错和超时相关的报错,处理方向完全不同,混在一起会白折腾。
判断瓶颈落在哪一类
两类问题的表现和处理方向不一样,可以先按下面的特征对一下自己的日志:
- 偏显存不足:出现内存分配失败、进程被系统终止、显存峰值贴近总量;窗口或批量增大时显存明显上台阶,而单次耗时还在可接受范围。处理方向是先降批量、缩短窗口、降低输入分辨率,或者减少中间张量的常驻副本。
- 偏延迟过高:显存峰值离上限还有明显余量,但总耗时随窗口长度增长很快;或者后处理占比异常高、单次调用容易超时。处理方向是优化前处理/后处理、减少重复调用、把可复用的部分缓存起来,而不是先加卡。
- 两类都紧:先解决显存,让任务能稳定跑完,再看延迟是否可接受;顺序反了容易在还没跑通的情况下就开始调切分。
需要提醒的是,多卡切分主要扩展的是显存容量和整体吞吐能力。如果延迟集中在单个请求的串行链路里(比如前处理解码、后处理还原、多次串行调用),切分通常不会降低单请求耗时,还可能因为卡间同步而变长。判断依据就是那张对照表里 total_s 的构成,而不是感觉。
需要切分时先列出代价
确认瓶颈确实在显存或整体吞吐上之后,再考虑切分。动手前建议把三件事写清楚,每件都要有对应的验收方式:
- 额外通信。核对项:切分后每一步要传什么(中间张量、权重分片还是请求分发),通信量是否随卡数一起增长,是否有明确的同步点。验收方式:在相同输入规模下,把切分前后的总延迟和单次延迟放在一起比;如果 GPU 利用率明显偏低、时间花在等待上,说明通信已经成了新瓶颈,这时候加卡不一定划算。
- 精度对齐。核对项:允许的误差范围是多少,窗口边界、padding、随机采样这些边界情况在两套配置下是否一致。验收方式:同一输入在单卡和多卡各跑若干次,逐字段比对输出,记录差异出现的位置和量级;差异无法解释之前,不建议把多卡结果当作单卡结果的等价替代。
- 调试复杂度。核对项:日志能否定位到具体是哪张卡、哪个阶段出错,错误能不能稳定复现,出问题后能否回退到单卡基线继续跑。验收方式:人为制造一个已知错误,看日志能否在短时间内指出卡号和阶段;如果做不到,先把日志标识补齐再切分。
这三项都能给出答案,再决定切分方案;给不出的部分,就是切分之后要额外承担的成本。单卡基线那份表要一直留着,它是后面所有对比的分母。