☰
GPU利用率只有12%?从数据流瓶颈到推理性能优化实战
2026/10/22 14:42:55 网站建设 项目流程

上线前夜,GPU只跑了12%。我看到这个数字的时候,心里先是一沉,紧接着又松了一口气——沉是因为离验收只剩几个小时,松是因为至少我们发现了它,而不是等系统上了生产才看到满屏超时告警。

事情是这样的:我们组接了一个图像识别服务的上线任务,模型、推理脚本、容器镜像全都准备好了,压测环境也搭好跑了一整轮。前面几轮功能测试都过了,结果最后一轮压测时,我习惯性打开监控面板瞟了一眼,发现GPU利用率稳定在12%左右,几乎是一条平线。显存倒是占了快九成,但算力就是上不去,任务队列越积越长,单张图片的处理时延已经是基线的三倍多。

这里先给不常碰GPU性能优化的朋友解释一下这两者的区别:显存占用率高代表模型和数据确实被加载进显卡了,但GPU利用率(GPU-Util)低则代表计算芯片大多时候在空转,没东西可算。就好比你雇了十个厨师站在灶台前,锅碗瓢盆全摆好了,但配菜师傅一直没把菜送过来,灶台的火是开了,厨师却只能干等着。

这个问题放在上线前夜,属于最让人紧张但又最值得庆幸的一类故障。说紧张,是因为它不像报错那样直接告诉你哪里坏了,需要一层层扒;说庆幸,是因为它造成的后果已经能用数据量化——队列积压、时延超标、吞吐不达标,每一条都足以让验收不过关。这次的排查和修复过程,我觉得很值得完整记录下来,里面有不少常规文档里不会写的东西,对容易踩GPU利用率坑的朋友应该有帮助。

1. 12%到底意味着什么:先分清是“不会算”还是“没得算”

1.1 性能指标的正确解读方式

拿到这个数字,第一件事不是慌,也不是立刻改代码,而是先想清楚它说明什么。

GPU利用率12%,指的是在采样周期内,GPU上的计算核心有任务可执行的时间占比只有12%。注意,这和显存占用是两个维度。我当时看到的显存占用接近90%,这说明显存里已经塞满了模型参数、中间特征图和缓存数据,但计算单元大部分时间处于空闲或等待状态。

为了说清楚这个现象,我画了一个最简化的推导逻辑:如果一个推理服务每处理一张图需要总计100毫秒,其中GPU真正计算只需要15毫秒,剩下的85毫秒都耗在等待数据到达、CPU预处理、张量搬运这些环节上,那么理论上限就是15%。如果实际观测值只有12%,恰好说明模型的计算能力基本没被浪费,瓶颈完全在“喂数据”这一侧。

后来验证下来,这个判断方向完全正确。所以我想强调一个经验:看到低利用率时,第一反应不应该是盲目加batch size或换更大显存的卡,而是先确认GPU到底是在“等数据”还是在“算不动”。前者是数据流问题,后者才是算力问题。两者修法完全不同,方向错了,加多少资源都没用。

1.2 上线前夜这个场景的特殊性

为什么说这个时间点发现问题反而算运气好?因为压测环境和真实生产环境的最大区别,在于数据输入速度。功能测试时数据量小,几张图慢慢喂,GPU自然能做到高利用率,因为没有排队压力;但压测模拟的是生产峰值流量,数据是一个接一个涌进来的,这时候数据流水线里任何一环卡的,都会直接反映到GPU利用率上。

而且这类问题有很强的“隐藏性”:服务本身不报错,所有请求都能正常返回,只是慢。如果验收只看功能正确性,这趴可能就混过去了,等真正上线,用户的请求量一上来,队列就像堵车一样越积越长,最终表现为大面积超时。上线前夜发现,意味着还有窗口去查,还有时间去修,还能在验收数据上给出一个干净的结论。这是我们当时一致认同的一点。

2. 第一轮排查:用三组数据定位瓶颈方向

2.1 三角验证法:GPU、CPU、内存一起看

排查时我没有直接上性能分析工具,而是先用最朴素的三个数据源做了一次“三角验证”。

第一组是nvidia-smi,确认GPU实时利用率和显存占用。这里有个细节:nvidia-smi默认大概一秒多刷新一次,对看趋势够用,但想看清瞬时波动,建议用nvidia-smi dmon或者nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv -l 1这类实时轮询方式。我当时的观察结果是:显存稳定在9成附近,利用率长期在10%到15%之间抖动。

第二组数据是CPU总占用率。我直接top了一眼,发现多核CPU的总使用率只有30%左右,但其中有一个Python进程的单核占用非常高,接近100%。这是个很强的线索——如果数据加载、预处理真在并行走,多核CPU的使用率应该明显更高才对;单核拉满而其他核闲着,几乎可以断定某个环节是串行处理的。

第三组数据是任务的耗时分布。我们的服务端日志里记录了每张图从进入队列到返回结果的完整耗时,我拆开看了一下:GPU推理本身的时间波动不大,反倒是请求在队列里等待的时间占了总耗时的七成以上。这等于把嫌疑锁定得更具体了——GPU确实在等,而等待的源头不是GPU自己慢,是前面的数据没及时送到。

这三组数据放在一起,已经能拼出一个大致的轮廓:数据生产端存在一个串行瓶颈,它拖住了整个流程,导致GPU长期吃不饱。到这里,第一轮排查的目标达成了,接下来需要更精准的工具去确认是哪个环节串行。

2.2 接入Profile工具:把时间花在哪里暴露出来

光猜是不够的,上线前夜更没时间瞎试。我直接给推理服务接上了性能分析工具,在压测流量下采集了一段Profile数据。

我用的工具组合是这样的:进程级别的CPU热点分析用PyTorch自带的Profiler,整体框架级的时间线用nsys做了一轮采样。前者定位Python层的函数耗时,后者看CUDA API和内核的启动间隙。

PyTorch Profiler输出的表格里,最扎眼的是两个数据点:一个叫DataLoader的迭代耗时占了整个step时间的将近80%,另一个是to(device)操作在时间线里反复出现,每次虽然只有几毫秒,但因为次数极多,累计占比也不小。这两个点结合起来,基本可以下结论了:数据从磁盘到内存、从内存到显存的整条链路都是瓶颈。

有个判断值得拿出来说:很多人以为GPU利用率低就该去看CUDA内核的耗时,但在这个案例里CUDA内核本身的执行时间非常健康,真正拖垮时间线的是内核与内核之间那一大片空隙——空隙就是等待,等待就是数据没到位。所以nsys的火焰图上,你看到的是一个个孤零零的kernel,中间隔着漫长的空白,这个画面比任何文字都直观。

2.3 找根因:问题落在预处理和加载上

继续往前挖,我们把数据加载链路拆开,按顺序梳理了一遍:读图→解码→Resize→归一化→转Tensor→拷贝到GPU→推理。

每一步我都加了临时计时日志,用压测流量跑了几百张图,最终统计出来:解码和Resize两个操作占了CPU侧总耗时的60%以上,而且这两个操作是在请求处理线程里同步执行的。也就是说,客户端每发来一张图,服务端线程先花大量时间在CPU上做图像预处理,做完才把数据往GPU上送。

这就像流水线上只有一个工人在干最重的搬运活,其他工位全在等他。GPU这边算得快,但上游只偶尔递过来一个工件,算完又接着等。再加一个不那么明显但同样致命的细节:我们用的图像解码库是PIL,它对大尺寸图片的解码是串行的,而且Python层的GIL决定了即使在多线程模型里,这些CPU密集型操作也没法真正并行。两个因素叠加,CPU侧就成了一个天然的串行瓶颈。

到这里,问题根因基本确认。接下来就是修了,修的时候我们没有零敲碎打,而是顺着数据流把所有瓶颈点一次性理顺。

3. 修复执行记录:把数据流从“串行小路”改成“并行高速”

3.1 预处理从同步改为异步:数据不等推理

第一个动手改的地方,是把图像解码、Resize这些CPU预处理操作从请求处理线程中彻底移出去,放到独立的预处理进程池里提前完成。

具体做法是引入了一个生产者-消费者队列:生产者进程负责从磁盘或网络读取原始图片数据,完成解码和Resize,把处理好的张量放进队列;消费者进程(也就是原来的推理主流程)直接从队列里取现成的张量,不再操心任何图像格式的事情。

这个改动在工程上不复杂,但效果是立竿见影的。原来的同步链路是“收到请求→现场预处理→送GPU”,现在的异步链路是“数据提前处理好→推理随时取用”。GPU等数据的问题,从根上被削掉了一大半。

我在这里用一个生活化的类比解释一下区别:同步方式是客人点菜,厨师才开始洗菜切菜下锅,每一道菜都要等全套流程;异步方式是后厨提前把菜切好配好,客人点单后直接开火炒,出菜速度自然快得多。

3.2 让加载进程真正并行起来

预处理虽然是异步了,但如果只有单进程在干,依然顶不住压测流量。所以第二件事就是增加数据加载的并行度。

我们用的是PyTorch生态,自然就落在了DataLoader的num_workers参数上。原来这个值设的是1,实际约等于没有并行。我参考了一个常规经验公式:num_workers一般设为CPU核心数或核心数减一,但不能盲目调大,因为worker进程太多会导致进程间上下文切换开销反而增加。

当时压测机的CPU是16核,我先按经验公式设成8,随后又用压测流量做了几轮对照:4个worker时GPU利用率提升到了40%左右,8个worker时冲到了65%上下,16个worker反而略有回落,原因是进程调度开销开始吃资源。最终我们选了8,这个配置和数据量级匹配得最好。

这个过程说明一个道理:并行度不是越大越好,一定要测。每个项目的解码耗时、图片尺寸、IO带宽都不一样,唯一靠谱的方式就是做一组梯度实验找到拐点。

3.3 用Pinned Memory把“搬运”也加速

数据在CPU侧准备好了,还要从内存拷贝到显存,这个搬运过程也有讲究。

默认情况下,CPU侧的数据存在可分页内存中,GPU在拷贝之前要先经历一次“固定页面”的操作,这个操作本身要消耗时间。如果我们提前把数据放在锁定内存(Pinned Memory)里,拷贝带宽会高很多,而且拷贝过程是异步的,可以和下一次数据预处理重叠起来。

对应到PyTorch代码,就是两行看起来不起眼、实际效果明显的改动:

DataLoader(..., pin_memory=True) # 拿到CPU张量后,用非阻塞方式拷贝到GPU input_tensor = cpu_tensor.cuda(non_blocking=True)

第一行让DataLoader内部自动把样本放到锁定内存,第二行告诉CUDA这个拷贝可以异步执行,不用等它完成就继续跑后续代码。这两处改动,直接把数据搬运的等待时间压到了极低。

我记得有朋友问过,non_blocking=True是不是对任何数据都能用?这里要补一句:它只在源张量本身位于Pinned Memory时才有意义,如果源张量是普通内存,传了non_blocking也会退化成同步拷贝,所以一定要配合pin_memory=True一起使用,二者缺一不可。

3.4 消除回读和同步点:少让GPU等CPU

解决完数据加载,我又顺手清理了两个“定时炸弹”。

第一个是每个Batch结束时会把一个中间结果张量调.item()取出来做日志统计。这个操作本身不慢,但它会产生一次GPU到CPU的同步拷贝,让GPU流水线停下来等CPU取完值。在压测这种高频调用下,一次小小的同步点会被放大成可观的空闲时间。我的处理方式是改成每N个Batch才统计一次,或者用异步拷贝的方式获取数值,坚决不在热路径上做同步。

第二个是Batch Size的问题。之前为了省显存,我们设得偏小,导致每个Batch的固定开销占比偏高。调大Batch Size之后,单次启动CUDA内核的固定开销被更多的计算量摊薄了,GPU利用率又往上走了一截。当然,这个要兼顾显存限制,我们最终选了一个在压测机上稳定不OOM的最大值。

这三板斧加上前面两步,改动全部落地后,我重新跑了一轮压测,GPU利用率从12%跳到了87%左右,任务队列不再积压,单张图处理时延也降到了基线水平。那一刻我盯着监控面板,心里的大石头才真正落了地。

4. 上线前的最终检查清单:照着查一遍,避免再踩坑

修好之后,我没有立刻宣布完工,而是把这次排查的经验整理成了一份清单,上线前照着过了一遍。这份清单对类似场景应该也有参考价值,我完整列出来。

4.1 数据链路检查清单

  • 数据加载是否走了异步流水线?有没有在请求线程里做同步解码或预处理?
  • num_workers是否经过梯度测试?还是随手填的一个值?
  • pin_memory=True和.cuda(non_blocking=True)是否配对使用?
  • 每个Batch里是否存在.item()、.numpy()、.cpu()这类同步操作?如果有,能否降频或改为异步?
  • Batch Size是否在显存允许范围内尽量取大?有没有因为设太小而放大固定开销?

这五条每一条都是这次排查中实际碰到的问题,也是数据流类性能问题最常出现的五个位置。

4.2 监控与验证检查清单

监控这一块,我额外强调一个以前忽略的点:上线前一定要确认监控指标的采样粒度。GPU利用率这种指标,如果监控系统按分钟聚合,那峰值和毛刺会被抹平,很多隐患根本看不到。我这次之所以能第一时间看到12%,就是因为压测时开着秒级的实时刷新。

在压测验证上,还有一个细节值得说:不要只测“能跑通”,要测“顶得住”。我们当时先把流量逐步加大到预估峰值的1.5倍,观察GPU利用率是否还能维持高位,再回降到正常流量看响应是否平稳。如果只测正常流量,很可能GPU利用率看起来不错,但一遇到突发流量就原形毕露。

4.3 一个容易被忽略的配置项

排查过程中我们还踩了一个跟框架版本有关的配置坑:某个版本里DataLoader有一个prefetch_factor参数,默认值是2,代表每个worker预取多少个Batch。我们的场景中,因为每个Batch的数据预处理耗时较长,默认的预取数量不够覆盖处理时间,导致worker不得不等下一批数据,GPU就又空转了。

这个参数平时存在感很弱,但在IO密集型的场景里影响不小。我把prefetch_factor调大之后,worker的缓冲区能多囤几批数据,GPU等待的时间进一步降低。这里也想提醒一句:框架版本升级后,最好把这类隐式默认值的变化一起查一遍,别让默认配置在背后偷偷拖后腿。

5. 常见问题速查:GPU利用率低还能从哪里查

每次聊到这种问题,总有人问“我照着检查了还是低,还有什么可能性”。我整理了几个排查阶段容易遗漏的方向,供参考。

问题现象:显存占满但利用率极低。大概率是数据流问题,优先查DataLoader、预处理、Pinned Memory。有一种特例是模型本身在跑大规模稀疏计算或访存密集算子,这类算子的理论利用率天花板本身就低,需要结合具体模型判断。

问题现象:利用率忽高忽低,平均下来还是低。这通常是输入的Batch数据大小不均匀造成的。有些服务为了凑Batch会做动态padding,导致不同Batch的计算量差异极大,GPU一会儿满载一会儿空转。解决办法是尽量统一输入尺寸,或者在动态批处理策略上做更细的调度。

问题现象:改了num_workers没有明显改善。注意看CPU总占用率,如果CPU已经接近满载还是没有改善,瓶颈可能不在CPU处理,而在磁盘IO或网络IO上。磁盘随机读慢、网络拉图延迟高,都会让worker进程无米下锅。

问题现象:CPU和GPU都有空闲,利用率还是低。这种情况要检查是不是多进程模型里有全局锁或者跨进程通信瓶颈。Python里尤其要注意GIL、跨进程队列的写入锁,在高频小数据量传输时,锁竞争可能成为隐形瓶颈。

这几种情况我都实际遇到过,处理思路大同小异:先量化每一段耗时,再顺着耗时最长的一环去拆,不要凭感觉优化。

6. 回到上线前夜:一点个人体会

写到最后,我想说几句实际操作的感受。

很多人一看到GPU利用率低,第一反应是换显卡、上多卡、调分布式,但在我们这次场景里,硬件资源从头到尾都是够的,缺的是让数据顺畅流进GPU的能力。上线前夜那个晚上,我们做的所有事情归纳起来就一句话:让数据准备的速度跑赢GPU计算的速度,让GPU永远有活干。这个思路放到大部分推理服务上都适用。

至于工具和方法,我的建议是真不要怕麻烦,Profile工具该上就上。一开始我也习惯靠眼睛盯监控、靠经验猜原因,但这次是nsys时间线里的空白区域直接告诉了我答案。工具输出的数据永远比直觉准确,尤其是在时间紧迫的时候,数据导向反而能帮你最快收敛问题。

这次经验来得虽然惊险,但好在没有带着隐患上线。后来我们把排查清单沉淀成了团队内部的标准动作,新服务上岗前都会过一遍。如果这篇记录能帮你在类似场景少走几步弯路,那就很值了。遇到GPU利用率低的状况,记得先问一句:它到底是在等数据,还是在算不动?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询