☰
GPU显存占用高但利用率低?上线前夜的一次推理服务性能排障全程
2026/10/22 12:09:53 网站建设 项目流程

上线前夜,GPU只跑了12%

数据都换好了,镜像也推上去了,灰度集群拉起来准备压最后一轮。我随手敲了一行nvidia-smi,本意是确认显存有没有被同事的调试任务占走,结果看到那行数字的时候,整个人愣了一下——GPU利用率12%,显存倒是吃得挺满,显存占用接近80%。

这种“显存高、算力低”的组合,比显存爆了还让人难受。爆显存至少错误信息明明白白,一眼就知道哪里出了问题;显存被大块张量占着、流处理器却大面积空转,说明数据在GPU上等着被算,但计算单元就是没活干。上线前夜看到这个数字,意味着这次推理服务的并发模型根本撑不住预期的流量,硬上线的结果大概率是接口超时、队列堆积,然后被下游调用方超时熔断,最后变成一场线上事故复盘会。

我把这12%当成了一个起点,往下追了一整夜。从最表面的nvtop观察,到用性能分析工具逐段拆,最后定位到的问题根源,其实是一个很多人天天都踩、但不到上线前夜根本不会在意的细节。这篇文章把那一夜的完整排查链路写下来,包括我怎么从GPU侧追到CPU侧、再追到数据管线和框架自身的开销,以及最后上线的配置长什么样。如果你的服务也出现过“显存高但利用率上不去”这种怪象,这篇内容应该能帮你少走不少弯路。

1. 当显存占用和计算利用率严重背离时,问题从哪里开始查

先说结论:GPU利用率低这件事,绝大多数情况下根本不是GPU本身不行,而是GPU在等数据、等同步、等调度,或者在被一堆低效的小算子拖后腿。12%这个数本身没有意义,有意义的是它背后那88%的空闲时间花在了哪里。

1.1 先分清三种“利用率”指标

很多人在这一步就混淆了。nvidia-smi里的GPU-Util百分比,指的是采样周期内GPU上一个或多个内核(kernel)实际运行的时间占比,它衡量的是计算单元忙不忙,不是显存用了多少。而显存占用率是另一套独立指标,两者没有直接正相关。

  • GPU-Util(计算利用率):流处理器在执行计算任务的时间占比。
  • 显存占用:当前加载到显存中的张量、缓存、权重总量。
  • 显存带宽利用率:数据搬运占理论带宽的比例,这个指标往往被忽略,但对推理服务影响巨大。

正常情况下,一个设计合理的训练或推理任务,GPU-Util应当稳定在85%以上。低于50%就值得警惕,低于30%基本就是病态了。我见过很多同学一看到利用率低就调batch size、加并发,结果越调越慢——因为在没有定位到瓶颈环节之前,所有参数调整都是盲人摸象。

1.2 我那一夜的排查工具链

排查性能问题不能靠猜,得靠数据。我常用的组合是这一套,按层次递进:

工具作用使用时机
nvidia-smi/nvtop看整体利用率、显存、温度、功率发现问题的第一眼
nvidia-smi dmon按秒记录多张卡的利用率历史确认是偶发还是持续低
nsys(NSight Systems)分析整个应用的CPU/GPU时间线,看kernel之间的空隙判断瓶颈在数据搬运还是计算
nsys+ncu(Compute)分析单个kernel的SM占用、内存吞吐判断单个算子有没有优化空间
py-spy/perf看Python/C++层面的CPU热点当CPU侧成为瓶颈时

对那一夜的场景来说,最关键的命令是nsys profile -t cuda,osrt,cpp -o output --force-overwrite true python serve.py。它能输出一份带时间线的报告,用NVIDIA的Nsight Systems打开后,能直接看到每个GPU kernel在时间轴上的位置、时长、以及相邻两个kernel之间CPU在干什么。

打开报告的一瞬间,问题就藏不住了。GPU的时间线像一节节稀疏的火车车厢——kernel跑一小段,然后是一条长长的空白,再跑一小段,再空白。空白的地方是CPU在忙着准备数据、拷贝数据或者等待锁。这就是GPU利用率上不去的直接原因:计算密度太低,每个kernel的活儿太少,中间的空隙太大。

2. 数据管线与CPU侧的问题:一个几乎人人都踩过的“隐形杀手”

如果说GPU是工厂里的机器,那CPU和内存就是原料输送带。机器转得再快,输送带跟不上,机器就只能在那边空转等待。那一夜我第一次追下去,发现最明显的问题不在GPU,而在数据喂进去的环节。

2.1 罪魁祸首之一:Python的GIL和DataLoader多进程

我那个服务用的是Python写的推理入口,数据预处理包括解码、切分、归一化,大量操作走的是Python库。问题出在,我把推理主体放到了GPU上,但预处理还留在Python主进程里跑,而Python的GIL限制了多线程并行,导致预处理几乎是以单核的算力在跑。

这一下的连锁反应是:GPU很快跑完一个batch的前向,然后停下来等Python把下一个batch准备好。nsys的时间线里,GPU的空闲间隔和Python预处理耗时高度吻合——预处理多久,GPU就闲多久。利用率当然上不去。

解法比较常规但也比较有效:把预处理改成独立的多进程流水线,主进程只负责把处理好的数据拿到GPU上,不要让主进程同时干两件事。如果用的是PyTorch,DataLoader的num_workers一定要开;如果用的是自定义服务,要给预处理单独启动几个进程,用队列把数据和GPU消费端解耦。

注意:num_workers也不是越大越好。它默认每个worker独立跑Python解释器,进程多了会有额外的内存开销和上下文切换成本。对文本类小数据,num_workers=4到8比较合适;对图像这类预处理较重的数据,可以开到CPU核心数的一半左右,再往上就要观察是否出现CPU运行队列堆积。

2.2 罪魁祸首之二:频繁的小张量拷贝和CPU-GPU数据搬运

第二个问题在显存占用高这个现象上也对得上号。当时的服务把每个请求都单独tensor.cuda()一下,然后在GPU上做动态shape的处理。每个请求都有一次完整的CPU到GPU的数据搬运,而不同请求的数据长短不一,GPU kernel也得跟着频繁重新分配。小批量、频繁搬运,是最典型的显存高而利用率低的场景。

可以这么理解:你把一大摞文件抱进复印机,一次抱一大摞,复印机可以连续工作很久;但你如果一趟只抱三五张纸,来回跑上百趟,复印机大部分时间都在等你跑路。我这个服务当时的问法就是一趟趟地抱纸。

改进方向有两个,当时我两个都做了。第一是把请求攒成batch再送进GPU,减少搬运次数;第二是把动态shape固定化,用padding统一长度,减少GPU侧因为shape变化导致的kernel重复实例化开销。改完之后,同样的压测流量下,GPU利用率从12%直接跳到了40%以上。

2.3 别忽视文件系统和IO侧

第三个比较隐蔽的问题是文件IO。那套服务有一个功能要从共享存储上读取附件,三个副本同时跑,每次请求都可能触发一次网络文件读取。在GPU空闲的时间段里,追踪CPU侧的热点会发现,线程卡在文件读取的系统调用上。

这种情况用py-spy dump --pid <pid>看Python栈,能看到大量线程阻塞在open/read等系统调用上;用perf top则能看到内核态的文件系统相关热点。解决思路是在服务启动时把高频访问的附件缓存到本地内存或SSD上,减少跨网络读取的频次。

排查建议:遇到GPU利用率低,先别急着优化模型或算子,先在CPU侧看几眼。用pidstat -p <pid> 1看看CPU是不是都被占满了,再排个队看哪个进程吃CPU。很多你以为是GPU性能问题的事故,查到最后CPU侧才是最忙的那个。

3. GPU侧的真相:不是所有kernel都配得上“计算密集”四个字

CPU侧和IO侧的问题修完之后,GPU利用率从12%涨到了大概四成。但离我要的85%+还有距离,而且再往上调batch size也没有明显改善。这时候就得把注意力放到GPU自己身上了。

3.1 小算子堆积:GPU的“流水线空转”

打开Nsight Systems的kernel视图,我按总耗时排序看了一遍GPU kernel的分布。结果很有意思:耗时最高的不是某一个大型卷积核或矩阵乘法,而是一大堆时间极短的小kernel。每个只有几十微秒,但数量极多,占满了整个时间轴。

这类小算子包括逐元素操作(如add、relu、gelu)、维度变换(reshape、permute)、归一化里的统计量计算等。单个看都不算贵,但GPU跑一个kernel是有固定开销的——包括kernel启动时间、上下文切换、内存同步。当你有几百个这样的小kernel排队跑,最终算下来,真正在“算”的时间占比反而不高。拿人来类比,就像你让一个打字员每打一个字就站起来接杯水,效率肯定上不去。

这一步的定位方式是用ncu --metrics sm__throughput.avg.pct_of_peak_sustained_elapsed去分析每一个kernel的实际SM占用率,很快就能找到那些SM利用率低的小算子名单。不同的框架叫法不同,但做的工作是一样的。

3.2 框架融合:把几百个kernel压成几十个

要解决小算子堆积的问题,主要靠两步。第一步是让框架自己做算子融合,把相邻的逐元素操作合并成一个kernel;第二步是把显式控制流和动态Shape从热路径里彻底去掉。

我当时用的是PyTorch生态,所以直接做了两件事:一个是给模型推理路径包上torch.compile,另一个是把原来为了应付动态shape而写的各种Python分支逻辑改成基于padding的定长方案。torch.compile加入之后,实际效果立竿见影——同一份代码,kernel数量下降了接近一半,GPU利用率从四成涨到了六成多。

如果你用的是Triton Inference Server,对应的方案是启用TensorRT的后端和自动FP16/FP8精度,TensorRT在算子融合这件事上做得比通用框架更彻底。如果你用的是自定义C++/CUDA服务,那就需要手写融合算子,把那些频繁出现的“elementwise+reduce”组合手动合成到同一个kernel里。

注意:torch.compile在一些老模型上可能出兼容性问题,尤其是用了自定义autograd Function的动态图。稳妥的做法是先跑通一遍推理,确认输出数值和原始逻辑一致,再切到线上。上线前夜做这种改动,一定要有完整的数值比对回归。

3.3 精度与并发:升级到FP16/F8和Kernel并发调度

把kernel数量降下去之后,我又顺手调了一次精度和并发调度。很多推理框架默认用FP32做前向计算,而模型的实际精度需求往往没那么高。切到FP16,最直接的好处是显存带宽压力减半、半精度算力翻倍,SM能跑得更满。如果用的是支持FP8的硬件,量化之后的吞吐还能再涨一截。

具体做法是:权重用FP16/FP8存储和计算,但保留FP32的master weight做精度校正;在关键层(如softmax、LayerNorm)保留FP32计算,避免数值溢出。这里有个常见的坑:FP16的数值范围有限,直接一刀切全部FP16,有些模型会出现精度崩坏。我一般会先跑一遍验证集的指标对比,确认掉点可以接受,再上生产。

另外还做了一件事,就是让多个小kernel在同一个stream里能够并发执行。GPU上的SM资源够多时,不同的小kernel可以同时跑在不同SM上,而不是一个结束另一个才开始。PyTorch里可以使用多个CUDA stream,但要注意的是,如果一个kernel依赖另一个kernel的输出,必须加事件同步,否则会读到脏数据。我实测下来,让独立的预处理kernel和计算kernel走不同的stream,能小幅但稳定地提升SM占用率。

4. 上线冲刺阶段的优化步骤与验证方法

凌晨一点半,我把能做的优化都叠上去了。摆在我面前的是四五个改动点,分布在不同代码层。上线前夜,最忌讳的就是一股脑全推上去然后出了问题时不知道是哪个改动引起的。

所以我按风险从低到高顺序操作,每改一步跑一轮压测,留数据,再改下一步。

4.1 分批改动、每步留痕的操作顺序

我当时按这个顺序来:先修CPU侧的预处理进程,再做数据搬运的batch化,然后上torch.compile,最后才切FP16。每一步都单独跑一轮压测,记录三组指标:GPU利用率、p99延迟、每秒请求吞吐。

具体步骤大概长这样:

  1. 把预处理从主进程拆出去,独立4个worker进程跑,主进程只负责推理。压测一轮,GPU利用率12%→27%。
  2. 把请求从单个送GPU改成动态batch成组送,同时固定输入shape,用padding统一长度。压测一轮,27%→46%。
  3. 给推理路径包上torch.compile,再次压测,46%→63%。
  4. 切换FP16,压测验证精度不掉点后放量,63%→78%。

这四步做完,吞吐比最初的版本高了三倍左右,p99延迟从原来的80毫秒压到45毫秒,GPU利用率虽然没到理论上的95%,但在这个服务的负载特征下,78%已经算健康水平了。

4.2 使用三个压测场景综合评估

我没有只压一组流量就草草收工。上线服务最怕的就是某种流量形态没问题、另一种流量形态直接被打穿。我当时设计了三种不同场景分别压测,发现利用率和延迟表现差异很大:

压测场景特点观察结果
小请求高频单条消息短、并发量大数据搬运占比高,CPU预处理压力大,对padding策略最敏感
大请求低频单条消息长、并发量小计算量和显存占用高,FP16收益最明显
混合随机模拟真实流量分布最能反映真实上线后的表现,作为最终上线的依据

真实的流量大概率不是均匀的,所以我最终以上线后真实流量分布为准,用混合随机场景的数据做容量预估。另外,在每种场景下我都特意把batch size往上调了几轮,找到性能和显存占用之间的平衡点——调太高虽然利用率高了,但显存容易溢出,一旦OOM就会整卡重启,得不偿失。

4.3 上线前夜的灰度验证策略

改到这一步,代码和配置已经在测试环境稳定跑了几小时。上线前夜真正焦虑的不是功能,而是会不会到明天流量一上来就暴露新问题。我当时的做法是,先不放开全量流量,只让1%的真实请求走新版本,观察半小时;确认GPU利用率、延迟和错误率都在预期范围后,再逐步放量到10%、50%、100%。这个过程不要跳级,每放一级都要看一遍指标趋势。

灰度期间我还做了一件平时不常做的事——把显存监控的告警阈值从“高位告警”改成了“抖动告警”。因为上线初期流量不稳,显存占用可能会在一段时间内快速爬升之后回落,固定的高位阈值往往告警不及时,抖动告警能更早发现异常。这也算是个实战小技巧。

5. 让12%不再重演:一些持续有效的检查机制

那一夜最终在早上六点把全量流量切了过去,GPU利用率稳定在75%到85%之间。回头复盘,发现一个挺讽刺的事实:这类问题其实很常见,但大多数团队都要到上线前夜这种高压时刻才会去查它。

5.1 建立“性能基线”和“容量规划”两道防线

我现在的做法是,在项目启动初就确立一套性能基线指标,包括不同并发下的GPU利用率、p99延迟、吞吐、显存占用等。每次模型结构或服务代码有改动,都要先跟基线比一遍,掉点超过阈值就当场定位,不留到上线前。这套基线不一定很复杂,用脚本定期压测、记录、对比即可,重点在于“每改必测”的习惯。

容量规划也值得提前做。很多人觉得gpu利用率越高越好,其实不是,推理服务要考虑冗余。留一点吞吐余量,是为了应对突发流量和单机故障后的流量重分配。我一般把利用率压到70%到85%视为健康区间,超过85%就要考虑扩副本或拆流。合理的规划比临场调优更省心。

5.2 基础设施层顺手加两个监控项

如果你已经上了Prometheus这类监控体系,GPU利用率、显存、温度这些常规指标不够,建议加上下面两个更挑剔的指标:

  • NVML里的SM时钟节流事件:可以捕捉到温度墙或功耗墙导致的计算降频。肉眼看到利用率不高,先看看是不是被降频了。
  • CUDA runtime API耗时分布:通过DCGM暴露的指标能看到kernel执行之外的时间开销(例如环境等待、通信等待、上下文切换),快速增长时可以提示CPU侧或通信侧存在新瓶颈。

5.3 最后盘点一下,最容易被忽视的三个原因

经历了这次排障,我总结出三个最容易被忽视的低利用率成因:

  • 动态shape:每次输入长度不同、导致padding和kernel反复重建,是利用率隐形杀手。优先考虑统一长度或做bucket分桶。
  • PyTorch默认的CPU与GPU之间张量搬运不及时释放:每次tensor.cuda()都会在两端产生临时内存,不及时释放会越积越多。建议用torch.cuda.set_per_process_memory_fraction、或者提前分配一个固定的缓存池。
  • 共享存储上的IO延迟:体现在GPU时间线的间隙里,但根源不在GPU。排查时先看CPU在等什么,往往比盯着GPU利用率本身更有效。

最后的建议:性能排查永远是先观察再动手,给足数据采集时间,一看数据再谈优化。不要因为“看起来GPU满了”就只盯着GPU,CPU侧、IO侧、框架调度侧都值得扫一眼。优化这种事,位置找对了,改动往往很小,收益却很大。

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

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

立即咨询