GPU利用率仅12%?模型推理部署性能优化全链路排查指南

发布时间:2026/10/18 5:50:34
GPU利用率仅12%?模型推理部署性能优化全链路排查指南
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要50msGPU利用率自然就掉到10%左右。用py-spy或者cProfile挂上去看火焰图基本一眼就能定位到热点函数。3.3 第三层看请求调度和batch策略如果你的服务是online serving请求是一个一个来的那batch size可能一直是1。batch size1的时候GPU的并行度根本吃不满利用率低是必然的。这时候你要看的是实际batch size分布是不是大部分请求都是单条推理排队策略有没有做dynamic batching超时设置是不是为了压延迟把batch window设得太短很多推理框架比如Triton、TensorRT-LLM、vLLM都内置了dynamic batching但默认参数往往偏保守。你需要根据实际流量特征去调max_batch_size、batch_timeout这些参数。3.4 第四层看模型本身有没有吃满算力如果前面三层都没问题GPU确实一直在算但利用率还是上不去那问题就在模型本身了。常见的原因包括算子没有用到Tensor CoreFP32的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也不忙请求太少或batch1框架日志dynamic batching、增大batchGPU-Util低显存占用高显存带宽瓶颈Nsight Compute优化访存、用更高效的算子GPU-Util低功耗低kernel太小或太少Nsight Systems算子融合、CUDA GraphGPU-Util波动大请求不均匀监控面板队列缓冲、弹性batchGPU-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永远是1GPU利用率能到20%就算不错了。Dynamic batching的核心思想是等一小段时间攒一批请求一起算。这个“一小段时间”就是batch window通常设几毫秒到几十毫秒。窗口越大batch越大GPU利用率越高但延迟也越高。这里有个权衡公式可以帮你估算期望batch size 请求到达速率 × batch window比如你的QPS是100batch window设10ms那期望batch size就是1。这时候你设再大的max_batch_size也没用因为根本攒不起来。你要么增大window要么提高QPS。实际调参的时候我一般会先看P99延迟的预算。如果预算允许100ms那batch window可以设到20-30ms这样batch size能到2-3GPU利用率会有明显提升。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延迟800msQPS只能跑到30。这个场景是虚构的但参数和现象都来自真实项目的典型值。5.2 第一步用py-spy定位CPU热点先别动GPU先看CPU在干什么。挂上py-spypy-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降到了20msGPU利用率从12%涨到了35%。但还不够。5.4 第三步开启dynamic batching35%的利用率说明GPU还是经常在等。这时候上dynamic batching。用Triton或者自己写一个简单的batch schedulerimport asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch_size8, timeout0.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 Graph65%还不够。把模型转成FP16model model.half().cuda()然后用CUDA Graph捕获前向static_input torch.randn(8, 3, 224, 224, dtypetorch.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延迟800ms150msQPS30120单位请求成本高降低约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_CONFexpandable_segments:True。6.5 多卡场景下的负载均衡如果你用多张卡要注意请求是不是均匀分到每张卡上。我见过一个服务4张卡里3张在跑1张在闲着原因是调度器用了round-robin但请求处理时间不均匀。这时候要用更智能的调度策略比如least-connections或者基于实际负载的动态调度。6.6 别忘了看PCIe带宽如果数据要从CPU搬到GPUPCIe带宽可能成为瓶颈。尤其是做视频解码或者大模型推理的时候数据传输时间可能比计算时间还长。用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这是性价比最高的精度优化P3CUDA 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是4batch_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在干什么看请求怎么流动看数据怎么搬运。看清楚了问题就解决了一半。