资讯动态

GPU利用率仅12%?推理服务性能瓶颈定位与优化实战

发布时间:2026/10/16 18:18:53 来源:尧图企业网站定制
1. 一个让所有搞推理部署的人都后背发凉的场景“上线前夜GPU只跑了12%”——这句话我第一次看到的时候脑子里立刻浮现出几年前自己经历过的一个夜晚。模型训练完了权重文件躺在磁盘上接口写好了压力测试脚本也跑通了一切看起来都很美好。结果上线前的最后一次全链路压测监控面板上GPU利用率那条线像一条死掉的心电图稳稳地趴在12%左右。QPS上不去延迟下不来老板在群里问“明天能不能上”你盯着屏幕手心全是汗。这个场景的核心其实是一个推理服务性能瓶颈定位的问题。它涉及的技术点包括GPU利用率监控、CPU与GPU之间的任务调度、数据预处理流水线、批处理策略、显存与计算资源的匹配、以及推理框架的底层执行机制。适合所有正在做模型部署、推理优化、服务上线的工程师阅读不管你是刚接触推理服务的新手还是已经调过几次参数的老手这个12%背后的东西都值得从头到尾捋一遍。我先说结论GPU只跑12%绝大多数情况下不是GPU本身的问题而是喂给GPU的数据不够快或者GPU在等CPU。这个道理就像你开了一家餐厅后厨的灶台火力全开能同时炒20份菜但传菜口一次只能递进去2份那灶台再猛也只能闲着。GPU就是那个灶台12%的利用率意味着它大部分时间在等活干。下面我按照自己实际排查这类问题的顺序把整个思路、工具、参数、坑点全部拆开讲。文章会比较长但每一段都是能直接拿去用的东西。2. 先搞清楚GPU利用率到底在说什么2.1 12%这个数字是从哪来的很多人看到GPU利用率第一反应是nvidia-smi里那个百分比。但这里有个巨大的坑nvidia-smi显示的GPU利用率定义是“在过去一个采样周期内GPU上有至少一个kernel在执行的时间占比”。注意它衡量的是时间占比不是计算单元占用率。这意味着什么呢假设你的模型推理一次需要执行10个kernel每个kernel执行1毫秒但kernel之间有大量的空隙GPU在等数据、等指令、等同步那么即使这10个kernel把SM流多处理器跑满了只要空隙足够大nvidia-smi显示的利用率依然会很低。所以12%可能对应两种完全不同的情况情况AGPU确实很闲大部分时间没有kernel在执行说明任务根本没喂上来。情况BGPU一直在执行kernel但每个kernel都很小执行时间极短采样周期内大部分时间在等下一个kernel。这两种情况的排查方向完全不同。情况A要去看数据流水线和CPU调度情况B要去看kernel融合和框架开销。2.2 更准确的指标SM占用率与显存带宽nvidia-smi的利用率只能作为第一层线索。真正要定位问题需要看更细的指标。如果你用的是较新的驱动和工具链可以用nvidia-smi dmon或者nvidia-smi pmon来看更细粒度的数据。更专业一点用Nsight Systems或者Nsight Compute做profiling能看到每个kernel的执行时间、SM占用率、显存读写带宽。我自己的经验是先看三个指标指标含义12%场景下的典型表现GPU Utilizationkernel执行时间占比低说明有空隙SM Occupancy活跃warp占最大warp的比例可能高也可能低Memory Throughput显存带宽利用率通常也低说明数据没喂满如果三个都低那基本可以确定是数据供给不足。如果GPU Utilization低但SM Occupancy高那可能是kernel太小、启动开销太大。如果Memory Throughput高但GPU Utilization低那可能是显存带宽瓶颈。注意不同版本的驱动和不同型号的GPUnvidia-smi的利用率计算方式可能有细微差异。不要只依赖这一个数字做判断一定要结合多个指标交叉验证。2.3 为什么12%这个数字特别危险12%不是一个随机的低值。如果GPU利用率是0%那说明任务根本没跑起来反而好排查。如果是50%说明有瓶颈但还能忍。12%意味着系统在正常运转但效率极低这种问题最隐蔽因为它不会报错不会崩溃只是慢。而且上线前夜这个时间点特别要命。你没有时间做大重构只能做针对性的优化。所以排查顺序必须是先找最大瓶颈用最小改动换取最大收益。3. 从CPU到GPU数据流水线的全链路拆解3.1 推理服务的典型执行流程一个标准的推理服务从收到请求到返回结果大致经过这些环节网络接收请求反序列化输入数据。数据预处理解码、缩放、归一化、格式转换。组批把多个请求合并成一个batch。数据传输把batch从CPU内存拷贝到GPU显存。模型前向计算GPU执行kernel。输出传输把结果从GPU显存拷贝回CPU内存。后处理解码输出、过滤、格式化。返回响应。GPU只跑12%意味着第5步在整体时间线里占比极低。那时间去哪了大概率花在了第2、3、4步以及第6、7步。也就是说CPU侧的预处理和后处理以及数据传输吃掉了大部分时间。3.2 预处理为什么是重灾区很多人写推理服务的时候预处理代码是直接照搬训练时的验证集处理逻辑。训练时你用的是DataLoader有多个worker并行而且不需要考虑延迟。但推理服务是单条或小批量实时处理的预处理逻辑如果写得重CPU根本扛不住。我见过最夸张的一个案例一个图像分类服务预处理里用了PIL做图像解码和缩放单张图预处理耗时30毫秒而模型在GPU上前向只需要5毫秒。结果就是GPU利用率只有百分之十几QPS被预处理卡得死死的。预处理的常见瓶颈包括图像解码用了单线程的库没有利用硬件解码。缩放和归一化用了Python循环没有向量化。数据类型转换频繁在numpy、torch tensor、list之间来回倒腾。没有做预处理和GPU计算的流水线重叠。3.3 数据传输那个容易被忽略的拷贝开销CPU到GPU的数据拷贝走的是PCIe总线。PCIe的带宽是有限的比如PCIe 3.0 x16的理论带宽大约是16GB/s实际有效带宽可能只有10GB/s左右。如果你每次推理都要拷贝大量数据这个拷贝时间会非常可观。更关键的是默认情况下CPU到GPU的拷贝是同步的。也就是说拷贝完成之前GPU不能开始计算CPU也不能继续做下一件事。这就形成了一个串行链条预处理→拷贝→计算→拷贝回→后处理。每一环都在等上一环。要打破这个串行链条就需要用到CUDA流和固定内存。固定内存pinned memory可以让拷贝走DMA不占用CPU而且拷贝速度更快。CUDA流可以让拷贝和计算重叠。这两个东西是推理优化的基本功。3.4 组批策略batch size不是越大越好组批是提高GPU利用率最直接的手段。单条推理的时候GPU可能只用了几个SM大部分计算单元闲着。组批之后一个batch的数据可以填满更多SM利用率自然上去。但batch size不是越大越好。原因有几个显存限制batch太大显存放不下直接OOM。延迟增加batch越大等待组批的时间越长单条请求的延迟越高。尾部效应如果请求量不稳定大batch会导致部分请求等待时间过长。我一般会做一个简单的测算先确定显存能放下的最大batch size然后在这个范围内测试不同batch size下的吞吐和延迟找一个平衡点。通常来说batch size在8到32之间是一个比较安全的起点具体要看模型大小和输入尺寸。4. 实操排查从12%到80%的完整过程4.1 第一步用时间线工具定位瓶颈不要凭感觉猜。第一步永远是拿数据。如果你用的是PyTorch可以用torch.profiler如果是TensorRT可以用Nsight Systems。这里我以PyTorch为例给一个可以直接抄的profiling代码import torch from torch.profiler import profile, record_function, ProfilerActivity model model.cuda().eval() input_tensor torch.randn(1, 3, 224, 224).cuda() with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduletorch.profiler.schedule(wait1, warmup1, active3), on_trace_readytorch.profiler.tensorboard_trace_handler(./log), record_shapesTrue, with_stackTrue ) as prof: for i in range(10): with record_function(preprocess): # 你的预处理逻辑 pass with record_function(forward): model(input_tensor) with record_function(postprocess): # 你的后处理逻辑 pass prof.step()跑完之后用TensorBoard打开./log目录你能看到一条完整的时间线。重点看几个东西CPU上的预处理占了多长时间。GPU上的kernel执行占了多长时间。两者之间有没有重叠。有没有大的空隙。我第一次做这个分析的时候发现预处理占了整个时间线的70%GPU计算只占15%剩下的是数据传输和同步。问题一目了然。4.2 第二步优化预处理把CPU解放出来定位到预处理是瓶颈之后优化方向就很明确了。我按优先级列几个手段手段一用更快的解码库。如果输入是图像不要用PIL用opencv-python的cv2.imdecode或者更好的选择是turbojpeg、nvjpeg如果GPU支持硬件解码。实测下来OpenCV的解码速度通常是PIL的2到3倍。手段二向量化操作。把Python循环改成numpy或torch的向量化操作。比如归一化不要写for循环逐像素减均值除方差直接用(img - mean) / std。手段三把预处理放到GPU上。如果预处理逻辑可以用torch tensor表达那就把它放到GPU上做。比如归一化、resize用torch.nn.functional.interpolate这些操作在GPU上做比CPU快得多而且省掉了CPU到GPU的拷贝。手段四多进程预处理。如果预处理确实很重而且无法向量化那就用多进程。Python的GIL限制了多线程但多进程可以绕过。用一个进程池把预处理分发给多个worker主进程只负责组批和推理。实操心得预处理优化有一个“二八定律”。通常20%的代码改动能带来80%的收益。优先做解码库替换和向量化这两个改动最小收益最大。4.3 第三步让数据传输和计算重叠预处理优化完之后如果GPU利用率还是上不去那就要看数据传输了。核心思路是不要让GPU等数据。具体做法使用固定内存pinned memory。在PyTorch里把输入tensor放到pin_memoryTrue的DataLoader里或者手动调用.pin_memory()。使用CUDA流。创建两个流一个用于拷贝一个用于计算。拷贝流把下一批数据传到GPU的同时计算流在处理当前批。使用torch.cuda.Stream和torch.cuda.stream()上下文管理器来实现重叠。一个简化的重叠示例stream1 torch.cuda.Stream() stream2 torch.cuda.Stream() # 第一批数据 batch1 next(data_iter) with torch.cuda.stream(stream1): batch1_gpu batch1.cuda(non_blockingTrue) for batch in data_iter: # 在stream2上计算上一批 with torch.cuda.stream(stream2): output model(batch1_gpu) # 在stream1上拷贝下一批 with torch.cuda.stream(stream1): batch1_gpu batch.cuda(non_blockingTrue) # 同步 torch.cuda.synchronize()这个模式的核心是让拷贝和计算在不同的流上并行执行。实测下来这个改动通常能把GPU利用率提升20到30个百分点。4.4 第四步调整组批策略和推理引擎如果前两步做完GPU利用率到了50%左右但还是不够理想那就要考虑组批策略和推理引擎了。组批方面可以尝试动态组批。不是固定batch size而是根据请求到达的速度动态调整。比如设置一个最大batch size和一个最大等待时间哪个先到就触发一次推理。这样既能提高GPU利用率又不会让延迟失控。推理引擎方面如果用的是原生PyTorch可以考虑切换到TensorRT、ONNX Runtime或者TorchScript。这些引擎会做算子融合、kernel优化、精度校准能显著减少kernel数量和启动开销。我做过一个对比同一个模型PyTorch原生推理的GPU利用率是35%切到TensorRT之后到了75%QPS翻了将近一倍。推理引擎GPU利用率QPS延迟PyTorch原生35%12045msTorchScript48%18032msONNX Runtime62%26024msTensorRT75%38018ms这个表格是我在一个图像分类模型上的实测数据输入尺寸224x224batch size 16。不同模型和硬件会有差异但趋势是一致的。5. 那些年我踩过的坑和对应的排查技巧5.1 常见问题速查表现象可能原因排查方法解决手段GPU利用率低CPU利用率高预处理瓶颈top看CPU占用profiler看预处理耗时优化预处理多进程GPU预处理GPU利用率低CPU利用率也低数据传输或同步瓶颈profiler看拷贝和同步耗时固定内存CUDA流异步拷贝GPU利用率低显存占用高batch太大或显存泄漏nvidia-smi看显存检查是否有未释放的tensor减小batch检查引用计数GPU利用率波动大请求量不稳定组批策略不合理看QPS时间序列动态组批调整等待时间GPU利用率低但SM占用高kernel太小启动开销大Nsight Compute看kernel时长算子融合换推理引擎多卡利用率不均负载均衡问题看每张卡的利用率调整请求分发策略5.2 一个特别隐蔽的坑同步点有一次我优化了半天GPU利用率就是上不去。profiler显示kernel执行时间很短但kernel之间有大段空白。后来发现代码里有一个torch.cuda.synchronize()被放在了循环内部每次推理后都强制同步导致CPU和GPU无法重叠。把那个同步去掉之后利用率直接从20%跳到了60%。类似的同步点还包括.item()调用会把GPU tensor的值取回CPU触发同步。.cpu()调用同上。print(tensor)如果tensor在GPU上打印会触发同步。某些日志库在记录tensor时也会触发同步。注意在推理循环里任何需要读取GPU tensor具体数值的操作都会触发同步。尽量把这些操作移到循环外或者用异步方式记录。5.3 另一个坑内存分配器PyTorch的CUDA内存分配器默认是缓存式的。也就是说释放的显存不会立刻还给GPU而是留在缓存里供下次分配。这本身是好事能减少分配开销。但在某些情况下会导致显存碎片化进而影响大batch的分配。如果你发现显存占用很高但实际使用不多可以尝试设置环境变量PYTORCH_CUDA_ALLOC_CONF来调整分配器行为。比如PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128可以限制碎片大小。5.4 上线前夜的应急手段如果你真的到了上线前夜没有时间做大优化这里有几个应急手段临时增加batch size如果显存允许直接把batch size翻倍GPU利用率通常能立刻提升。临时开启多进程用gunicorn或uvicorn的多worker模式每个worker独立处理请求能提高整体吞吐。临时降级预处理如果预处理里有非必要的步骤比如某些数据增强直接去掉。临时切推理引擎如果之前已经导出了ONNX或TensorRT模型直接切过去通常能立刻见效。这些手段都是治标不治本但上线前夜先保证能上后面再慢慢优化。6. 从12%到80%之后还能做什么6.1 持续监控与告警上线不是终点。GPU利用率是一个动态指标会随着请求量、数据分布、模型版本的变化而波动。建议在监控系统里加上GPU利用率的告警比如连续5分钟低于30%就触发告警。这样你能在问题扩大之前发现它。监控的维度建议包括GPU利用率每张卡显存占用SM占用率显存带宽利用率推理延迟P50、P95、P99QPSCPU利用率预处理耗时数据传输耗时6.2 自动扩缩容如果请求量波动很大可以考虑自动扩缩容。低峰期减少实例高峰期增加实例。扩缩容的触发条件可以基于QPS或延迟而不是GPU利用率。因为GPU利用率低不一定意味着需要缩容可能是瓶颈在别处。6.3 模型层面的优化如果推理服务已经优化到瓶颈了可以考虑从模型层面入手量化FP32转FP16或INT8减少计算量和显存占用。剪枝去掉不重要的权重减小模型体积。蒸馏用大模型教小模型保持精度的同时减小体积。算子融合把多个小算子合并成一个大算子减少kernel启动开销。这些手段需要重新训练或转换模型周期较长但收益也更大。6.4 一个我常用的性能基线模板最后分享一个我常用的性能基线模板每次上线前都会跑一遍确保没有明显的性能退化import time import torch import numpy as np def benchmark(model, input_shape, batch_size, num_iterations100, warmup10): model model.cuda().eval() input_tensor torch.randn(batch_size, *input_shape).cuda() # Warmup with torch.no_grad(): for _ in range(warmup): model(input_tensor) torch.cuda.synchronize() start time.time() with torch.no_grad(): for _ in range(num_iterations): model(input_tensor) torch.cuda.synchronize() end time.time() total_time end - start avg_latency total_time / num_iterations * 1000 # ms qps batch_size * num_iterations / total_time print(fBatch Size: {batch_size}) print(fAvg Latency: {avg_latency:.2f} ms) print(fQPS: {qps:.2f}) print(fGPU Utilization: {torch.cuda.utilization()}%) return avg_latency, qps # 测试不同batch size for bs in [1, 4, 8, 16, 32, 64]: try: benchmark(model, (3, 224, 224), bs) except RuntimeError as e: print(fBatch Size {bs} failed: {e}) break这个脚本能快速告诉你在当前硬件和模型下哪个batch size的性价比最高。我一般会把这个脚本放在CI里每次模型更新都跑一遍防止性能退化。7. 一些个人体会GPU利用率低这个问题说到底是一个系统工程问题。它不像是模型精度不够可以通过调参解决也不像是代码报错有明确的堆栈信息。它需要你从整个数据流的角度去思考找到那个最窄的瓶颈。我自己的经验是80%的GPU利用率问题都出在CPU侧。预处理、数据传输、同步点这三个地方是重灾区。把这三个地方优化好大部分场景下GPU利用率都能到60%以上。剩下的20%才需要动推理引擎和模型本身。还有一点不要迷信GPU利用率这个数字。有些场景下GPU利用率低是正常的比如小模型、低延迟要求的场景。强行提高利用率可能会增加延迟得不偿失。优化的目标应该是在满足延迟要求的前提下最大化吞吐而不是单纯追求GPU利用率。最后再分享一个小技巧如果你不确定瓶颈在哪先把batch size调到最大看GPU利用率能到多少。如果调到最大还是低那瓶颈一定在CPU侧或数据传输。如果调到最大利用率上去了但延迟爆炸那瓶颈在组批策略。这个简单的测试能帮你快速缩小排查范围。

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

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

免费获取报价 →
↑