上线前夜,监控面板上跳着一个让我后背发凉的数字:GPU 利用率 12%。
服务已经全部就绪,模型权重加载完,接口自测能正常返回结果。按计划压测跑起来之后,预期里这块计算卡应该稳稳跑到 70% 以上,结果实际 QPS 只有预期的四成,p99 延迟还冲到了 1.8 秒。GPU 不忙,业务也上不去——这是比 GPU 打满更麻烦的故障形态。没人愿意在前夜遇到这种问题,但它偏偏就在这时候来了。
这篇文章把从 12% 到恢复正常的排查过程完整捋一遍。我会写清楚当时我看了哪些监控、为什么先怀疑某个环节、每一步验证了什么、最后几个小时里动了哪些配置。无论你是做推理服务部署、训练流程优化,还是在准备自己的性能压测,这套排查思路应该都适用。
1. 压测报告跳出“12%”时,问题到底出在哪里
1.1 现象数据:GPU 不忙,服务却先撑不住了
当时我们部署的是一个某开源模型的在线推理服务,输入是结构化文本,输出是处理后的结果。功能上已经没什么问题,于是开始做上线前的容量摸底。压测脚本用固定并发把请求持续往里打,压了十分钟,面板上的数字让我有点懵:
- GPU 利用率:平均 12%,峰值不超过 25%
- 显存占用:78%,看起来“显存够用”
- 请求成功率:99.9%,没有报错
- QPS:目标值的 42%
- p99 延迟:1.8 秒,而目标要求是 800ms 以内
这几个数字放在一起就很拧巴。显存占了不少,说明模型确实已经常驻显存了,可计算单元大部分时间在空转。请求也没有大面积失败,但就是整体吞吐上不去,延迟还偏高。
GPU 利用率低,很多时候比 GPU 打满更让人抓狂。打满时至少说明“计算资源用上了”,你要做的是降配或者优化算法;而利用率只有 12%,说明问题大概率不在模型计算本身,而在模型前后的某个环节。这是一个方向性的分岔口,选错了方向,后面几个小时就是白忙。
1.2 为什么“利用率低”不等于“资源空着浪费”
有些朋友会觉得,GPU 12% 不是挺好吗?省电、不热、看起来还很从容。但放在上线压测的场景里,这个想法很危险。
利用率低,只说明计算单元忙的时间少。但如果 QPS 和延迟没有达标,那这 12% 其实是一个“过程指标”,背后的问题是请求在别的地方排队等了好几百毫秒甚至更长。用户感知到的不是 GPU 占有率高,而是“转圈转得久”。
我习惯用一个食堂的比喻来理解这件事:GPU 是掌勺大厨,计算能力相当于他炒菜的最大速度。利用率 12%,等于大厨大部分时间站在灶台边发呆。可顾客照样排队排成长龙。你冲过去看,发现不是大厨偷懒,而是配菜工没把食材送上来,或者点菜单子还卡在服务员手里。GPU 空转只是结果,动作发生在别处。
所以排查的第一步不是去改模型、换 GPU,而是搞清楚“到底是谁没把菜送到大厨手上”。
1.3 12% 这个数字的迷惑性:可能掩盖三类瓶颈
同一个“12%”,背后的原因可以完全不同。我梳理了一下当时最怀疑的三类情况,也建议你遇到类似问题时按这个维度去对比:
| 类型 | 特征 | 典型现象 |
|---|---|---|
| 数据供应不足 | GPU 算完一批,下一批还没准备好 | 利用率呈短促尖峰状,中间有大段平地 |
| 同步等待过多 | CPU 与 GPU 反复等待彼此,算子小、切换多 | 利用率整体偏低,功耗不稳定,延迟抖动明显 |
| 计算本身不饱和 | 模型太小、批量太小,单次计算压不满 GPU | 利用率上不去,但 QPS 也低,明显“杀鸡用牛刀” |
这三种虽然都表现为“GPU 利用率 12%”,但从监控曲线和性能特征上能看出明显区别。当时我们拿到的曲线是第一种和第二种的混合体:利用率像心电图,一会儿冲到 30%,一会儿跌到 3%,完全没有平稳期。这个形态基本可以确定,GPU 是在“间歇性干活”,每次干的时间极短,然后长时间等待新输入。
2. 排雷第一步:先搞清 GPU 是在“闲着”还是“等饭来”
2.1 先用几行命令看趋势,别急着看平均值
很多人上来就看nvidia-smi输出的平均值,然后下结论。平均值很容易骗人。比如 5 分钟平均 12%,可能是稳定地低,也可能是 1 秒冲到 95%、后面 4 秒都是 0,平均下来还是 12%。这两种情况的维修方向完全不同。
第一步建议用实时采样工具看趋势。我当时用的命令大概是这样的:
nvidia-smi -l 2 # 每2秒刷新一次,看利用率、显存、功耗、温度 nvidia-smi dmon -s pu -d 5 # 连续输出 GPU 内部利用率一边压测一边盯输出,重点看的不只是一个数字,而是几个数字的配合关系:
- 利用率尖峰后是否立刻回落?
- 显存占用有没有跟着利用率一起起伏?
- 功耗是否一直在低位徘徊?
如果功耗只有额定值的 20%,说明 GPU 真的没有“用力”,而不是某种监控工具统计口径的问题。功耗是硬件层面最诚实的指标之一,它不会骗你。
当时我们在利用率每次冲高时,功耗也能跟着跳一下,但整体还是低。说明 GPU 确实持续在做“小份工作”,每次工作很轻,做完立刻闲下来。这就把范围缩小了:要么是数据输送不稳定,要么是计算任务被切得太碎。
2.2 快速定位“数据等 CPU”还是“CPU 等 GPU”
下一步是看 CPU 侧。用top -H -p <服务进程pid>能看到每个线程的 CPU 占用情况,比只看进程总体占用率有用得多。
当时进程总 CPU 占用大约 36%,看起来不高,排除了“CPU 打满”这种最直白的嫌疑。但用top -H看了每个线程之后,发现里面有一个线程的 CPU 占用接近 100%,而其他线程都很闲。这就是典型的“单线程卡点”。
接着用pidstat 1和工具里的 IO 等待统计看了一眼磁盘。这里的逻辑是这样的:
- 如果磁盘读等待很高,说明数据读取跟不上
- 如果数据读取不忙,但某个 CPU 线程接近 100%,说明瓶颈在处理逻辑本身
- 如果 CPU 也不忙,磁盘也不忙,GPU 也不忙,那就是锁等待或者网络/排队延迟
我们当时的结论是:磁盘不忙,数据量也不算大,但有一个预处理线程被反复唤醒,几乎满负荷。GPU 每次等这个线程把数据处理完才开始干活,GPU 能不闲吗?
2.3 给一次请求拆个时间账,所有疑问都清楚了
工具只能给出“哪里忙”,但不能直接告诉你“哪里该优化”。于是我在代码里临时加了几段打点,统计一个请求从进来到出去,各个阶段各花了多少时间。
统计结果非常直接:
- 数据读取:约 80ms
- 预处理(编码、格式化、转为模型输入):约 680ms
- 模型推理:约 42ms
- 后处理与返回:约 110ms
- 其他等待:约 390ms
一次请求一共大约 1.3 秒,真正的 GPU 推理只占 42ms,占比 3%。预处理一个人就吃掉了 680ms,几乎占了总时间的一半。
这个数据一出来,整个排查方向就立刻聚焦了:问题不在模型,不在 GPU,而是在送数据进模型之前的那段流程。这也是为什么 GPU 利用率只有 12%——因为 GPU 在那 680ms 里一直等饭,等到了也只炒两三秒的菜。
3. 顺着数据链路逐段挖:CPU、磁盘、批尺寸的连锁反应
3.1 数据处理管线不背锅才怪:worker 太少、锁、Python 开销
发现预处理耗时 680ms 之后,我们先检查数据加载配置。当时用的深度学习框架是常见的开源版本,默认的数据加载 worker 数是 2,预处理在 Python 侧进行,还会涉及一些文本规整、模型输入格式转换等操作。
这里面的第一个问题已经很明显:worker 太少,而且没有做数据预取。默认情况下,上一批数据被 epoch 循环取走之后,下一批的预取材才开始工作。数据管线是“串行”的:请求来了,先逐条预处理,预处理完再组 batch,组完才送 GPU。GPU 在整个串行链路的最末端,属于绝对的“下游受害者”。
这次排查中,我们把加载配置改成了这样:
dataloader = DataLoader( dataset, batch_size=8, # 后面会讲为什么从1调到8 shuffle=False, num_workers=8, # 从默认2调到8,让预处理并行 prefetch_factor=4, # 预取更多批次,避免串行等待 pin_memory=True, # 把内存拷贝放到后台,减少H2D阻塞 )改动这一层之后,预处理耗时从 680ms 降到了 200ms 左右,GPU 利用率也从 12% 涨到接近 30%。这个结果验证了主嫌疑,但 30% 仍然不够上线的目标。数据管线只是第一根稻草,后面还有别的瓶颈在压着。
这也解释了一个很容易踩的坑:CPU 总占用不高,不等于数据管线没问题。默认的 worker 数往往只用了 CPU 的几个核,其他核心全闲着。你看到的总利用率 36%,其中有 25% 是一个核心打满,剩下几个核心在打酱油。总利用率这个宏观指标,在定位单线程卡点时非常不可靠。
3.2 batch_size 不是无脑调大,但是 12% 时往往不够大
第二个问题是单请求推理。
之前的服务实现是“来一条请求,预处理一条,送一次模型推理,再返回”。也就是说,GPU 每次只处理一个样本,叫做 batch size = 1。
batch size = 1 的问题在于:一次推理不仅要算矩阵乘,还要承担固定的调度开销、内存搬运、算子启动成本。一个 batch 里只有一个样本,这部分固定开销被一个样本独自承担。这就像你开车去上班,每次只带一个人,油费不会因为载一个人就按几分之一算。
我们把 batch size 调到 8 之后,单次推理的总耗时没有按 8 倍上涨,只涨到了原来 batch=1 时的约 1.8 倍。换句话说,一次处理 8 个请求的总耗时,大概只比一次处理 1 个请求多 80%。平均到每个请求上,推理成本降到原来的 22% 左右。
但注意,batch_size 不是越大越好。当 batch 大到一定程度后,单次推理耗时开始接近线性增长,而且显存占用也会明显抬升。我们当时试了 batch=16、batch=32,发现吞吐的增量明显放缓,p99 延迟反而开始变差,于是停在了 8 这个平衡点。
建议你用一个小脚本扫一遍 batch size,记录下来总耗时和延迟的数据:
| batch size | 单批耗时 | 单样本平均耗时 | 显存占用 |
|---|---|---|---|
| 1 | 30ms | 30ms | 6.1GB |
| 8 | 55ms | 6.9ms | 10.8GB |
| 16 | 90ms | 5.6ms | 15.2GB |
| 32 | 190ms | 5.9ms | 21.5GB |
不同模型、不同显存容量、不同算力水平,拐点都会不一样。重点是找到“单样本平均耗时”开始回升的那个点,那才是你的合理 batch 大小。
3.3 预处理里的 Python 开销与动态形状:隐藏的同步杀手
预处理的 680ms 里,大头是每条样本做了大量 Python 对象操作:字符串切分、词典映射、拼接、转 tensor、填充到定长。这些操作在 CPU 侧看着不起眼,但每条请求怼进来都做一遍,而且没法并行到多个核心,最终就压到了那一个线程上。
可以把这些预处理改成更贴近底层的方式,比如统一用张量化操作代替循环,把不变量提到循环外,减少临时对象生成。这一项我们改动完,200ms 又降到了 90ms 左右。
还有一个容易被忽略的问题是动态形状。输入文本长度不统一,每条样本转成 tensor 后长度不同,框架在推理时就要反复重新计算图结构,GPU 侧等待 CPU 把这些重新分配的工作做完。我们做了一件事:在预处理阶段将所有输入统一填充到预设长度,然后开启半精度和静态图优化。这之后,模型推理部分 42ms 稳定到了 25ms 左右。
到这里,GPU 利用率终于爬到了接近 60%,QPS 达到了目标的七成。剩下的 12% 到 60% 之间,还有最后一道硬骨头:小算子太碎,GPU 一半时间在“换班”。
4. 最后三个小时:我们真正动手改的东西和改造优先级
4.1 先啃数据管线:把单队列改成双缓冲队列,稳赚不赔
数据管线的改造是所有改动里风险最低、收益最明确的一步。因为它不动模型逻辑,只是把“等数据”变成“提前备好数据”。
我们用到的思路是双缓冲:一个队列正在 GPU 执行时,另一个队列同时在做预取和预处理;GPU 算完一批,立刻能拿到已经准备好的下一批。用前面的食堂类比,就是配菜区多了一个“备餐台”,大厨炒完一道菜不用伸着脖子等,直接端下一盘。
落到代码层,num_workers=8、prefetch_factor=4、pin_memory=True大体上就是这么个作用。改动后可以先小流量验证,不行随时回滚,没有任何副作用。上线前夜如果只能改一样东西,我建议优先改这里。
但有一个细节要注意:num_workers不是越大越好。如果每个 worker 都占用大量内存做数据拷贝,进程内存会迅速上涨。我们当时从 2 调到 8,内存占用涨了大约 3GB,属于可接受范围。如果再往上调,比如 16,内存压力就会开始挤占显存预留空间,得不偿失。自己机器上建议一边调一边看内存,别只看 GPU。
4.2 再削调度开销:算子融合与整图编译
数据管线解决了之后,下一个大头是“调度开销”。
我们模型本身规模不大,单次推理也能在 25ms 内完成,但这里说的 25ms 是 GPU 侧真正计算的时间吗?并不是。用性能剖析工具看时间线后,我发现在一小推理过程里,几十个非常小的算子被逐个启动。每个算子的启动时间大约几微秒,而计算本身可能也只有几十微秒。GPU 花在“从上一个算子切换到下一个算子”的时间,比真正算的时间还长。
这种情况在小模型上尤其典型。模型规模一大,每个算子本身算得够久,启动开销占比就低;模型小的时候,算子碎、启动开销占比高,GPU 就会呈现“忙碌但发射率高”的假象,实际上大量时间浪费在切换上。
我们抓了几个特别碎的小算子,尝试了两类手段:
第一类是算子融合,把相邻的、可以合并的算子合并成一个,减少切换次数。比如把归一化和激活函数合进前面的卷积或线性层里,这类操作在大多数推理引擎里已经是自动化的,不一定需要手工改,但需要确认你没有用“完全关闭优化”的模式。
第二类是整图编译。在支持的框架里,它会把整个模型的计算图“固化”成一个可复用的执行计划。第一次构建时花的时间多一点,之后每次执行直接重放,不再走一遍图解析和算子调度。大概的格式是:
graph = cuda.Graph() # 第一次捕获,固定输入/输出内存 graph.capture_begin() output = model(input_batch) graph.capture_end() # 后续执行直接重放 graph.replay()这么改完,GPU 侧的调度开销被大幅压低,相同 batch size 下的单批耗时又降了 30% 左右。GPU 利用率从 60% 涨到了 80% 附近,QPS 终于达到了预期。
不过这一步的改动风险要高于数据管线。整图编译通常要求输入形状固定;如果业务里有动态长度、动态 batch,就要么在框架配置里关掉动态 shape 相关特性,要么单独走兼容方案。我们当时是把输入填充到固定长度,业务上可以接受,所以风险是可控的。如果你的业务对动态 shape 依赖很强,不要在上线前夜强上这种优化,它一旦出问题,排查难度会成倍上升。
这里给一句发自肺腑的提醒:架构级优化,必须在白天、在充足时间里做;前夜只适合做“能回滚的配置级优化”。
4.3 最后调 batch 与并发:改一项验一项,防止“虚高”
到了这一步,我们顺手把 batch_size 继续从 8 试到 16,再配合并发请求队列做了流量整形。也就是说,不是每个请求来了立刻单跑推理,而是进一个队列,攒到 batch=16 再一起送到 GPU。这种方式能让吞吐进一步上涨,代价是单请求在队列里多等几十毫秒。
这里有一个很关键的操作习惯:每一次改动,只改一个变量,然后压测五分钟,记录 QPS、延迟、GPU 利用率和显存。不要一次性把所有优化全打开,否则出了问题你不知道是哪一步引起的,排查成本比优化本身还高。
最终我们把 batch 定在 16,配合整图编译后的静态形状,GPU 利用率稳定在 78% 到 85% 之间。QPS 达到了目标的 1.2 倍,p99 延迟压到了 620ms,比上线要求还低了近两成。
为什么最后没有继续追求 95% 以上的利用率?因为利用率不是最终目标。如果为了多拉 10 个百分点的利用率而牺牲了延迟稳定性,上线后用户感受反而会变差。80% 左右的利用率,配上达标的 QPS 和延迟,是这个场景下的性价比最高点。
4.4 上线前夜的取舍:哪些改动敢做,哪些只敢排进下周
前夜改了三样东西,但每一件的风险等级不一样。我把这次的经验整理成一个简单的取舍表,供参考:
| 优化手段 | 收益 | 风险 | 上线前夜建议 |
|---|---|---|---|
| 调大数据加载 worker 数、开启预取 | 中高 | 低,可回滚 | 敢做,优先做 |
| 调大 batch_size 并增加并发队列 | 高 | 低,但需关注延迟 | 敢做,但要扫参数 |
| 输入固定长度、开启半精度 | 中 | 中,需业务配合 | 业务可接受就做 |
| 算子融合、整图编译 | 高 | 中高,动态 shape 受限 | 小模型可做,大模型谨慎 |
| 更换推理引擎 | 很高 | 很高,需全量回归 | 绝对不做,排到上线后 |
我在前夜学到的一个原则是:离上线不到 24 小时,任何“改了之后要花一小时才能验证正确性”的优化,都不值得冒险。收益再大,只要会拖慢上线节奏,就先记进 backlog,等上线稳定之后再分批次做。
我们这次比较幸运,数据管线、batch、图优化三个改动都顺利回归通过。如果中间任何一步出现诡异的不稳定现象,我会立刻回滚到两小时前的版本,先保证能按原定时间上线——哪怕性能差一点,也不在前夜做无把握的重构。
5. 压测前夜如果再来一次,我会先做的三件事
5.1 提前跑一个“小流量基线”,而不是等到压测才反应
这次之所以前夜手忙脚乱,一个原因是性能摸底做得太晚。如果能在上线前三天,哪怕只是起一个单机服务,打一点模拟流量,看一下“当前 GPU 利用率大概是多少”,12% 这个炸弹早就暴露了,完全不用等到前夜。
建议你准备上线前,先花半小时跑一次低并发冒烟测试,记录三组数据:GPU 利用率、QPS、端到端延迟。如果利用率低于 40%,就直接按这篇文章的排查顺序走一遍。基线数据提前拿到,后面所有改动都有对照,你不用靠猜。
5.2 监控指标要组合看,别只盯一个利用率
只有 GPU 利用率的监控,就像开车只看时速表不看油表,迟早出问题。前夜我最开始看到 12% 时,第一反应是“是不是监控工具统计错了”,后来是看功耗曲线才确认问题真实存在。
我建议至少要同时看这些指标:
- GPU 利用率与功耗:利用率低但功耗高,说明有别的进程在抢占;利用率低功耗也低,说明 GPU 在等数据
- CPU 各线程占用:用
top -H看有没有单线程打满,而不是只看总占用 - 显存占用:显存高但利用率低,大概率是模型已加载但计算没喂饱
- 请求各阶段耗时:这是所有监控里,最能帮你直接定位瓶颈的硬数据
5.3 请求批处理逻辑要早一点写进架构
如果让我提前规划,我会在服务设计的第一天就内置一个“请求排队 + 批处理”的机制,而不是等到压测之后才补。也就是把“来一个请求,立刻推理”的模式,改成“来多个请求,攒够一批再推理”。这会带来一个微小的额外延迟,但吞吐量会大幅提升。
不过这里要留意业务约束:如果你们的服务要求单条请求延迟必须极低,批处理就不一定合适,因为排队等待本身会吃掉时间。建议在业务需求里明确写清楚“延迟优先还是吞吐优先”,再决定是否采用批处理。
前夜解决完问题后,我把这个教训记进了项目复盘里:性能验证不是上线前的仪式,而是开发流程里的常规步骤。这次遇到的 12% 利用率,虽然排查花了不少时间,但最终让整个服务在性能指标上达到了预期。如果再遇到下一个服务上线,我大概率会第一天就把这条排查链路跑一遍。
最后分享一个小技巧:如果时间真的不够,你可以先只调一个参数——把数据加载的 worker 数调大,并开启预取。这一项通常是最容易出效果、也最安全的一步。它没法保证解决所有问题,但往往能让你从 12% 变成 30% 以上。剩下的事,留到上线后的第一个迭代周期再从容处理,比前夜把自己逼到极限要好得多。