news 2026/10/22 6:28:21

GPU利用率12%背后:在线推理服务性能瓶颈排查与优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GPU利用率12%背后:在线推理服务性能瓶颈排查与优化实战

上线前夜,监控面板上跳着一个让我后背发凉的数字: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单批耗时单样本平均耗时显存占用
130ms30ms6.1GB
855ms6.9ms10.8GB
1690ms5.6ms15.2GB
32190ms5.9ms21.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% 以上。剩下的事,留到上线后的第一个迭代周期再从容处理,比前夜把自己逼到极限要好得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 6:28:18

38.8毫秒返回结论:决策模型为何能跳过生成式推理?

如果一个接口能在38.8毫秒内返回答案&#xff0c;而且整个过程“不生成一个字”&#xff0c;你会怎么想&#xff1f;我第一次看到这个性能数字时&#xff0c;第一反应是有人把大模型蒸馏到只剩一个分类头&#xff0c;后来仔细研究开源仓库才发现&#xff0c;它走的完全不是生成…

作者头像 李华
网站建设 2026/10/22 6:27:56

科学速读训练:从逐字慢读到一目十行的高效阅读方法

你有多久没有"完整读完"一本书了&#xff1f;不是那种翻两页就困、刷手机比翻书多的"假阅读"&#xff0c;而是真正一口气读进去、读完还能复述出核心观点的阅读。我说的"一目十行"不是什么天赋异禀&#xff0c;也不是某些培训机构渲染的"量…

作者头像 李华
网站建设 2026/10/22 6:27:46

AI辅助开题报告写作:从选题凝练到答辩预演的完整实操

1. 从选题荒到导师约谈&#xff1a;我为什么会对一款AI写作辅助工具动心1.1 那场把我逼到灵感的开题答辩预演先说个背景。我在某高校读研那阵&#xff0c;研究方向定得早&#xff0c;可开题报告整整憋了三周。一开始我以为自己有进度&#xff0c;结果把初稿交过去&#xff0c;某…

作者头像 李华
网站建设 2026/10/22 6:27:44

Axure图片上传自动预览:从文件输入框到动态面板的完整交互配置

简介&#xff1a;一份 Axure 图片上传与自动预览实现教程&#xff0c;主要面向 Axure 初学者和中级用户&#xff0c;帮助产品经理、交互设计师在原型中模拟真实上传效果&#xff0c;避开浏览器安全机制将本地路径替换为 C:/fakepath 的常见阻碍。文档按步骤讲解页面元件布置&am…

作者头像 李华
网站建设 2026/10/22 6:27:43

Java锁底层原理全解析:从对象头到内存屏障

1. 一条主线看懂Java锁的全貌Java锁的底层原理&#xff0c;几乎是所有并发编程学习绕不过去的坎。你背过synchronized的用法&#xff0c;知道volatile能保证可见性&#xff0c;也听说过“锁升级”这个词&#xff0c;但真被问到“对象头里的Mark Word到底存了什么&#xff0c;偏…

作者头像 李华
网站建设 2026/10/22 6:27:07

PowerShell激活Python虚拟环境报错?执行策略与activate.ps1全解析

很多人第一次遇到这个提示&#xff0c;都是在代码编辑器的内置终端里&#xff1a;刚刚创建好虚拟环境&#xff0c;兴冲冲地敲下.\venv\Scripts\activate.bat&#xff0c;回车&#xff0c;结果什么反应都没有&#xff0c;或者直接跳出一行红字。再仔细一看&#xff0c;编辑器贴心…

作者头像 李华