☰
GPU利用率仅12%?模型推理部署性能优化全链路排查指南
2026/10/18 5:50:30 网站建设 项目流程

1. 上线前夜,GPU只跑了12%:一个让所有搞推理部署的人都后背发凉的场景

凌晨两点,监控大屏上那条GPU利用率曲线像一条死人的心电图,稳稳趴在12%的位置。模型服务已经全量接入,流量也在正常打进来,但显卡就是提不起劲。QPS上不去,P99延迟却高得离谱,业务方在群里每隔十分钟问一次“好了没”,而你盯着nvidia-smi里那个刺眼的12%,脑子里只有一个念头:这玩意儿到底卡在哪了?

这个场景我太熟了。GPU利用率低,几乎是所有做模型推理部署的人都会撞上的墙。它不像OOM那样直接给你一记响亮的报错,也不像CUDA版本不匹配那样干脆起不来服务。它更像是一种慢性病——服务能跑,请求能回,但性能就是上不去,资源就是吃不满,成本却一分不少地烧着。你花大价钱买的A100/H100,结果跑出了CPU的吞吐量,这事儿搁谁身上都睡不着觉。

这篇文章就是写给那些正在或者即将踩这个坑的人。不管你是刚接手推理服务的算法工程师,还是负责模型上线的后端开发,或者是被老板按着头优化成本的技术负责人,我都会把“GPU利用率上不去”这件事从头到尾拆一遍。我会讲清楚它为什么会发生、怎么一步步定位、每种原因对应的解法是什么,以及我在实际项目里踩过的那些坑。文章里涉及的工具选型、参数配置、排查路径,都是基于常见工程实践给出的合理方案,你可以直接拿去对照自己的场景。

先说一个核心判断:GPU利用率低,从来不是单一原因造成的。它可能是数据加载拖了后腿,可能是batch size设得太保守,可能是算子没吃到Tensor Core,也可能是请求调度把GPU饿着了。你要做的不是猜,而是像法医一样,一层一层地剖开看。

2. 先搞清楚:GPU利用率到底在衡量什么

2.1 12%这个数字是怎么算出来的

很多人看到nvidia-smi里那个百分比,第一反应是“GPU在偷懒”。但这个数字的真实含义是:在过去的采样周期内,GPU上有多少个时间点至少有一个kernel在执行。注意,它衡量的是“有没有kernel在跑”,而不是“kernel跑得有多满”。

这就意味着两件事。第一,如果你的服务在等数据、等网络、等CPU预处理,GPU上没有任何kernel在执行,这个数字就会掉下去。第二,即使GPU上一直有kernel在跑,但如果这些kernel本身很小、很碎,或者大量时间花在访存而不是计算上,利用率数字可能看起来还行,但实际算力根本没被榨干。

所以12%这个数字,本质上是在告诉你:在绝大多数采样时刻,GPU是空闲的。它不是在“慢慢算”,而是在“等着算”。

2.2 为什么大家这么在意这个数字

因为GPU是按时间计费的。云上的A100一小时几十块钱,你跑出12%的利用率,等于87%的钱打了水漂。自建机房更惨,卡是固定资产,电费是持续支出,利用率低意味着你的单位推理成本高得离谱。

但比成本更致命的是延迟。GPU空闲往往意味着请求在某个环节被阻塞了,这个阻塞会直接反映到P99上。用户不会关心你的GPU利用率,他们只关心“为什么这个接口要等三秒”。所以优化GPU利用率,本质上是在同时优化成本和体验。

2.3 一个容易被忽略的前提:你的瓶颈可能根本不在GPU

我见过太多人一上来就盯着GPU调参,结果查了半天发现是CPU预处理成了瓶颈。模型推理是一个完整的流水线:请求进来、数据解码、预处理、张量搬运、前向计算、后处理、返回结果。GPU只负责其中“前向计算”这一段。如果前面任何一环堵住了,GPU就只能干等着。

所以在开始任何优化之前,你必须先回答一个问题:GPU是真的在算但算得慢,还是根本没东西可算?这两个方向的排查路径完全不同。

3. 定位瓶颈:从12%出发的四层排查法

3.1 第一层:看GPU到底在干什么

第一步永远是打开nvidia-smi,但不要只看那个百分比。你要看的是这几个东西:

  • GPU-Util:前面说过了,采样周期内有kernel执行的时间占比
  • Memory-Usage:显存占了多少,是否接近上限
  • Power Draw:功耗拉起来没有,如果功耗很低,说明GPU确实没在干活
  • Clocks:核心频率和显存频率有没有降频

如果Memory-Usage很高但GPU-Util很低,那大概率是显存带宽或者显存容量出了问题。如果Power Draw只有额定功耗的20%,那GPU确实在摸鱼。

更细一点,你可以用nvidia-smi dmon来看实时的功耗、温度、利用率变化,或者用nvidia-smi pmon看每个进程的GPU占用。这些工具能帮你快速判断GPU是“没活干”还是“干活没效率”。

3.2 第二层:看CPU在干什么

GPU空闲的时候,CPU往往在忙。你要看的是:

  • CPU利用率:是不是某个核跑满了
  • Python进程状态:是不是卡在GIL上
  • 数据加载线程:DataLoader的worker是不是不够

一个很典型的场景是:你的预处理逻辑写在了Python主线程里,每次请求进来都要做一堆PIL操作、numpy变换,这些全是CPU-bound的。GPU算完一个batch只要5ms,但CPU准备下一个batch要50ms,GPU利用率自然就掉到10%左右。

用py-spy或者cProfile挂上去看火焰图,基本一眼就能定位到热点函数。

3.3 第三层:看请求调度和batch策略

如果你的服务是online serving,请求是一个一个来的,那batch size可能一直是1。batch size=1的时候,GPU的并行度根本吃不满,利用率低是必然的。

这时候你要看的是:

  • 实际batch size分布:是不是大部分请求都是单条推理
  • 排队策略:有没有做dynamic batching
  • 超时设置:是不是为了压延迟把batch window设得太短

很多推理框架(比如Triton、TensorRT-LLM、vLLM)都内置了dynamic batching,但默认参数往往偏保守。你需要根据实际流量特征去调max_batch_size、batch_timeout这些参数。

3.4 第四层:看模型本身有没有吃满算力

如果前面三层都没问题,GPU确实一直在算,但利用率还是上不去,那问题就在模型本身了。常见的原因包括:

  • 算子没有用到Tensor Core:FP32的kernel在A100上只能跑出FP16的几分之一
  • 大量小算子:kernel launch overhead吃掉了大部分时间
  • 访存瓶颈:算子本身是memory-bound的,算力再强也没用
  • 精度问题:该用FP16/BF16的地方用了FP32

这时候你需要用Nsight Systems或者Nsight Compute来做kernel级别的profiling,看每个kernel的耗时、occupancy、memory throughput。

4. 常见原因与对应解法:一张表看清所有坑

4.1 原因速查表

现象可能原因排查工具解法方向
GPU-Util低,CPU某核跑满预处理CPU-boundpy-spy, top异步预处理、多进程、GPU预处理
GPU-Util低,CPU也不忙请求太少或batch=1框架日志dynamic batching、增大batch
GPU-Util低,显存占用高显存带宽瓶颈Nsight Compute优化访存、用更高效的算子
GPU-Util低,功耗低kernel太小或太少Nsight Systems算子融合、CUDA Graph
GPU-Util波动大请求不均匀监控面板队列缓冲、弹性batch
GPU-Util低,延迟高调度或通信瓶颈链路追踪优化调度、减少同步

这张表不是让你对号入座就完事,而是给你一个排查的起点。实际场景往往是多个原因叠加,你需要按优先级一个个排掉。

4.2 数据加载:最容易被忽视的瓶颈

我敢说,至少一半的GPU利用率问题,根源都在数据加载上。尤其是做视觉任务的,图像解码、resize、归一化这些操作,如果全放在Python里做,CPU根本扛不住。

解法有几个层次:

第一层:多进程DataLoader。PyTorch的DataLoader有num_workers参数,设成CPU核数的2-4倍通常比较合适。但注意,worker多了会吃内存,而且每个worker都要复制一份数据,显存和内存都要算清楚。

第二层:把预处理搬到GPU。用torchvision.transforms里的GPU版本,或者用DALI这样的库,把解码和增强都放到GPU上做。这样CPU只负责调度,GPU自己喂自己。

第三层:预取和缓存。如果数据是静态的,可以提前预处理成二进制格式,训练时直接mmap读取。如果是动态的,至少要做prefetch,让数据加载和计算重叠起来。

注意:num_workers不是越大越好。我见过有人设成64,结果每个worker都在抢GIL,反而更慢。一般从4开始试,逐步加到CPU核数左右,观察GPU利用率变化。

4.3 Batch策略:online serving的命门

Online serving和离线推理最大的区别是:请求是流式到来的,你不知道下一个请求什么时候来。如果来一个算一个,batch size永远是1,GPU利用率能到20%就算不错了。

Dynamic batching的核心思想是:等一小段时间,攒一批请求一起算。这个“一小段时间”就是batch window,通常设几毫秒到几十毫秒。窗口越大,batch越大,GPU利用率越高,但延迟也越高。

这里有个权衡公式可以帮你估算:

期望batch size = 请求到达速率 × batch window

比如你的QPS是100,batch window设10ms,那期望batch size就是1。这时候你设再大的max_batch_size也没用,因为根本攒不起来。你要么增大window,要么提高QPS。

实际调参的时候,我一般会先看P99延迟的预算。如果预算允许100ms,那batch window可以设到20-30ms,这样batch size能到2-3,GPU利用率会有明显提升。

4.4 精度与算子:让Tensor Core真正跑起来

A100的FP16算力是FP32的8倍,H100的差距更大。如果你的模型还在用FP32推理,那GPU利用率低是活该。

混合精度推理的基本操作是:

# PyTorch示例 model = model.half() # 转FP16 input = input.half() with torch.no_grad(): output = model(input)

但事情没这么简单。有些算子对精度敏感,转FP16会掉点;有些算子没有FP16实现,会自动回退到FP32,反而更慢。你需要用torch.cuda.amp做autocast,或者用TensorRT这样的工具做算子级别的精度选择。

另外,kernel launch overhead也是个大问题。如果你的模型有几百个小算子,每个算子启动都要几微秒,加起来就是几毫秒。这时候可以用CUDA Graph把整个前向图捕获下来,一次性提交,能省掉大量launch开销。

5. 实操:一次完整的GPU利用率优化过程

5.1 场景设定与初始状态

假设我们有一个图像分类服务,模型是ResNet-50,部署在单张A100上,用PyTorch原生serving。流量是每秒50个请求,每个请求一张224x224的图片。初始状态下,GPU利用率12%,P99延迟800ms,QPS只能跑到30。

这个场景是虚构的,但参数和现象都来自真实项目的典型值。

5.2 第一步:用py-spy定位CPU热点

先别动GPU,先看CPU在干什么。挂上py-spy:

py-spy top --pid <pid>

结果发现,PIL.Image.open和transforms.Resize占了60%的CPU时间。这就是典型的预处理瓶颈。GPU算一个batch只要10ms,但CPU准备一个batch要100ms。

5.3 第二步:把预处理搬到GPU

把PIL换成torchvision.io.read_image,把resize和normalize换成GPU版本:

import torchvision.transforms as T from torchvision.io import read_image transform = T.Compose([ T.Resize(256), T.CenterCrop(224), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def preprocess(path): img = read_image(path).cuda() return transform(img).unsqueeze(0)

这一步做完,CPU时间从100ms降到了20ms,GPU利用率从12%涨到了35%。但还不够。

5.4 第三步:开启dynamic batching

35%的利用率说明GPU还是经常在等。这时候上dynamic batching。用Triton或者自己写一个简单的batch scheduler:

import asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch_size=8, timeout=0.01): self.max_batch_size = max_batch_size self.timeout = timeout self.queue = deque() async def add_request(self, request): self.queue.append(request) if len(self.queue) >= self.max_batch_size: return await self.flush() await asyncio.sleep(self.timeout) return await self.flush() async def flush(self): batch = list(self.queue) self.queue.clear() return await self.process(batch)

batch size从1变成平均4之后,GPU利用率涨到了65%,P99延迟反而降到了300ms。因为GPU一次算4张图的时间和算1张图差不多。

5.5 第四步:转FP16 + CUDA Graph

65%还不够。把模型转成FP16:

model = model.half().cuda()

然后用CUDA Graph捕获前向:

static_input = torch.randn(8, 3, 224, 224, dtype=torch.float16).cuda() g = torch.cuda.CUDAGraph() with torch.cuda.graph(g): static_output = model(static_input)

这一步做完,GPU利用率到了85%,P99延迟降到了150ms。QPS从30涨到了120。

5.6 优化前后的对比

指标优化前优化后
GPU利用率12%85%
P99延迟800ms150ms
QPS30120
单位请求成本高降低约70%

这个提升不是靠某一个“银弹”实现的,而是四层优化叠加的结果。每一层解决一个问题,最终把GPU喂饱了。

6. 那些文档里不会写的避坑经验

6.1 不要一上来就调batch size

我见过太多人一遇到GPU利用率低就把batch size往大了调,结果OOM了,或者延迟炸了。batch size是结果,不是原因。你要先搞清楚GPU为什么在等,再去决定要不要增大batch。

6.2 监控要看到秒级以下

很多监控系统是分钟级采样的,GPU利用率12%和15%在分钟级曲线上看起来差不多。但实际服务可能是“忙一秒闲五秒”,分钟级平均下来就是16%。你要用秒级甚至亚秒级的监控,才能看到真实的波动模式。

6.3 注意CPU和GPU的同步点

.item()、.cpu()、print(tensor)这些操作都会强制同步,让CPU等GPU算完。如果你在循环里频繁做这些操作,GPU就会不停地被打断。我见过一个服务,就因为日志里打了一个loss.item(),GPU利用率直接掉了20%。

6.4 显存碎片也会影响利用率

PyTorch的缓存分配器有时候会留下碎片,导致明明显存够但就是分配不出来。这时候可以试试torch.cuda.empty_cache(),或者设置PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。

6.5 多卡场景下的负载均衡

如果你用多张卡,要注意请求是不是均匀分到每张卡上。我见过一个服务,4张卡里3张在跑,1张在闲着,原因是调度器用了round-robin但请求处理时间不均匀。这时候要用更智能的调度策略,比如least-connections或者基于实际负载的动态调度。

6.6 别忘了看PCIe带宽

如果数据要从CPU搬到GPU,PCIe带宽可能成为瓶颈。尤其是做视频解码或者大模型推理的时候,数据传输时间可能比计算时间还长。用nvidia-smi topo -m看拓扑,用Nsight Systems看数据传输耗时。

7. 从12%到85%:一个可复用的优化框架

7.1 四步排查法回顾

回到最开始的那个框架:

  1. 看GPU:用nvidia-smi和Nsight确认GPU是在等还是在算
  2. 看CPU:用py-spy和top确认CPU是不是瓶颈
  3. 看调度:确认batch策略和请求模式是否合理
  4. 看模型:确认算子和精度是否吃满了硬件

这四步是有顺序的,因为越靠前的问题越常见、越好修。先把数据加载和batch策略搞定,再去抠算子级别的优化。

7.2 优化优先级排序

如果资源有限,我建议按这个优先级来:

  • P0:解决CPU预处理瓶颈,这是最常见的
  • P1:开启dynamic batching,这是online serving的标配
  • P2:转FP16/BF16,这是性价比最高的精度优化
  • P3:CUDA Graph和算子融合,这是进阶优化
  • P4:换更高效的推理框架,比如TensorRT、vLLM

每做完一步都要重新测,不要一次性全上,否则出了问题你不知道是哪个改动导致的。

7.3 一个简单的checklist

上线前,对着这个清单过一遍:

  • [ ] 预处理是否在GPU上做
  • [ ] DataLoader的num_workers是否合理
  • [ ] 是否开启了dynamic batching
  • [ ] batch window是否根据延迟预算调过
  • [ ] 模型是否用了FP16/BF16
  • [ ] 是否有不必要的CPU-GPU同步
  • [ ] 监控是否能看到秒级GPU利用率
  • [ ] 多卡场景下负载是否均衡

这个清单不能保证你一定能到85%,但至少能帮你避开80%的常见坑。

8. 最后分享几个我踩过的坑

第一个坑是过度依赖框架默认参数。Triton的默认max_batch_size是4,batch_timeout是0,等于没开batching。我一开始没注意,调了半天以为是自己代码的问题,后来发现是配置没改。

第二个坑是在Python里做图像解码。PIL和OpenCV的解码都是CPU-bound的,而且GIL锁得死死的。后来换成torchvision.io和DALI,世界都清净了。

第三个坑是忽略了日志的同步开销。有一次我在推理循环里打了一个print(output.shape),结果GPU利用率从70%掉到了40%。因为print会触发tensor的同步,GPU每算完一个batch就要等CPU打完日志才能继续。

第四个坑是显存碎片导致的假OOM。明明nvidia-smi显示还有10G显存,但就是分配不出来。后来加了expandable_segments配置才解决。

这些坑的共同点是:它们都不会给你一个明确的报错,只会让你的GPU利用率悄悄掉下去。所以做推理部署,监控和profiling不是可选项,是必选项。你得养成习惯,每次上线前都看一眼GPU利用率曲线,别等到业务方来问才去查。

GPU利用率从12%到85%,中间隔着的不是某一个神奇的参数,而是一整套排查和优化的方法论。这套方法论的核心就一句话:别猜,去看。看GPU在干什么,看CPU在干什么,看请求怎么流动,看数据怎么搬运。看清楚了,问题就解决了一半。

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

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

立即咨询