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_size8, # 后面会讲为什么从1调到8 shuffleFalse, num_workers8, # 从默认2调到8让预处理并行 prefetch_factor4, # 预取更多批次避免串行等待 pin_memoryTrue, # 把内存拷贝放到后台减少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 倍上涨只涨到了原来 batch1 时的约 1.8 倍。换句话说一次处理 8 个请求的总耗时大概只比一次处理 1 个请求多 80%。平均到每个请求上推理成本降到原来的 22% 左右。但注意batch_size 不是越大越好。当 batch 大到一定程度后单次推理耗时开始接近线性增长而且显存占用也会明显抬升。我们当时试了 batch16、batch32发现吞吐的增量明显放缓p99 延迟反而开始变差于是停在了 8 这个平衡点。建议你用一个小脚本扫一遍 batch size记录下来总耗时和延迟的数据batch size单批耗时单样本平均耗时显存占用130ms30ms6.1GB855ms6.9ms10.8GB1690ms5.6ms15.2GB32190ms5.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_workers8、prefetch_factor4、pin_memoryTrue大体上就是这么个作用。改动后可以先小流量验证不行随时回滚没有任何副作用。上线前夜如果只能改一样东西我建议优先改这里。但有一个细节要注意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再配合并发请求队列做了流量整形。也就是说不是每个请求来了立刻单跑推理而是进一个队列攒到 batch16 再一起送到 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% 以上。剩下的事留到上线后的第一个迭代周期再从容处理比前夜把自己逼到极限要好得多。