ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI推理服务GPU利用率低?一次从12%到86%的性能优化复盘

AI推理服务GPU利用率低?一次从12%到86%的性能优化复盘

上线前夜,压测脚本跑到第十分钟,我看着监控面板上那个数字,整个人都不好了:GPU利用率12%,QPS才一百出头,而明天的验收指标是它的六倍。这意味着就算把显卡的每一份算力都榨干,也救不了这个上线窗口。更难熬的是时间——凌晨就要切流量,此刻最怕的不是发现“有bug”,而是发现“问题不知道在哪”。

这个场景在AI推理服务上线时太常见了。你以为瓶颈在显卡,结果显卡闲得冒烟;你以为显存不够用,凑近一看才占了两个G。真正的原因往往是整个请求链路里有某个环节在拖后腿,GPU只是在一边等着干活。这篇文章就复盘一次类似的“GPU利用率只有12%”的排查和优化过程,把判断思路、用到的命令、真正起作用的改动、以及后来反复踩过的坑,一次讲清楚。

1. GPU利用率12%到底在说什么

1.1 别把GPU-Util当成GPU“平均负载”

很多人一看监控面板上写着12%,脑子里冒出的第一个念头就是“这块卡只发挥了12%的算力”。这个理解不够准确,甚至可能带偏排查方向。

监控里显示的GPU利用率,本质上是采样周期内SM(流处理器)上“活跃时钟周期”的占比。它反映的是GPU核心有没有在“干活”,而不是某段时间内干了多少有效计算。比如一次推理计算本身只花了10毫秒,剩下90毫秒都在等数据,那这段时间里GPU核心大多在空转,监控数字自然很低。反过来说,如果你的模型本身就很小、算子很轻,计算瞬间就完成了,利用率不高也未必是故障。

所以要把两种状态分开:一种是“真闲”,一种是“没活干”。真闲是硬件能力过剩、请求本来就稀疏,比如在线人数只有几十个,GPU低利用率再正常不过。没活干则是链路里有东西卡住了,GPU在等CPU、等磁盘、等锁,这时候利用率低就是警报。12%这个数字,在持续压测的场景下出现,几乎可以断定是后一种。

1.2 利用率低什么时候是问题,什么时候不是

判断之前先问自己三个问题:QPS达标了吗?延迟达标了吗?是在持续满载的压测条件下观察的吗?

如果只是并发几十的小流量,GPU利用率低说明硬件冗余,优化空间有但不紧急。真正的危机是并发已经拉满,QPS离目标还有好几倍差距,延迟也稳不住,这时候GPU利用率还只有十几个点,那基本可以锁死在链路瓶颈上。

我这次遇到的情况就是典型:压测并发从50一路加到200,QPS没有跟着涨,GPU利用率固定在12%,显存稳定在2GB出头,CPU侧只有一个核在满负荷跑。看到这几组数据同时出现,我心里已经隐约有了方向,接下来就是用工具把判断坐实。

2. 排查前先想清楚:数据是怎么流经GPU的

2.1 从一次请求到一次推理的完整数据链路

很多人一遇到GPU利用率低就直接盯着模型结构调算子,这是走了弯路。正确做法是先退一步,把一次请求从进来到返回所经过的路径完整画出来。

以典型的图像类推理服务为例,一条请求大概要经过:客户端网络传输、网关转发、服务进程接收、反序列化、图像解码、预处理(缩放、归一化、颜色空间转换),然后才是数据从内存拷贝到显存、触发GPU算子计算、结果拷回内存、后处理、序列化返回。

这条链路里,只有“算子计算”发生在GPU上,其余全在CPU和内存侧。GPU利用率只跟投喂给它的“计算任务”充不充足有关,而不由你声明的算力决定。如果预处理被放在同步路径上,每次推理前都要在CPU上做完整的图像解码和缩放,GPU就像一个排队等上菜的食客——后厨出菜慢,前厅的人再多也只能干坐着。

所以我在调优时,习惯先把链路画在纸上,再一个环节一个环节问:这一步是CPU在做还是GPU在做?是串行还是并行?有没有可能在等待某个外部资源?这套思路放到任何推理框架里都通用。

2.2 一张表定位瓶颈在哪一侧

定位瓶颈其实就三件事:看GPU、看CPU、看IO。我一般同时开三个观察窗口,一个是GPU监控命令的周期刷新,一个是top,还有一个是磁盘/网络监控。根据这几组数据的组合,能很快把嫌疑范围圈出来。

现象组合大概率原因优先排查方向
GPU低 + CPU单核高预处理/解码/序列化在CPU侧串行堵住数据流水线异步化、多进程预处理
GPU低 + 多核CPU都不高请求没喂进来,或链路串行等待动态batch、并发模型、框架配置
GPU低 + 磁盘IO高输入数据体积过大或读取太慢缓存、预取、压缩数据
GPU低 + 显存占用高显存碎片或模型异常放大显存池配置、模型结构检查
GPU高但QPS低算力浪费在无效搬运或同步上算子融合、减少内存拷贝

这张表不算绝对真理,但能帮你在上线前夜这种高压环境里快速缩小问题范围。不需要一开始就确定根因,只要判断出“问题大概在哪个面”,就已经赢了一半。

2.3 上线前夜的排查节奏:先止血,再根治

上线窗口只有几个小时,如果按“先把所有原理搞清楚再动手”来,基本等于自掘坟墓。我的原则是:先止血,找一个能在半小时内生效、风险可控的改动,把指标拉回及格线;等上线稳定之后,再回头做更彻底的重构。

止血手段未必优雅,比如增大batch、调整并发数、把预处理丢到独立进程,甚至暂时压缩输入图片的尺寸,都是能快速见效的招。但它们能让你撑过今晚,这才是优先级最高的事。长期优化手段虽然好,但在那个时间点,先保证服务能按时交付才是最重要的。

3. 实战回放:12%是怎么被救回来的

3.1 现场现象:压测数字让人脊背发凉

交代一下背景:某个图像分类服务,模型参数量在百万级别,跑在单卡服务器上,推理框架用的是常见的深度学习推断组件。压测时发现,并发从50加到200,QPS一直卡在一百出头,P99延迟倒是没有明显劣化。但目标QPS是八百,这个差距非常大。

奇怪的地方在于:如果瓶颈在算力,并发增加时延迟应该快速上涨;如果瓶颈在CPU,那应该看到多个核被跑满。但现场只有其中一个CPU核达到100%,其他核都在躺平,整个系统看起来“并不忙”。这个现象指向一个很具体的判断:问题不是资源耗尽,而是串行链路上某个环节被堵死了。

3.2 第一轮排查:用系统自带工具把嫌疑范围缩小

第一步是开三个终端,分别跑GPU监控刷新、CPU实时状态和磁盘IO监控,然后重新压测。

GPU监控输出里,GPU利用率稳定在12%,显存2.1GB,功耗只有四十多瓦——显卡确实没在认真干活。CPU实时状态里,一个Python主进程把单个核吃满,其他核使用率很低。磁盘IO几乎没什么波动,读写速率都极小。

看到这三组数据,网络和磁盘基本可以排除,CPU单核瓶颈浮出水面。既然只有一个核在满负荷,而不是多核均匀分摊,那么大概率是某个环节被强制串行化了。接下来要搞清楚的是:这个核到底在忙什么。

3.3 第二轮排查:把“卡在哪”这件事钉死

要看到进程内部每一行代码在干什么,最直接的办法是采样。我给Python解释器挂上采样器,运行几十秒后导出一份线程内的函数调用栈统计。结果非常清晰:线程几乎全部时间都花在图像解码和缩放这两个操作上,主服务线程在完成这两个操作之前,根本不会向推理引擎提交任何计算任务。

为了进一步确认,我在推理路径上打上时间戳,统计从请求进来到真正触发GPU计算之间的耗时分布。数据出来后,问题彻底明确了:一次请求里,解码和预处理平均要花200多毫秒,而GPU实际计算只占十几毫秒。也就是说,一次请求90%以上的时间都消耗在CPU侧的准备环节,GPU大部分时间都在空转等数据。

这和监控面板上的12%对上了:GPU利用率不是被算力上限压住的,而是被投喂速度饿住了。

3.4 根因找到了:GPU在等CPU洗数据

根因归纳下来其实就两条,一条是架构层面的,一条是配置层面的。

架构问题:预处理写在了同步调用链里。每次请求进来,先解码图片、再缩放、再归一化,全部在同一个线程里做完才去碰GPU。这些操作本身是纯CPU开销,于是整个推理过程变成了“CPU准备好一份数据,GPU算一次,CPU再去准备下一份”,GPU只能在中间干瞪眼。

配置问题:推理引擎配置里写了一个很大的batch上限,但接入层没有做请求聚合。表面上看batch参数设到了30多,实际上请求是一个一个提交的,每个请求单独触发一次推理,batch参数形同虚设。一个请求进去只喂一张图,GPU的并行能力几乎没有发挥。

还有一个当时没立刻注意到、事后回想很典型的细节:数据加载预处理的线程数设成了1,内存锁页也没开。这意味着即使想用多线程并行预处理,也会被这个初始配置卡死。后来一查,这类配置文件的默认值大多是为“单机调试验证”场景准备的,直接拿到生产压测环境下,天然水土不服。

3.5 动手优化:从应急到长效的完整改动

确定方向后,按“先保上线、再谈优化”的顺序动了三刀。

第一刀最急:把预处理从同步路径挪走。改成异步预取,主线程只负责任务分发和结果回收,预处理交给一组独立的预取进程提前完成,处理好之后把数据放进内存池。这一步不碰模型、不动推理框架,风险最小,但直接让GPU的空闲等待大幅减少。

第二刀是配置层面的:预取进程的并发数从1调到16,同时打开内存锁页,让数据所在的内存页不被换出,减少因缺页导致的额外延迟。这两项改动只改配置,几乎零风险,但对吞吐的影响非常明显。

第三刀属于长效优化:启用请求聚合的动态批处理。让多个在很短时间窗口内到达的请求攒成一个小批次再提交给推理引擎,一次计算处理多张图。为了进一步降低批量推理成本,推理后端切成了半精度,计算速度提升的同时显存占用反而下降了。至于更进一步的算子融合和计算图优化,我放到了上线稳定后的第二周再做,当时的时间不允许动那么大刀。

3.6 结果对比:从12%到86%

改动完成后重新压测,效果是肉眼可见的:GPU利用率从12%一路爬到86%左右,显存占用小幅上涨但远没到上限,QPS从一百出头涨到了六百多,接近目标。最难得的是P99延迟几乎没有变差,因为之前的大量时间本来就耗在等待上,真正的计算队列还没有排起来。

几十毫秒的GPU计算时间没有变化,变化的是让GPU“连续吃饭”,而不是“吃一口等半天”。这台服务器从“看起来闲得慌”到“稳定吃满”,只改了几处配置和一层流水线架构,没有换任何硬件。后来复盘时同事问为什么不直接加一块卡,我算了笔账:加卡要改代码、改调度、改显存策略,上线前夜根本折腾不起,而且问题的根源压根不在卡上。

4. 这类问题的通用排查清单和你最容易踩的坑

4.1 症状-原因-对策速查表

为了让你以后不用再从零开始扯一遍,我把排查中遇到的各种组合整理成一张速查表。看到症状先对号入座,能省非常多时间。

症状原因快速对策
GPU低,单核CPU 100%同步链路的CPU预处理瓶颈异步化预处理,提高预取并发
GPU低,多核CPU都不高请求没形成批次,或推理配置空洞开动态batch,检查并发模型
GPU低,显存被占满显存碎片或模型异常放大查显存池配置,必要时重启
GPU低,磁盘IO打满数据加载是瓶颈加缓存、预取、压缩数据体积
GPU低,网络小包超高服务间调用频繁,通信开销大合并请求,减小头开销,就近部署
GPU低,算子数量多且碎kernel启动开销占比大算子融合、计算图优化、批处理

这些组合我都是拿真实故障“对过账”的,准确率很高。遇到类似问题,先打开监控、盯几眼关键指标,再翻表定位,比盲猜盲试靠谱得多。

4.2 五个我亲手踩过的坑

第一个坑:代码把模型加载到了CPU上。看着显卡是空的,程序也在跑,但压根没调GPU。特征是显存占用几乎为0,或者只有一点点。解决方式很简单:检查模型张量的设备信息,确认推理运算确实发生在GPU上。

第二个坑:Windows上推理一切正常,换到服务器上却利用率上不去。很多时候是GPU没有被设置成计算模式,被图形桌面组件抢占了一部分调度。如果部署环境是带界面的图形工作站,最好确认GPU运行在计算模式再上线。

第三个坑:“开了batch但等于没开”。配置里写了很大的batch上限,但接入层没有做聚合,还是一个请求跑一次推理。检查标准很简单:看单次推理输入的样本数,如果次次都是1,那batch配置就是白写了。真正要解决的是在接入层做请求聚合。

第四个坑:用多线程并发Python代码,美其名曰提升吞吐,实际上被解释器锁卡在一个核上。尤其是CPU密集的预处理任务,线程并发不仅没有加速,反而引入上下文切换开销。这种情况要用多进程,而不是多线程。

第五个坑:把“GPU利用率高”当成“GPU用得好”。利用率上去之后,还得看有效吞吐和延迟。有些优化可以把GPU利用率拉得很高,但大量算力浪费在无效内存搬运或同步等待上,指标好看,QPS没涨。压测时永远盯着QPS和延迟,利用率只是辅助参考。

4.3 上线压测前必做的检查清单

下面这份清单是后来每次做推理服务上线前都会过一遍的,按执行顺序列出来:

  1. 确认模型计算设备是GPU,而不是CPU在兜底。
  2. 确认GPU处于计算模式,不受图形调度影响。
  3. 用压测工具以目标并发打流量,确认GPU利用率、CPU各核、磁盘IO、网络四类指标在同一时间轴上对齐。
  4. 检查推理配置中的batch上限是否真正生效,单次推理输入数不能恒为1。
  5. 检查数据预处理是否异步,是否有足够的多进程并行,内存锁页是否打开。
  6. 检查请求聚合或动态批处理是否开启,小时间窗内的请求能否合并成批次。
  7. 压测时留意GPU功耗和温度。如果功耗也很低,说明GPU确实在空等,而不是算力耗尽。

这几项都过了,GPU利用率低到离谱又找不到原因的概率会低很多。真正难搞的往往不是技术本身,而是你不知道该往哪个方向看。

我个人现在遇到GPU利用率低,第一反应永远是“先看CPU在哪忙”,而不是“这块卡是不是太小了”。十次里有八次,瓶颈都不在显卡上。

最后再分享一个土办法:压测时在终端开一个一秒刷新的循环,盯着GPU利用率数字跳动的节奏。如果它是一阵一阵地跳,说明流量突发没喂饱;如果它稳定在一个低位纹丝不动,那基本可以断定链路里有个环节在持续堵着。这个土办法比很多花哨的profiler都直观,而且永远不用装额外的东西。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进