首页 / 资讯中心 / 文章详情

GPU利用率从12%到57%,上线前夜推理性能优化全记录

GPU利用率从12%到57%,上线前夜推理性能优化全记录 ★ FEATURED ARTICLE
上线前夜压测报告刚出来监控面板上那个数字让人有点睡不着——GPU利用率只有12%。说实话在部署现场看到这个数字第一反应是“这台机器是不是坏了”第二反应才是“问题到底出在哪”。我在好几个项目里都遇到过类似的场景今天把排查思路和优化过程完整梳理一遍希望能帮到正在为上线前性能数字发愁的人。先把这个场景聊透12%不是训练场景里的低利用率而是推理服务在压测请求下只跑出12%的GPU利用率。这意味着GPU绝大多数时间在“等活干”而线上流量马上要进来如果不处理要么延迟明显变长要么只能临时加机器。无论哪一种都挺肉疼。这篇文章就从“12%这个数字怎么来的”讲起按排查顺序一步步拆最后给出一套可以照着用的优化路径。1. 看懂12%GPU利用率这个数字的含金量1.1 别把监控面板上的百分比当成算力占用率很多人看到12%的第一反应是“GPU性能只发挥了一成”。这话不够准确因为监控工具报的“利用率”根本不是这个含义。拿最常用的nvidia-smi来说它的GPU-Util统计的是采样周期内GPU上处理器引擎处于忙碌状态的时间占比。注意是“时间占比”不是“算力被用掉了多少”。打个比方这就像出租车上的计价器只要车在动就开始跳表哪怕车上没客人、没拉货计价器也在走。GPU利用率反映的是“它有没有在转”而不是“每个计算单元被数据喂得有多满”。如果一个任务里全是小kernel、短计算中间穿插大量等待GPU核心也会因为“正在执行”而显示某个利用率但这个利用率离真实的计算强度差得很远。所以12%不一定说明机器有问题更多是说明它在大部分时间里处于一种“启动一下、然后歇着”的状态。1.2 训练和推理是两种完全不同的GPU工况在训练场景里跑一个大batch的数据数据搬运和计算的比例相对合理GPU经常能稳定跑到80%以上。但推理是完全另一套体系一个请求从进来到返回模型计算可能只占几十毫秒前面要解码、缩放、归一化后面要后处理、序列化CPU侧有一堆活要干。请求到达还有峰谷波动batch又通常很小GPU自然会在很多时间里处于“无数据可算”的闲等状态。所以上线前夜看到12%先别急着怀疑机器或框架得先确认一个前提压测是否真的把压力打到了GPU面前。如果压测脚本本身每秒只发几个请求那GPU当然闲这个数字没有参考价值。我见过有人拿着每秒10个请求的压测数据来报GPU利用率低属于自己把自己吓到了。先把QPS和并发抬到合理水位再看利用率数字才有讨论意义。1.3 推理场景里12%到底意味着什么推理场景的低利用率不光是性能问题也是成本问题。假设一台GPU机器按单位时间计成本12%利用率意味着每8.3分钟里只有1分钟在“算钱”剩下的时间钱照付、机器空转。延迟侧也难受GPU没被喂满请求其实在CPU侧排队积压整体延迟高得不正常。我一般把推理场景的GPU利用率分成三档20%以下说明主要瓶颈在GPU外部CPU预处理、数据搬运、调度占了绝对主导20%~50%说明GPU有一定参与度但流水线里还有很多缝隙50%以上说明大体健康后面再优化是锦上添花。12%这个位置几乎可以直接判断为“GPU被饿着”而不是“GPU算不动”。2. 先定位瓶颈12%背后问题往往不在GPU2.1 第一板斧给一个完整请求画出时间线不要一上来就盯着nvidia-smi刷新那只能让你知道“它很低”不能告诉你“它为什么低”。正确做法是先画一条完整请求的时间线把从进来到返回的每一段耗时拆开。我手头某个模拟项目X是个目标检测推理Demo单请求总耗时大约420毫秒。拆开来看大概是这个样子HTTP接收与反序列化约25ms图像解码、缩放、归一化约130msCPU到GPU的拷贝约15ms模型推理kernel约45msGPU到CPU的拷贝约10msNMS等后处理约140ms序列化返回约55ms这个结构很有意思GPU侧真正干活的时间加到一起也就70ms左右其中推理45ms、两次拷贝25ms。就算GPU侧优化到无缝衔接利用率上限也只有16%左右和12%并没有本质区别。换句话说问题的主战场根本不在GPU而在CPU侧那270多毫秒的预处理和后处理里。2.2 用profiler验证猜测不要凭印象调优时间线是从业务日志里估算的更严谨的确认方式是用profiler。我常用的流程是跑一小段有代表性的压测同时开profiling记录然后打开时间轴看GPU活动条和CPU活动条之间的缝隙。如果GPU kernel之间出现几十毫秒的空闲那基本就是数据没准备好如果kernel内部SM占用率低才需要进一步看算子实现本身。工具方面常用的有Nsight Systems、Nsight Compute如果模型跑在PyTorch上可以用自带profiler粗略看CPU time和CUDA time各占多少ONNX Runtime有自带的profiling开关TensorRT场景下用Nsight Systems也够。不需要一开始就全开那次压测里我只开了一轮短记录就基本锁定了CPU预处理这个嫌疑对象。开完再把工具关掉不要让它一直干扰线上服务。2.3 四个最常见的“GPU饿死”原因画完时间线、看完profile之后低利用率基本逃不出下面这四种情况CPU预处理太慢GPU一直在等原料。比如图像解码、缩放、归一化这些操作全在请求线程里串行做。数据拷贝是同步的GPU在H2D和D2H时被卡住。H2D指主机内存到显存D2H是反方向这两个方向如果直接同步执行GPU只能等。请求到达碎、batch太小。每次只来一两个请求每个kernel的启动开销被反复支付GPU的有效计算时间被稀释。框架调度或锁竞争造成大量等待。Python默认多线程受GIL影响CPU侧处理请求时互相排队GPU在一边干等。还有一个容易忽略的显存碎片多了以后新请求要重新申请大块显存也会在时间轴上形成小空洞。先按这四类去排查基本能覆盖大多数低利用率场景。3. 快速拉高GPU利用率我验证过的一套组合拳3.1 把CPU预处理改成流水线让GPU不断粮最立竿见影的一步是把预处理从请求线程里拆出去改成独立流水线。之前那个模拟项目X原本是图像解码、缩放、归一化全部在请求到达后的同一线程里做GPU只能干等。改成生产者-消费者结构之后情况就不一样了一个队列专门收原始数据几个CPU worker专门做预处理预处理完的结果进另一个队列推理线程从队列里拿tensor再提交给GPU。串行变并行GPU不用再等CPU慢慢磨一有数据就能开算。这里有个细节Python环境里做CPU密集的预处理尽量用多进程而不是多线程因为解码和数值计算这类操作受GIL影响多线程经常白忙。结构上类似这样# 简化示意生产/消费流水线 request_queue Queue(maxsize64) tensor_queue Queue(maxsize16) def preprocess_worker(): while True: raw request_queue.get() tensor decode_and_normalize(raw) # CPU侧预处理 tensor_queue.put(tensor) def inference_worker(): while True: tensor tensor_queue.get() result model(tensor.cuda(non_blockingTrue)) # ...我自己把这个方案落地后单请求耗时从420ms降到了220ms左右GPU利用率从12%爬升到25%上下。如果再叠加异步数据传输和pin_memory还能再挤出一部分等待时间。注意一个细节non_blockingTrue只有在输入tensor来自pinned memory时才真正异步否则还是会同步拷贝。pinned memory是固定在主机内存里的缓冲数据拷贝不需要经过临时页中转配置时多两步但H2D时间能明显缩短。3.2 把碎片化请求粘成batch摊薄启动开销批处理是另一个收益非常大的优化点。单个请求时kernel启动、框架调度这些固定开销全摊在一个小计算上GPU自然闲。把请求按时间窗口聚合比如每10ms或攒够16个请求再提交一次计算端就变成了批量矩阵乘并行度明显提升。具体调参时盯两个参数max_batch_size和max_delay。窗口越长、batch越大GPU利用率越高但单个请求的等待时间也越长。所以不能拍脑袋定得拿真实流量模型来试。我一般先给max_delay设置一个对延迟影响很小的值比如5ms或10ms然后逐步增大batch同时观察P95延迟和GPU利用率的变化。batch从1提到4通常比从4提到16带来的提升更明显因为小batch时启动开销占主导往前几步最划算。如果场景再往前一步走到大模型推理还可以考虑连续批处理不等一个batch整体结束而是边计算边让新请求插进来接着跑。这个对吞吐的提升非常可观但配置复杂度也上了个台阶。中小模型阶段把动态batch加上去已经能解决大部分低利用率问题。3.3 在框架层面再压榨一把编译优化与精度校准优化到一定程度就该检查模型本身在GPU上跑得够不够“省”。同一个模型从训练框架直接搬上线和经过推理引擎转换后的版本差别可以非常大。算子融合会把多个小kernel合并成一个省掉中间多次读写常量折叠提前算掉固定量低精度推理让显存占用降下来。显存降下来意味着同一块卡能放更大的batch利用率自然往上走。FP16基本无感INT8就要谨慎。INT8的校准集要尽量覆盖真实数据分布不能拿几张图随便校准否则精度抖动上线后非常难查。上限前夜如果时间紧我建议先FP16试水再考虑INT8。另外一个小技巧是如果推理框架支持CUDA Graph开启后能减少kernel启动开销。固定shape的模型收益明显但动态shape和graph匹配经常冲突处理不好会偶发报错必须通过压测覆盖验证。3.4 服务层配置让GPU“永远有活可干”服务层配置的目的是让GPU随时有请求可算。这里有几件事值得检查并发队列要够长。接入层如果限制了并发数压力根本传不到GPU。模型常驻显存。上线前把模型加载、算子编译都跑完服务起来后不要反复加载释放。后端进程数与CPU核数匹配。CPU核不够预处理会排队GPU又会断粮。设置合理的超时和熔断。遇到慢请求宁可快速失败不要让队列被慢请求占满。上线前做一次预热。服务启动后用代表性请求跑几十遍把缓存、CUDA context、图优化都准备好再开始正式压测。这些全是配置层面的事但它们共同决定了GPU能不能被“喂饱”。很多时候GPU利用率低不是优化不够而是压根没把饭送到它嘴边。4. 上线前夜排障实录从12%到57%的全过程4.1 一个典型的上线前压测问题是怎么被解决的把前面的步骤串起来看一遍完整排障过程。某次上线前压测模拟项目X的推理服务压到目标QPS后GPU利用率只有12%。第一反应是机器有问题换卡测试没有变化。接着用Nsight Systems跑了一轮发现GPU侧实际干活只有70ms左右而整个请求420msCPU侧的预处理和后处理占了大头。确定CPU瓶颈后把预处理改成多进程流水线利用率升到25%。然后把固定batch1改成动态batch窗口设10ms利用率升到40%。这时又发现显存占用持续上涨排查下来是每次请求都在重复创建CUDA context和显存分配改成常驻context和显存池后稳定住了。最后把模型切成FP16版本再压测利用率停在57%左右P95延迟也满足上线预期。这个过程中每个阶段都重新跑了一次时间线分析因为瓶颈是会转移的拆掉预处理后处理就变成大头改好批处理序列化又可能成为下一个等待点。不重新看数据很容易在旧瓶颈上反复做无用功。4.2 问题速查表看到不同症状先查什么现象常见原因优先排查方向GPU利用率低、CPU占用高CPU预处理、后处理或数据拷贝过重查看top/htopprofile CPU热点函数GPU利用率低、CPU也不忙请求量不足、并发上限过小、batch过小检查压测QPS与并发调大batch/窗口GPU利用率低、显存持续上涨模型重复加载、context泄漏、显存碎片查看进程显存与上下文数量启用显存池GPU利用率低、波形每隔一段出现尖峰周期性任务、模型重载、预热不彻底拉长时间看监控波形排查后台任务这张表不是万能药但能让你在凌晨上线前少走很多弯路。先判断瓶颈在哪一侧再决定动哪里比直接改模型改框架要高效得多。4.3 最常用的排查命令与工具排查GPU问题离不开基础命令和工具。我常用的几条# 实时刷新GPU状态每秒一次 nvidia-smi -l 1 # 更紧凑的GPU监控只关注计算和利用率 nvidia-smi dmon -s p -u -c 100 # 抓取一次短任务的Profiling数据 nsys profile --tracecuda,nvtx,osrt -o bench_profile python bench.py # 对kernel做细粒度分析 ncu --set full --launch-count 3 python bench.py注意这些工具都有额外开销不要在正式压测全程打开。用短任务抓一次分析完就关掉换成常规监控。CPU侧热点可以用perf或者火焰图工具看很多服务端线程卡顿问题火焰图一出就能直接定位到具体函数。4.4 几个容易被忽略的隐藏坑第一个坑监控面板的数字可能是均值。有些监控系统按1分钟取平均短时尖峰完全被抹平看起来一直都是低利用率。压测时我习惯用1秒采样窗口看瞬时波形保留原始数据别让面板帮你做二次加工。第二个坑并发不是越大越好。我见过一次从32并发调回8并发后GPU利用率反而上去了。原因是并发太高时CPU侧被调度和锁拖垮请求全在排队GPU反而吃不到数据。所以调整并发时要同时看CPU侧的负载和GPU侧的到达情况不能只看请求量。第三个坑优化要一轮一轮做不要死磕一个点。预处理好之后瓶颈可能转到后处理后处理解决后可能又轮到序列化每一轮都要重新看时间线、重新分阶段排查。最后分享一个小技巧上线前夜如果时间紧优先做三件事——预处理流水线、动态batch、FP16。这个组合在模拟项目X里被验证过多次步骤不复杂性价比很高通常能把利用率从10%~15%拉到40%以上。等到第二天上线后再慢慢把INT8校准、CUDA Graph这些精细优化补上也不迟。
阅读完成 · 觉得有帮助?
咨询建站