资讯动态

GPU利用率从12%到73%:推理服务性能压测优化复盘

发布时间:2026/10/22 5:35:11 来源:尧图企业网站定制
凌晨一点半距离上线窗口只剩五个小时。压测程序跑完第二轮我盯着 nvidia-smi 里 GPU-Util 那一栏困意直接给吓没了12%。白天单测的时候模型 forward 的延迟明明是毫秒级代码走查也没发现异常所有人都以为这会是一次安稳上线。结果真实流量一压GPU 基本处于“半睡半醒”的状态QPS 只有目标值的三分之一上线窗口就那么点压测报告却交不出去。这就是做推理服务的人最容易踩的“性能幻觉”模型单测正常全链路压测一上就爆雷而 GPU 这个最贵的硬件反而在摸鱼。如果你也遇到过类似情况——单测正常、上线前压测 GPU 利用率低得离谱这篇复盘值得花十分钟看完。我会把当时的排查思路、profiler 里看到的关键数据、最终改动清单和为什么这样改完整写下来。1. 先弄清楚 12% 是什么GPU 利用率不是你想的那个百分比1.1 nvidia-smi 的 Utilization 到底在测什么很多人一看到 GPU-Util 12% 就觉得显卡坏了但其实要先理解这列数字的采样机制。nvidia-smi 里的 utilization.gpu反映的是过去采样周期内 SM流式多处理器上有没有活跃 kernel 的时间比例它不直接等于“计算资源用了多少”更不等于显存带宽用了多少。有时候采样周期比较粗GPU 刚跑完一批短 kernel 进入空闲采样那一刻正好没踩中显示 0% 也是常事。另外一个常见的误区是把显存占用当成利用率。显存占得高比如 16G 显存用了 12G只代表模型和中间激活值把显存放满了和 SM 有没有在干活是两码事。显存是仓库SM 是产线仓库塞满不代表产线在转。还有一个必须排查的“正常低利用率”场景如果模型以 embeddiing 查询、broadcast、KV cache 读取这类内存密集型操作为主SM 计算单元本来就闲着利用率低不代表性能有问题因为真正的瓶颈在显存带宽。我们当时做的第一步就是先判断这 12% 是“病”还是“正常态”。1.2 三分钟排除“误报”显存、功耗、CPU 一起看把 nvidia-smi dmon -s m 跑起来重点看两个字段SM 占用和显存带宽占用。还顺手看了功耗和时钟频率。结果很明确功耗只有额定功耗的 20%显存带宽占用也不到三成SM 的忙碌程度确实是 12% 上下。这说明 GPU 不是被显存带宽卡住而是真的处于“等指令”的空转状态——它在等 CPU 给它喂活儿。然后看了一眼 CPU 占用率85% 以上。这个数字非常关键。GPU 没在忙CPU 在忙说明问题的根源大概率不在 GPU 本身而在它外围那一圈数据供给、CPU 上的预处理、Python 侧的执行调度。到这里第一轮判断完成不是采样误报也不是带宽饱和GPU 的 SM 确实在大面积空转。2. 数据供给排查模型自己在等什么2.1 直喂随机张量先把预处理和 IO 全部甩开线上链路里一张图从请求进来要经过解码、resize、normalize、转 GPU、进模型、后处理。任何一个环节慢都会让 GPU 挨饿。为了把“模型本身低效”和“上游供给慢”这两个问题分开我写了一个很短的测试绕开所有数据管道直接用 torch.randn 造一个 shape、dtype、device 都正确的随机 tensor持续喂给模型然后观察 GPU 利用率。import time import torch from model import create_model model create_model().cuda().eval() dummy torch.randn(8, 3, 224, 224, devicecuda) with torch.no_grad(): # warmup把 cuDNN autotune 和动态图编译的首次开销跑掉 for _ in range(20): out model(dummy) torch.cuda.synchronize() t0 time.time() for _ in range(200): out model(dummy) torch.cuda.synchronize() dt time.time() - t0 print(favg: {dt / 200 * 1000:.2f} ms/iter)这段代码跑的时候用另一个终端盯 nvidia-smi。这个测试回答了一个很关键的问题把数据调到模型嘴边GPU 到底能不能高速运转结果让人意外——就算数据凭空出现GPU 利用率依然只有 12% 左右。这直接排除了“只要优化数据管道就能解决”的侥幸问题藏在模型 forward 内部。不过这不代表数据供给就没问题后面全链路 profile 又给了第二重证据。2.2 全链路压测CPU 预处理才是真正的“红绿灯”随机 tensor 测试之后我回到真实链路给每一步埋了时间戳请求进入、解码完成、resize 完成、normalize 完成、转 GPU 完成、forward 完成、后处理完成跑 50 个请求取平均。结果触目惊心CPU 侧的 decode resize normalize 平均耗时 58msGPU forward 只有 10ms。58ms 对 10msGPU 当然吃不饱——它每干一个 10ms 的活就要空等五倍的时间。更让人哭笑不得的是我们竟然在预处理代码里看到了训练期遗留的图像增强分支随机旋转、随机翻转、色彩抖动生产环境根本不需要这些但条件判断和额外计算一个都没少。这是很典型的“训练 transform pipeline 被整体复制到推理代码”事故上线前代码走查如果只盯着模型结构根本发现不了。这里还有一个容易忽略的叠加效应CPU 满载时负责 CUDA kernel launch 的 CPU 线程会被操作系统抢占调度。PyTorch 的每个 GPU kernel 都需要 CPU 先把 launch 指令发出来CPU 忙到喘不过气GPU 的输入队列就变空SM 自然没事可干。表面看是“GPU 空闲”实际上是“CPU 没空搭理 GPU”两个问题撞在一起才让 12% 这个数字这么刺眼。3. Profiler 撕开模型内部三宗罪浮出水面3.1 小算子密集GPU 的工作效率被拆碎了数据供给只是第一个问题模型 forward 内部还有更大的问题。我用 torch.profiler 抓了一轮 batch8 的 forward记录 CPU 侧和 CUDA 侧的全部活动from torch.profiler import profile, ProfilerActivity with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapesTrue, ) as prof: for _ in range(50): out model(dummy) print(prof.key_averages().table(sort_bycuda_time_total, row_limit15))关键读数整理成一张表看得非常清楚观察项数值说明GPU kernel 有效执行时间合计约 1.3msSM 真正在跑业务计算的时间kernel 之间的空隙约 8.8msGPU 等 CPU 发布下一个 kernelCUDA 同步等待约 2.6ms等结果从 GPU 拷回 CPU每轮 forward 的 kernel 数量236 个eager 模式下一个算子一个 kernel执行时间小于 10us 的 kernel 占比68%大量 kernel 本身只跑几微秒这个组合说明模型不是“计算量大所以慢”而是“算子切得太碎”。一个 CUDA kernel 的 launch 在 CPU 侧要花大概 3 到 10 微秒包括参数校验、context 检查、命令 buffer 提交等等。当 kernel 本身只运行 2 到 6 微秒时CPU 发布指令的时间比 GPU 执行的时间还长GPU 必然大量时间在排队、切换、等待指令。这种问题在训练时并不明显因为训练 batch 大、迭代连续GPU 队列永远是满的CPU 还没发完当前 kernelGPU 已经把上一批算完了流水线能自然叠起来。到了 batch 小的实时推理CPU 和 GPU 的节奏差就暴露无遗。3.2 动态 shape 带来的隐形罚款第二个问题来自输入 shape 不固定。我们的推理服务做了动态 batching同一个 batch 里的图片数量 n 是实时变的可能这一轮是 3 张下一轮就变 6 张。更糟的是加载模型时开启了 torch.backends.cudnn.benchmark True——这是训练时代养成的习惯。当输入 shape 变化时cuDNN 的第一反应是重新跑 autotune测试各种卷积算法来选当前 shape 下的最优解。这个搜索过程会消耗大量 GPU 时间跑一堆用于测速的候选 kernel但对业务产出没有任何帮助更麻烦的是shape 一变之前缓存的算法选择和 kernel 实例可能全部作废前面做的预热也白做了。如果每个请求的 batch size 都在变GPU 就反复陷在“搜索算法、切换 kernel、重新缓存”的循环里计算效率自然上不去。3.3 .item() 同步点流水线被定时切断最后一个问题是在代码走查时发现的。推理循环里为了记录延迟和一个中间指标每处理一个 batch 就调用一次 tensor.item()。这个操作看起来只是取一个数实际上会触发一次设备同步CPU 必须等 GPU 把当前队列里的所有 kernel 全部执行完拿到返回值才能继续往队列里塞下一批 kernel。GPU 队列一旦排空流水线就断了重新启动还得经历一个回满的过程。# 错误示范hot path 上的同步 for batch in iterator: out model(batch) _ out.mean().item() # 这里会同步GPU 队列被清空 results.append(out)# 修正同步点移出 hot path for i, batch in enumerate(iterator): out model(batch) results.append(out) if i % 50 0: monitor_queue.put((i, time.time())) # 异步打点不碰 GPU去掉同步点后GPU 利用率从 12% 升到 17%。这只是第一步但足以证明同步点的杀伤力。4. 理解 GPU 饥饿的机理为什么训练没事、推理拉了4.1 计算单元的“忙”取决于队列是否充足把 GPU 利用率用一条公式写出来会非常直观GPU SM 利用率 ≈ kernel 执行时间 /数据准备 传输 kernel 启动调度 同步等待 kernel 执行 输出传输这六个环节里面只有第五个是真正让 SM 干活的其他任何一个环节变大都会把利用率拉低。所以你优化 GPU 利用率本质上不是让 GPU 跑得更快而是把分母里“非执行环节”的时间压下去。这个公式是我在排查这类问题时最常用的思维框架。4.2 训练与推理的天然差异训练时一次迭代涉及几百个 kernel 连续启动而且迭代之间的 kernel 形状和顺序高度一致CPU 可以在 GPU 执行当前 kernel 的同时提前把下一个 kernel 的 launch 指令准备好流水线基本不会断所以训练利用率高是常态。推理则完全不同batch 小、kernel 短、CPU 和 GPU 的交互更频繁。任何一次同步等待或者 CPU 侧的一下卡顿都会立刻反映成 GPU 队列变空。用一个生活化的比喻训练像装满集装箱的重卡车队GPU 是一条高效传送带箱子源源不断传送带就不停推理像一堆零散快递每个包裹都很小反而分拣和装车的时间占了大头包裹再多传送带也只能间歇性动一下。4.3 低利用率什么时候算正常需要强调一点不是所有服务都必须把 GPU 利用率拉到 60% 以上才合理。低延迟在线推理如果 batch 很小GPU 平均利用率 20% 到 30% 很常见关键是 QPS 和延迟预算是否达标GPU 利用率只是中间指标。我们这次场景是高吞吐批处理目标 QPS 对应的期望利用率在 60% 以上实际只有 12%属于明确的性能事故。所以排查前先定义清楚你的场景要延迟还是要吞吐再决定该不该追利用率。5. 上线前夜的修复组合拳每一步改了什么、效果如何5.1 裁剪预处理把重活从 CPU 挪到 GPU第一步先删掉训练时代遗留的图像增强分支CPU 预处理直接从 58ms 降到 34ms。第二步是把 resize、normalize、通道转换这些能 GPU 化的部分全部改成 GPU 算子decode 仍然留在 CPU但 decode 之后立刻把 tensor 转到 GPU不要在 CPU 上继续做任何 transform。torchvision.transforms.v2 里的多数算子可以直接接收 GPU tensor或者用 torch.nn.functional.interpolate 自己实现 resize。改动之后CPU 侧只剩下解码和最小量的封装占用率掉到 20% 以下GPU 则多出几毫秒“顺手”完成这些预处理。这里的关键认知是CPU 预处理和 GPU 推理本是流水线中的两个工位只要一个工位慢整条线都会堵在它前面。把预处理挪上 GPU 后两个工位不再互相阻塞CPU 也能腾出手去做下一个请求的解码。5.2 固定输入 shape把确定性还给编译器推理服务改成固定 batch8不满足 8 张时用 padding 补齐forward 输出后再切掉 padding。这一步配合一轮 warmup让 cuDNN 的算法选择和 kernel 缓存都稳定下来model create_model().cuda().eval() dummy torch.randn(8, 3, 224, 224, devicecuda) with torch.no_grad(): for _ in range(10): model(dummy) torch.cuda.synchronize()同时把训练时代遗留的 torch.backends.cudnn.benchmark True 关掉。固定 shape 之后用启发式算法加预热已经足够快没必要再让 cuDNN 反复去“考试”。如果不固定 shape 还开着 benchmark等于每次换 shape 都触发一次 autotune前面省下来的时间全得赔回去。5.3 torch.compile 把细碎小算子打包固定 shape 之后终于敢上 torch.compile 了。我们用的是torch.compile(model, modereduce-overhead)这个模式通过 CUDA Graph 把 kernel launch 次数和 CPU 调度开销压下来对小算子密集型的模型特别对症。第一次调用会有较长的编译时间和额外显存占用跑几轮 warmup 之后可以接受压测环境里多出来的那点显存还够用。这里有个建议如果你的模型里有动态控制流、自定义算子或者某些不支持编译的模块torch.compile 可能会静默回退到 eager 模式所以不能只看到编译成功就放心还要再用 profiler 确认一次 kernel 数量和 kernel 耗时确实下来了。我们当时就在一个自定义采样模块上踩过这个坑——编译报告一切正常profile 一看那几个算子还是老样子全部走了回退。5.4 同步点删干净把推理 hot path 里的 .item()、.cpu()、torch.cuda.synchronize() 全部移走。监控数据改成了异步队列方式或者用低频采样不再为了一个日志数字隔断 GPU 流水线。这一步做完GPU 利用率已经从 17% 涨到了 24% 左右虽然还是偏低但证明方向完全正确。5.5 改动效果一批来自压测环境的数字所有改动合起来之后压测报告终于变得能看了状态GPU kernel 时间/请求全链路墙钟/请求GPU 利用单 worker QPS初始10ms82ms12%12去掉同步点10ms76ms13%13固定 shape warmup14ms71ms20%14预处理迁移 GPU17ms24ms70%41torch.compile 完整修复11ms15ms73%66从这组数字能看出真正拉高 GPU 利用率的不是某一项魔法改动而是把“GPU 等待”的时间一点一点挤出去预处理挪走CPU 不再塞车shape 固定编译器不再迷路同步点删掉流水线终于连续最后 torch.compile 把碎 kernel 打包让 SM 真正忙起来。每一步单独看效果都不算巨大合在一起才是数量级的差异。6. 复盘别让性能幻觉陪你到上线前夜6.1 单测正常不等于链路正常我们白天测的是“模型 forward 单测”绕过了预处理、绕过了真实并发、绕过了监控打点所以一切都好。上线前的压测应该直接从 API 入口灌流量用真实请求链路走完全程压测期间持续记录 CPU 占用、GPU 占用、显存带宽、QPS 和延迟分位数。只要链路里有一段在 CPU 上拖后腿单测模型再快也白搭。6.2 最值钱的排查工具是那个随机 tensor 脚本这段经验里我收获最大的是“直喂随机 tensor”这个小脚本。它从一开始就明确告诉我们模型 forward 内部有问题逼着排查往 profiler 方向走而不是在数据管道里空转。后来我把这个脚本固定在了团队的压测工具里每次上线前先跑一遍直喂 tensor 的利用率都上不去那模型执行方式一定有问题不用等全链路压测再爆雷。6.3 不要只盯 GPU util 一个指标最后提醒一句GPU util 必须和显存带宽、功耗、CPU 占用放在一起看。显存带宽打满而 util 低说明模型属于 memory-bound优化计算逻辑会事倍功半功耗极低而 util 低说明 GPU 在等指令CPU 爆满而 GPU 空闲说明瓶颈在供给端。分清这几种情况排查方向才不会跑偏。如果让我说句实在话那个直喂随机 tensor 的脚本再加上 profiler 的 kernel 空隙统计基本能解决八成“GPU 利用率低”的疑难杂症。剩下的两成大概率是你在某个角落还留着一句 .item()。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑