☰
GPU利用率从12%到73%:推理服务性能压测优化复盘
2026/10/22 5:35:04 网站建设 项目流程

凌晨一点半,距离上线窗口只剩五个小时。压测程序跑完第二轮,我盯着 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, device="cuda") 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(f"avg: {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 平均耗时 58ms,GPU forward 只有 10ms。

58ms 对 10ms,GPU 当然吃不饱——它每干一个 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 抓了一轮 batch=8 的 forward,记录 CPU 侧和 CUDA 侧的全部活动:

from torch.profiler import profile, ProfilerActivity with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True, ) as prof: for _ in range(50): out = model(dummy) print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))

关键读数整理成一张表,看得非常清楚:

观察项数值说明
GPU kernel 有效执行时间合计约 1.3msSM 真正在跑业务计算的时间
kernel 之间的空隙约 8.8msGPU 等 CPU 发布下一个 kernel
CUDA 同步等待约 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 还没发完当前 kernel,GPU 已经把上一批算完了,流水线能自然叠起来。到了 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,把确定性还给编译器

推理服务改成固定 batch=8,不满足 8 张时用 padding 补齐,forward 输出后再切掉 padding。这一步配合一轮 warmup,让 cuDNN 的算法选择和 kernel 缓存都稳定下来:

model = create_model().cuda().eval() dummy = torch.randn(8, 3, 224, 224, device="cuda") 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, mode="reduce-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%41
torch.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()。

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

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

立即咨询