最开始碰到这个问题,是在维护某个图像分类推理服务的时候。当时一台配了两张卡的机器,nvidia-smi里 GPU-Util 常年只有 20% 到 30%,偶发还能看到0%。按多数人的第一反应,这属于典型"算力不够用",该加卡了。可把卡的规格从单卡换到更高一级之后,利用率没升多少,账单倒是漂亮地翻了一倍。
后来我把整个请求链路从数据读取、预处理、模型推理到后处理全部打点,才明白 GPU 利用率低的根子压根不在 GPU 上——它大多数时间不是在"算得慢",而是在"等数据""等调度""等拷贝"。这篇文章就围绕 AI 模型推理场景里的 GPU 利用率提升,把完整的排查思路、改造手段和踩坑经历拆开讲清楚。适合正在维护推理服务、对显存和算力利用率不满意的工程师参考;如果你是刚入门的小白,也希望你能从这套方法论里找到诊断问题的节奏。
1. 排查问题根源:GPU 利用率低往往不是 GPU 的错
1.1 一张 nvidia-smi 截图能告诉我们什么
很多团队对 GPU 利用率的理解就停留在nvidia-smi那一列GPU-Util上。实际上这个数字的含义很窄:它表示采样周期内 SM(流式多处理器)上有活跃 warp 的时间占比,是一个忙碌度指标,而不是"算力被用得多充分"的指标。换句话说,GPU-Util 99%只能说明 SM 在"忙",但它可能忙在一堆低计算强度的内存拷贝、元素级算子、甚至空转等待上——这是我们后面会展开的"伪峰值"陷阱。
建议的第一步,是把信息看全一点。用nvidia-smi dmon -s u -c 100或者nvidia-smi -l 1连续采几分钟,盯着这些维度看:
- SM 利用率:是否稳定在合理区间,还是像锯齿一样上蹿下跳;
- 显存带宽利用率 / 内存使用:带宽打满往往意味着访存密集型算子太多;
- 功耗和频率:功耗很低但利用率很高,说明算力没真正用在刀刃上;
- 时钟降频原因(clocks throttling reason):温度或功耗墙导致降频,也会白瞎利用率。
如果你只拿一张截图做判断,大概率会得出错误结论。我们当时就是靠dmon连续采样发现,GPU 每隔几百毫秒就有一段明显的空闲谷底,而 CPU 侧却一直在满负荷跑:这已经隐约指向了"GPU 在等 CPU"这条线索。
1.2 用时间线 Profiling 定位"等"在哪一环
要真正说清楚 GPU 在等谁,必须做端到端的时间线剖析。我的惯用工具是 NVIDIA 的 Nsight Systems(命令行是nsys),它能把 CPU 的函数调用、GPU 的 kernel 执行、CUDA 的拷贝活动画在一条时间轴上,一眼看出谁在等谁。
nsys profile --trace=cuda,nvtx,osrt -o trace_output ./run_inference.sh nsys stats --report cuda_gpu_trace trace_output.qdrep拿到时间线之后,我一般按下面三种典型模式分类:
| 时间线特征 | 可能的瓶颈 | 优先处理方向 |
|---|---|---|
| GPU 长期空闲,CPU 侧预处理/数据加载占满 | 数据管线喂不上来 | 数据加载、预处理、解码优化 |
| CPU 空闲,但 GPU 两个 kernel 之间缝隙很大 | 内核启动开销、框架调度延迟 | CUDA Graph、算子融合、减少 launch 次数 |
| GPU 连续跑大量短小 kernel,SM 却不算忙 | 算子颗粒度过小、融合不足 | 算子融合、更激进的推理后端(如 TensorRT) |
我们当时的结果特别典型:一个请求里 GPU 推理本身只要 3 到 4 毫秒,但 CPU 侧的图像解码和缩放平均吃掉 18 毫秒,数据拷贝还要再花 3 毫秒。GPU 在大部分时间里是饿着肚子等饭的。弄清楚这一点之后,后面的优化方向就极度清晰了——先把数据喂饱,再谈模型本身提速。
2. 数据管线与批处理改造:把 GPU 的嘴喂满
2.1 把预处理从 CPU 挪到 GPU
如果 profiling 结论是"CPU 预处理拖后腿",最直接的改造就是给数据管线动手术。以 PyTorch 服务为例,最省事的一步是调大DataLoader的num_workers,同时把耗时操作从同步路径挪走。但仅仅是多进程还不够,因为解码和缩放依旧吃 CPU。
我们当时做了三层改造:
- 换解码器:把 PIL 的 JPEG 解码换成 libjpeg-turbo,吞吐立刻上了一个台阶;
- 静态操作离线化:凡是"所有样本都要做且参数固定"的变换(比如固定尺寸的 Resize、固定均值和方差的 Normalize),直接在数据集预处理阶段算完缓存,线上不再重复计算;
- 动态变换上 GPU:torchvision 新版本的 transforms 支持在 GPU 上执行,像
torchvision.transforms.v2.Resize、ToDtype这类算子可以直接放到 GPU 流里,和模型推理共用显存带宽。
# 思路示例:GPU 侧 transform 管线 from torchvision.transforms import v2 transforms = v2.Compose([ v2.Resize((224, 224), antialias=True), v2.ToDtype(torch.float32, scale=True), v2.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]).cuda()但要注意一个边界:不是所有预处理都适合无脑塞进 GPU。遇到逐样本差异很大的操作(比如按检测框动态裁剪、随机增强),GPU 端做反而会因为不规则分支损失效率。我的经验是"粗筛留在 CPU,细变换放到 GPU",两者各干各擅长的活。做完这一步,我们单请求的 CPU 预处理耗时从 18 毫秒压到 2 毫秒以内,GPU 的空闲陡坡明显少了一大截。
2.2 锁页内存与异步拷贝:消灭 H2D 间隙
预处理减少之后,下一个被放大的瓶颈通常是 Host 到 Device 的拷贝(H2D)。这里有一个基础但极其关键的点——用锁页内存(pinned memory)配合非阻塞拷贝。
普通内存(pageable)是操作系统可分页管理的内存,GPU 想要数据得先由驱动把它搬到一块临时的锁页 staging buffer,再通过 DMA 进显存,多一次搬运就多一份延迟。而锁页内存直接用cudaHostAlloc或cudaMallocHost分配,不会被换页,DMA 可以直接运作,省掉中间那道弯。
实际配置上有两种场景:
- PyTorch 侧:
DataLoader(..., pin_memory=True)即可,代价是多占一块主机内存; - 自研 C++ 推理服务:用
cudaHostAlloc分配 input/output buffer,再在独立 CUDA stream 上发cudaMemcpyAsync,让拷贝和后续 kernel 执行重叠。
我们测试中的一个典型数字:H2D 拷贝从 3.2 毫秒降到 1.1 毫秒,而且因为拷贝走的是独立流,它和算子执行的耗时能重叠起来。对尾部延迟和吞吐都是实打实的帮助。唯一要提醒的是锁页内存别一口气分太多,因为锁页内存不可换页,分多了会挤压系统可用内存,导致整机不稳定。
2.3 动态批处理窗口:吞吐与延迟的平衡艺术
在在线推理场景里,请求是按不定时间到达的。静态 batch 特别蠢:batch 设 32,来 10 个请求得空等剩下 22 个;来 100 个请求又得在后面排队。于是"动态批处理"就成了服务端推理的标准解法。
它的原理不复杂:一个收集线程先攒请求,在时间窗口内攒到max_batch_size就立刻送进 GPU,如果窗口超时还没攒满,有多少送多少。这样既保证 GPU 每次吃的都是"大份饭",又不至于让请求无限等下去。窗口大小是关键超参,我给一组改造过程中的实测倾向供参考:
| 窗口大小 | 吞吐表现 | 延迟表现 | 评价 |
|---|---|---|---|
| 4ms | 一般,接近小 batch | P99 低,响应快 | 没充分利用批处理 |
| 12ms | 较好,吞吐显著上升 | P99 略涨,可接受 | 多数服务的甜点区 |
| 32ms | 很好,GPU 持续满载 | P99 崩坏,尾延迟恶化 | 通常不建议直接用 |
我当时先以"期望 P99 的一半"作为窗口起点,再按压测结果小步调整。另外注意 batch 里的 padding 浪费:max_batch_size不必设得很大,因为超过一定规模后,显存和计算增益会被无效 padding 抵消。这个阶段的收益非常直接:GPU 利用率从 30% 出头跳到接近 60%,吞吐几乎翻倍,代价只是延迟微增。
3. 计算图与推理后端重写:挤出核函数之间的空隙
3.1 CUDA Graph 捕获:把上千次 Kernel Launch 变成一次重放
数据喂饱之后,某些固定 shape 的模型还会暴露另一个隐蔽瓶颈——kernel 启动开销。一个中型 CNN 的推理迭代可能包含几百到上千个 kernel,每个 kernel 从 CPU 侧启动大概要花 2 到 5 微秒。假设有 800 个 kernel,光启动开销就 2.4 毫秒起步;如果 GPU 算完一轮只要 5 毫秒,那 CPU 的 launch 速度几乎决定了 GPU 的上限。
CUDA Graph 的解决思路是:先以捕获模式完整跑一遍推理,把整个 kernel 依赖关系"录"成一张图(Graph),之后每次推理只是简单重放这张图,启动开销被压缩到一个很低的水平。PyTorch 2 里可以通过torch.cuda.CUDAGraph手动捕获,或者干脆用torch.compile的图模式让框架自动处理。
不过有两个前提必须满足:
- shape 必须固定,至少是固定到几个 profile 档位;动态 shape 下每次都要重新捕获,得不偿失;
- 逻辑不能带 Python 分支,图捕获要求执行路径稳定。
以我们一个静态 batch 的模型为例,把推理从普通 eager 模式切到 CUDA Graph 后,单轮启动和调度开销从 0.8 毫秒降到约 0.15 毫秒,在短模型上非常可观。如果你的服务要支持动态 batch,不要一开始就上 Graph——先把 batch 挡位固定成几档(比如 1、4、8、16),每个档位分别捕获图,调度器按实际攒到的 batch 大小选图重放。
3.2 算子融合与 TensorRT 后端:减少读写的叠加效应
算子融合是另一种"挤空隙"的手段。以最常见的Conv + BatchNorm + ReLU为例,三个算子如果分开跑,每个算子的输出都要写回显存、下一个算子再从显存读一次。显存读写虽然快,但多几次就非常可观。融合成一个算子后,中间结果只留在寄存器或共享内存里,彻底消掉两轮读写。
手工做融合既费劲又容易出错。生产环境我更推荐走 ONNX -> TensorRT 的路线,让工具的图优化器自动完成融合、精度选择和内存复用。基本流程是:
# 先把 PyTorch 模型导出为 ONNX python export_onnx.py --checkpoint model.pt --output model.onnx # 再用 trtexec 转换,FP16 通常能显著提高吞吐 trtexec --onnx=model.onnx --saveEngine=model.plan \ --fp16 --workspace=4096 \ --minShapes=input:1x3x224x224 \ --optShapes=input:8x3x224x224 \ --maxShapes=input:16x3x224x224实测中,同样的模型经过 TensorRT 转换后,kernel 数量从 420 个降到 117 个,端到端推理耗时减少接近四成。但这里有个必须强调的纪律:TensorRT 不是白拿的,转换后一定要做精度验收。我们的做法是拿一批真实业务样本,对比 ONNX Runtime 输出和 TensorRT 输出,用余弦相似度或 top-1 一致率设阈值(一般要求 99.5% 以上)。别拿随机数测,随机输入掩盖问题。
如果团队不想引入额外推理后端,torch.compile也能做一部分算子融合,但通常收益不如 TensorRT 那么彻底,适合当中间过渡步骤。
3.3 混合精度与 TF32:别把 FP16 当成"降一半精度"
A 系列及更新的显卡对 FP16 和 TF32 有专门的 Tensor Core 加速路径,吞吐通常是 FP32 的好几倍。很多人在这一步直接model.half()一把梭,结果遇到精度下降就头大。
这里要分两层说清楚:
- TF32是 Ampere 架构默认用来跑 FP32 计算的模式,能让 FP32 的"壳"吃到 Tensor Core 的加速,精度损失通常非常小;
- FP16把权重和激活都切成半精度,显存占用和带宽要求减半,但如果模型里某些层动态范围很大(比如归一化、softmax、大 logits),就可能溢出或产生不可忽略的误差。
经验做法是:用torch.autocast(device_type='cuda', dtype=torch.float16)做混合精度,让框架自动把安全算子切到 FP16,敏感算子留在 FP32。关键层如果自己心里没底,就单独捞出来用 FP32 跑,别让它拖累整体。
这类收益依赖模型结构而定。我们同一个模型在 A 卡上测过一组对比:纯 FP32 单请求约 6.2 毫秒,TF32 约 4.5 毫秒,混合精度 FP16 约 3.1 毫秒。但精度验证只在真实业务数据上做才算数——模型评估时用一套离线 golden 集,每次切换精度都跑一遍,把误差指标记录在案。
4. 踩坑实录:显存碎片、伪峰值与尾部延迟
4.1 显存碎片化与间歇性超时
优化上线后我们遇到过一件诡异的事:整体吞吐上去了,但日志里偶发出现某个请求耗时从 5 毫秒跳到 400 毫秒的尖峰。最初以为是网络抖动,后来抓调用栈发现,耗时几乎全花在cudaMalloc上——动态批处理让显存 buffer 频繁地按不同大小分配和释放,显存池里挖出连续大块内存越来越难,分配一次要几百毫秒。
PyTorch 的缓存分配器在大多数场景会自动复用显存,但踩过坑之后才知道还需要调。环境变量里可以这样控制:
export PYTORCH_CUDA_ALLOC_CONF="max_split_size_mb:256,expandable_segments:True"max_split_size_mb控制缓存块可以拆分的大小,防止大块被小请求反复切碎;expandable_segments让显存段按需扩展。自研 C++ 服务没有这些现成机制,更稳妥的做法是预分配固定大小的批处理 buffer 并循环复用,彻底消灭运行期高频分配。
复盘时我们的结论是:优化不能只盯着 kernel 跑得快不快,内存分配这种"基建"一慢,前面所有成果都会被一次性吞掉。
4.2 "利用率 99%" 的假象:SM 忙碌不等于算力打满
另一个很有迷惑性的坑,是某次压测时nvidia-smi显示 GPU-Util 顶到了 99%,团队一度以为优化到头了。直到我们用 Nsight Compute 看 kernel 级别的分析才发现,SM 里绝大部分时间在执行的是低计算强度的访存类指令和空转等待,真正的 Tensor Core 运算单元忙绿率不到 20%。
说白了,GPU 的"忙碌"分很多种:有的算子访存密集但算术稀疏,比如 ReLU、归一化、拼接,SM 虽然被占用,算力却远没打满。这就是典型的伪峰值。应对方式就是我们前面说的算子融合和 TensorRT——把一堆轻量级算子黏合起来,减少 SM 空转频率。
从那以后,我们团队定了一条规矩:监控里只贴 GPU-Util 不算完,必须附带 SM busy reason、Tensor Core 利用率和显存带宽利用率。看问题不能只看一个数字一个维度。
4.3 动态批处理的尾部延迟失控
动态批处理把窗口调大到 32ms 之后,吞吐数据非常好看,GPU 几乎满载,但客户端开始频繁报警,P99 延迟从 27 毫秒飙升到 57 毫秒。原因很简单:窗口越长,攒批引入的排队等待就越久,特别是请求到达不均匀时,第一批请求可能在队列里干等整整一个窗口。
解决办法不是取消批处理,而是给它加上"紧急逃生门"。我们在批处理收集器里加了两个控制参数:一是单请求最大等待时间,超过阈值立即强制出队;二是紧急出队阈值,比如窗口刚过一半但队列里有请求已经等了较久,就提前送走。调整后 P99 回落到 31 毫秒,吞吐只损失约 6%。
这里其实是个工程取舍问题:在线服务先满足延迟约束,再谈吞吐。窗口大小永远要和业务 SLO 绑在一起调,不能脱离延迟目标单独追求 GPU 利用率。
5. 优化效果的验证与日常观测:让数据告诉你还缺什么
5.1 关键指标拆解:从"利用率"到"吞吐量与延迟"
经过前面几轮改造,整套服务的状态已经很难用一个数字概括。我们后来固定了一套指标清单,每次优化前后都对照记录:
| 指标 | 说明 | 优化前 | 优化后 |
|---|---|---|---|
| SM 利用率 | GPU 计算单元忙碌比例均值 | 28% | 71% |
| 显存带宽利用率 | DRAM 读写带宽占峰值比例 | 35% | 58% |
| 端到端吞吐 | 每秒成功处理请求数 | 215 req/s | 372 req/s |
| P99 延迟 | 99% 请求的延迟上限 | 45ms | 31ms |
| H2D 拷贝时间占比 | 数据拷贝在单请求耗时中的占比 | 12% | 4% |
| CPU 峰值使用率 | 单机 CPU 在压测期间的峰值 | 几乎打满 | 低于 60% |
表格里这些数字只是某一轮改造的记录,不是标准答案,真正重要的是理解它们之间的关系:SM 利用率上去了,但如果显存带宽利用率跟着顶满,说明模型访存密集,下一步该做融合或量化;P99 降下来了但吞吐没涨,说明单请求通路没问题,新的短板可能在服务端并发模型上。
另外一个很多人忽略的动作,是上线前先把优化目标量化成一句话。比如"在 P99 不超过 35ms 的前提下,把单卡吞吐从 215 提到 320 req/s"。有了这个约束,后面每一步取舍都有依据,不会为了利用率好看而牺牲业务体验。
5.2 压测与长期监控:别只信一小时的活数据
性能优化最怕"上线一时爽,回归火葬场"。团队后来把优化验证变成一条固定的例行流水线:
- 用真实业务样本构造压测数据集,注意混合不同尺寸、不同输入类型的请求,别只喂单一形状;
- 压测工具固定下来,我们用的是一套基于 gRPC 的自研压测脚本,按目标 QPS 打满 15 分钟以上,记录 P50 / P99 / 最大延迟;
- 每次代码或配置变更,先把同样的压测跑一遍,再和基线对比,把结果提交到内部记录里;
- 线上部署后用 DCGM 采集 GPU 的 SM 利用率、显存带宽、功耗、温度和降频原因,接到 Prometheus + Grafana,长期盯着有没有"偷偷回归"。
这里想特别提醒一件事:模型版本迭代也可能悄悄改变算子结构,同一个推理服务换了模型权重甚至换了模型结构,之前的优化参数可能全部失效。所以压测流水线不只是给"优化"服务的,它更是给日常迭代服务的。任何一次模型上线,都应该顺手把 GPU 侧指标跑一遍。
关于降频还有一个隐藏知识点:如果散热和供电跟不上,GPU 会在高利用率下自动降频,此时利用率可能依然是 90% 以上,但单位时间的实际计算量反而少了。监控里加一个"时钟降频原因"字段,能帮你区分"算力不够"还是"散热不够"。
6. 最后一点体会
如果只让我总结一句,那就是:GPU 利用率提升这件事,顺序比努力重要。先画时间线定位瓶颈,再喂数据,最后才改计算图——我们最开始如果直接上 TensorRT 和 CUDA Graph,可能只拿到百分之十几的收益,因为真正的瓶颈在 CPU 喂数据那条路上,动 GPU 端的优化都属于南辕北辙。
另外还想分享一个小技巧:把第一次 profiling 的完整时间线截图和各类指标基线留下来。几个月后当你怀疑"服务是不是变慢了"的时候,翻出这些旧记录做对比,远比临时拍脑袋猜要高效得多。优化方案本身没什么神秘的,难的是过程中诚实地面对数据,一步一步把真正的瓶颈挖出来,再对症下药。