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-bound | py-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延迟 | 800ms | 150ms |
| QPS | 30 | 120 |
| 单位请求成本 | 高 | 降低约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 四步排查法回顾
回到最开始的那个框架:
- 看GPU:用nvidia-smi和Nsight确认GPU是在等还是在算
- 看CPU:用py-spy和top确认CPU是不是瓶颈
- 看调度:确认batch策略和请求模式是否合理
- 看模型:确认算子和精度是否吃满了硬件
这四步是有顺序的,因为越靠前的问题越常见、越好修。先把数据加载和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在干什么,看请求怎么流动,看数据怎么搬运。看清楚了,问题就解决了一半。