ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GPU利用率只有12%?从同步管线到动态批处理的推理性能优化实战

GPU利用率只有12%?从同步管线到动态批处理的推理性能优化实战 1. 监控大屏上的12%:上线前夜的第一声警报事情发生在某次服务上线前夜。当晚九点启动最后一轮压测,流量已经打到预估峰值的1.2倍,十一点半我抬头看监控面板,GPU利用率只有12%这行数字就挂在最显眼的位置。对跑过推理服务的人来说,这个数字出现在上线前夜,意味着两件事必居其一:要么流量根本没进到GPU,要么显卡在空等某个更慢的环节。第一反应当然是慌,但冷静下来想,低利用率本身不是故障,它只是症状,真正的问题是:瓶颈不在GPU上,而我们还没找到它在哪。当时摆在面前有两条路。第一条是既然压测流量已经是峰值的1.2倍,GPU还有88%的余量,那就带病上阵,明天上线再说。这个判断很有迷惑性,因为表面上看,资源这么富余,上线应该稳了。但稍微多想一步就会冒冷汗:如果GPU只用了12%,说明每个请求的耗时里,GPU真正干活的时间只占很小一块,剩下那88%的时间它都在等别人。这个人可能是CPU、可能是磁盘、可能是网络、可能是锁,等哪天真实流量一冲,GPU不会先崩,撑不住的一定是那个隐形瓶颈。第二条路就是连夜排查、修完再上。我们最终选了这条,理由很直接:压测的目的不是证明服务能不能通,而是证明服务在目标流量下能不能稳。一个连GPU都喂不饱的服务,在峰值流量下大概率会以更难看的方式暴露问题。与其明天当着用户的面炸,不如今晚当着监控面板的面查。1.2 先厘清概念:GPU利用率到底在利用什么很多人看到GPU利用率这个指标,会下意识把它理解成显卡有没有通电核心有没有在转,其实不是。GPU利用率统计的是在采样周期内,GPU上有多少时间在真正执行计算任务,也就是kernel的占用时间比例。它跟显存占用是两个维度:显存是存放数据的地方,利用率是计算数据的快慢。所以经常出现一种错觉——显存占了十几G,看起来模型已经稳稳跑在显卡上,但实际利用率低得可怜。利用率低的原因多种多样,常见的无外乎这几种:服务压根没流量;模型太轻、显卡太强,单次推理只摸到显卡性能的皮毛;数据要经过CPU解码、预处理,CPU忙不过来,GPU干等;推理框架一次只处理一个请求,批处理没开;序列长度或者图片尺寸参差不齐,大量计算被浪费在填充上;又或者监控采样粒度过大,把真实的忙碌毛刺全抹平了。不同的原因对应完全不同的修法,所以第一步永远不是调参数,而是判断12%到底是哪种12%。我一般会把利用率区间粗略分成四档:低于20%,基本可以断定瓶颈在GPU之外,先别动显卡相关配置;20%到60%,通常和数据管线、批处理策略有关,值得继续压;60%到90%,属于健康区间,说明GPU已经是主要瓶颈,优化空间在于算子效率和显存带宽;90%以上,显卡接近饱和,这时反而要小心尾部延迟和排队击穿。当晚我们看到的12%,明显落在第一档,排查方向应该锁定在GPU之外。1.3 我们当晚给自己定的判断标准不是所有低利用率都值得通宵。如果服务本身流量就小,模型又轻,12%可能是最经济的状态,硬去堆并发反而把延迟做差。所以我们先明确了三个前提:第一,压测流量必须覆盖预估峰值,否则这个数字无意义;第二,响应延迟有硬性SLA,优化不能牺牲它;第三,机器成本敏感,能少用一张卡就少用一张卡。在这三个前提下,12%才变成必须查的问题。我们的目标也很具体:在峰值流量下GPU利用率稳定到60%到75%,p95延迟控制在800毫秒以内。注意,目标不是100%,因为推理服务的流量天然有毛刺,100%往往意味着排队和超时已经在发生了。定好标准,排查就不会跑偏。2. 第一轮排查:想当然的结论和它掉进的坑2.1 从响应时间反推:模型没坏,那瓶颈在哪排查刚开始,团队里冒出来两个最自然的猜测:一是服务根本没收到流量,二是模型没跑在GPU上、反而掉到了CPU。这两个猜测都很好验证,但都错了。先看流量,服务端的请求计数指标显示单机QPS已经到1.6左右,在单进程同步处理的情况下这已经是极限,说明流量确实进来了。再看设备:模型稳稳加载在显卡上,显存占用正常,单次推理耗时稳定在72毫秒左右,结果也正确。我还特意跑了一遍单请求的耗时测试,总耗时约617毫秒,响应看似正常。问题就藏在这个正常里。这里有个反直觉的经验:响应时间正常,恰恰说明显卡没在忙。一个请求从进来到返回,总共花掉617毫秒,其中真正由GPU执行的只有72毫秒,剩下的545毫秒全部花在了解码、缩放、后处理、序列化这些环节上。也就是说,GPU是整条链路里最闲的角色,它干完自己的活之后,有大把时间在等别人交数据。首轮检查的结果可以整理成一张表,方便对照:检查项结果初步结论服务端请求量单机QPS约1.6有流量,不是空转模型设备放置在GPU上,显存正常占用排除模型掉到CPU单次GPU推理耗时约72ms模型本身运行正常单请求总耗时约617msGPU忙的时间只占约11.7%GPU利用率12%与耗时占比基本吻合看到这张表的时候,我心里基本有数了:现在的问题不是显卡坏了或者模型有问题,而是GPU在这条处理链路里太闲了。接下来的关键问题是,剩下那545毫秒到底浪费在哪,以及为什么并发上来之后它还是12%。2.2 并发压测证明了一个反直觉的事实为了排除流量不够所以利用率低的可能,我们把压测并发从1路调到8路,再调到32路。按直觉想,请求多了,GPU总有忙起来的时候。结果完全反直觉:并发从1路涨到32路,单机QPS还是卡在1.6左右,没怎么动,而p95延迟从617毫秒一路飙升到3秒以上,GPU利用率稳如泰山地停在12%。这个组合现象信息量极大。如果瓶颈在GPU,并发增加时利用率应该上升、吞吐应该上升;如果瓶颈在网络或磁盘,利用率会有波动,但也不会完全纹丝不动。现实是:吞吐上不去,延迟线性恶化,利用率不变——这是典型的串行处理特征。请求们不是在GPU上排队,而是在服务进程外面排队,一个处理完,下一个才能轮到。为什么会串行?因为我们当时用Python起了一个同步的Web服务,每个请求进来后,依次执行HTTP解析、图像解码、缩放填充、归一化、模型推理、后处理、序列化返回。整条流水线是单线程的,同一时刻只有一个请求能走完全程。GPU推理本身只需要72毫秒,但它在流水线里被前后左右的CPU环节夹着,一个周期600多毫秒,它只能忙72毫秒,12%就是这道串行结构的天花板。2.3 用Profiler给一次请求拍X光片为了拿到铁证,我们用性能剖析工具给单个请求做了全链路的时间拆解,把617毫秒逐段切开。结果是这样的:环节耗时运行位置说明HTTP接收与解析约50msCPU请求体读入, multipart解析图像解码约140msCPU高分辨率图解码,最耗时之一缩放/填充/归一化约230msCPU拷贝含转张量和GPU拷贝,其次是它模型推理约72msGPU全链路唯一较重的GPU环节后处理约95msCPU阈值过滤、坐标计算、结果组装序列化与返回约30msCPU组装响应体,写回连接617毫秒里,GPU侧只有72毫秒,占比11.7%,四舍五入就是监控面板上的12%。Profiler的时间线视图里,GPU那一栏几乎全是空白,偶尔冒出一个小方块,那就是72毫秒的推理。而这个空白持续的时间,正好是CPU在做解码、缩放、后处理的阶段。看到这个图,所有人都不说话了。上线前夜的第一个结论达成:不是GPU不行,是CPU把GPU饿着了。接下来要回答的问题变成:怎么让GPU别等CPU?3. 根因:同步管线里,GPU在等一把全局锁3.1 时间账单一笔一笔算清楚我们不妨把一次请求的617毫秒看成一份时间账单。GPU推理占72毫秒,这是它唯一需要付出的时间;CPU解码和预处理占了370毫秒,后处理占95毫秒,剩下80毫秒是网络和序列化。整份账单里,CPU相关开销占了大头,GPU只是个等着分账的小角色。如果服务是串行单进程的,那么每处理一个请求,GPU的工作时间/总时间就只有72/617,约11.7%。哪怕请求队列里堆了一百个任务,GPU也得一个接一个地等,利用率上限就是这个比例,永远不会因为并发升高而变好。我们看到的12%,不是GPU偷懒,而是它被同步流水线焊死在了这个比例上。这里要交代一个技术背景:为什么Python写的同步Web服务会这么串行。一方面,请求处理函数里解码、预处理、推理、后处理都是同步调用,函数不返回,下一个请求就进不来;另一方面,即使开了多线程,解释器级别的锁也会让CPU密集型的图像处理很难并行。多进程能缓解,但我们当时只起了单进程,等于把整条流水线锁死在一个线程里。这个架构在低流量下能跑,一旦流量上来,排队和延迟立刻失控。3.2 为什么多来点流量救不了利用率网上讨论GPU利用率低时,最常见的建议就是加大并发、多压一会,这次实测直接证明了这条建议在这个场景下无效。道理可以用一个厨房类比讲清楚:一个人既负责洗菜又负责炒菜,灶台就是GPU。灶台每次只能炒一盘菜,炒一盘只要一分钟,但洗菜切菜要五分钟,所以灶台大部分时间都是冷的。这时候来再多客人,灶台也不会变得更忙,只会让客人排队等更久。打破这个局面的唯一办法,是改变流水线结构,让灶台连续点火:洗菜的人只洗菜,炒菜的人只炒菜,洗好一盘就递进去炒一盘,灶台的火就不断。对应到系统上,就是两件事:把解码、预处理这些CPU活从请求线程里拆出去,交给独立的Worker池并行干;再把推理端的单请求处理改成动态批处理,让显卡一次吃进去一批数据。还有一个更细微的点值得单独说:改造前,我们测过单请求响应时间只有617毫秒,看起来不快不慢,很容易骗过验收。但617毫秒只能支撑1.6QPS,距离目标峰值15QPS差了将近十倍。也就是说,用单请求快不快来评估推理服务,是典型的以偏概全;真正要看的是吞吐和延迟在目标并发下的表现。3.3 目标利用率怎么定,别被越高越好带偏确定了方向之后,团队内部讨论过一个问题:利用率到底要到多少才算合格?当时有人提出把GPU干到90%以上,被我拦住了。对于在线推理服务,90%以上的利用率往往意味着请求已经排成队了,每个请求都在等前面的请求释放显存和计算资源,尾部延迟会非常难看。真正健康的区间,是让GPU在绝大部分时间有事做,但保留一点余量应对流量毛刺。因此我们按三个约束来定目标:一是压测流量覆盖预估峰值,二是p95延迟不超过800毫秒,三是单机吞吐能覆盖15QPS并有适当裕量。在这样的约束下,60%到75%的利用率就是合理落点。这个目标同时决定了后面的参数怎么调:批处理窗口不能太大,最大批量不能贪,一切以延迟SLA为边界。4. 修复:把串行苦力改成流水线动态批处理4.1 预处理重构成多Worker异步队列修复方案分两步走,第一步先解决CPU把GPU饿着的问题。我们把图像解码、缩放、归一化、转张量这些纯CPU环节,从原来的请求处理函数里整个抽出来,放进一个独立的预处理队列,由一组Worker进程并发消费。请求进来后先往队列里扔原始字节,Worker处理完再把张量送进推理队列。推理端只负责等张量、攒批、上GPU、返回结果。用伪代码表示就是:# 优化前的同步处理(串行) def handle_request(image_bytes): img decode_image(image_bytes) # CPU 耗时约140ms tensor letterbox_normalize(img) # CPU 耗时约230ms out model.infer(tensor) # GPU 耗时约72ms return serialize_postprocess(out) # CPU 耗时约125ms# 优化后的流水线结构 # 预处理Worker:只做CPU活,干完把张量放进队列 def preprocess_worker(image_bytes): img decode_image(image_bytes) tensor letterbox_normalize(img) preprocess_queue.put(tensor) # 推理Worker:攒批后一次性上GPU,窗口设12ms def infer_worker(): batch [] deadline now() 12 # 最多等12ms,凑不满也发 while now() deadline and len(batch) max_batch_size: if not preprocess_queue.empty(): batch.append(preprocess_queue.get()) if batch: return model.infer_batch(batch)这个改造的关键,是把CPU侧的单线程串行,变成多Worker并行。4个预处理Worker一上来,CPU侧每秒能产出的张量数量立刻翻了几倍,GPU不再需要干等。生产环境要用真正的异步框架加有界队列,伪代码只是帮助理解结构。队列必须设置上限,否则流量突增时内存会被撑爆,这叫背压控制,和让GPU有活干同等重要。4.2 动态批处理三个参数:最大批量、等待窗口、超时策略第二步是给推理端开动态批处理。GPU的强项是并行计算,一次处理一张图和一次处理八张图,耗时并不是八倍关系,通常只有三到四倍。这意味着批量越大,单张图的平均GPU耗时越小,利用率自然上升。但工程上不能无限堆批量,必须同时控制延迟。动态批处理的核心参数有三个。第一个是最大批量,受显存和模型结构限制,我们初步测下来批量8比较合适。第二个是等待窗口,也就是一个批次最多等多久,我们设为12毫秒——超过这个时间哪怕没凑满8张也直接送GPU。第三个是超时策略,等待窗口本质上就是给延迟SLA让路,宁可少收几张,也不能让某个请求在队列里蹲太久。不同批量配置的实测吞吐对比也印证了过了拐点收益递减的规律:配置单批耗时(估)理想吞吐说明批量172ms约13.9张/秒优化前等效批量4约150ms约26.7张/秒吞吐约翻倍批量8约260ms约30.8张/秒吞吐再上一个台阶批量16约430ms约37.2张/秒吞吐增益放缓,延迟明显变差从表里能看出,批量从1提到8,吞吐是划算的;但到16,收益趋缓,延迟却在恶化。当时我也踩过一次弯,把批量直接调到16,利用率确实冲到82%,p95延迟却飙到1.4秒,直接突破SLA,只好灰溜溜回退。后来总结出来的经验是:批量参数必须和预处理Worker数量一起调,因为如果CPU产不出足够多的张量,批量再大,GPU也只能干等凑不齐的批次。4.3 预处理细节里还能再抠出几十毫秒流水线和批处理改完之后,我们又回头抠了预处理本身。原来的图像处理路径里有不少浪费:解码出来的图像在内存里被拷来拷去,每次缩放、归一化都要新建中间数组;转张量时又是隐式拷贝,CPU到GPU的搬运时间被白白放大。这些单看都是几十毫秒的小事,但在每秒二十多个请求的吞吐下,累加起来就是可观的CPU和总线占用。具体做了三件事。一是让解码和缩放的中间结果走连续内存,减少碎片化拷贝。二是把归一化参数预计算好,避免每个请求重复算。三是如果请求里经常带同一批图片,可以加一层简单的缓存,但要注意缓存命中率和内存上限,收益和风险并存。另外,后处理里的结果组装能向量化就向量化,别在Python层写一长串逐元素循环。这些细节最后帮我们把预处理Worker的单张耗时压了一些,CPU侧瓶颈进一步放宽,GPU才有机会吃到更大的批量。改造到这一步,心里已经有底了:流水线通了,接下来就是拿压测数据说话。5. 压测验证和上线决策:利用率上去了,钱也省了5.1 压测数据对比:从12%到67%,吞吐涨了十倍第二天凌晨两点,我们把改造后的服务重新部署,跑同一套压测脚本。流量还是预估峰值的1.2倍,监控面板刷新出来的数据,跟四小时前完全是两个世界:指标优化前优化后GPU利用率12%67%单机吞吐约1.6 QPS约17.6 QPSp95延迟(1.2倍峰值)3秒以上(排队)约520ms单请求平均总耗时617ms约430ms失败率长时间压测时明显抬升0%吞吐从1.6涨到17.6,翻了十倍,GPU利用率稳定在67%,p95延迟压在520毫秒,在800毫秒的SLA之内。更重要的是,在比预估峰值还高20%的流量下,服务不再排队积压,失败率归零。显卡终于从陪跑变成了主力。这里说句实话,17.6这个数字放到今天不算惊艳,很多成熟方案能做得更高。但对我们当晚的处境来说,从1.6到17.6,意味着原本以为要两台GPU机器才能扛住的峰值流量,现在一台还能带富余。这个差距,就是12%和67%之间的全部意义。5.2 当晚的决策:带病上阵还是修完再上数据出来之后,上线的决策反而变得很简单。如果按最初带病上阵的思路走,第二天的真实流量一旦冲到峰值,单机处理能力卡在1.6QPS,排队延迟会以肉眼可见的速度恶化,然后自动扩缩容机制大概率会疯狂加机器。更麻烦的是,那时排查会比今晚更慌,因为线上不能随意停服,每多一分钟,损失都在放大。这晚的教训让我想明白一件事:上线前压测的意义,就是主动把明天可能发生的故障提前到今晚,让它在一个可控环境里发生。我们运气好的地方在于,问题暴露得足够早、特征足够典型,四小时就完成了从定位到修复的闭环。如果那天晚上草率上线,恐怕就不是一个通宵能解决的问题了。5.3 顺带发现:硬件资源可以减配优化带来的直接经济收益,比预想中更明显。原本我们按吞吐需求预留了两台GPU机器,理由是一台可能扛不住峰值。改造后单机就能跑到17.6QPS,而目标峰值是15QPS,等于一台机器还留有接近20%的裕量。于是当晚就退掉了第二台机器的订单。上线前夜熬这一宿,换来的不只是延迟和稳定性的提升,还有一年下来实实在在的机器成本节省。很多团队看到GPU利用率低,第一反应是显卡不够好,急着换更强、更多的卡;这次经历恰恰说明,利用率低往往不是硬件问题,而是软件管线问题。在动用预算买卡之前,先用Profiler把时间账单算明白,性价比要高得多。6. 复盘:GPU低利用率排查的固定套路与检查清单6.1 可以直接抄的排查步骤这次踩坑之后,我把GPU利用率低的排查顺序固定成了一套流程,后面再遇到类似问题,基本按这个顺序走就不会乱:先确认流量:服务实际的QPS/RPS有没有达到预期。没有流量时的低利用率不是问题。再确认模型确实在GPU上:查设备放置、显存占用,排除模型意外跑到了CPU。测单次推理耗时:如果单次推理很快、总耗时却很长,瓶颈一定在GPU之外。跑并发压测,观察组合指标:并发翻倍,利用率不涨而延迟恶化——串行问题;利用率涨但吞吐不涨——锁或IO问题。用Profiler拆一次请求的时间线:把每个环节的耗时列出来,算GPU占比,锁定最大的CPU开销。按占比修复最大头:预处理、后处理、序列化、网络,按顺序逐个优化,每改一步重新压测。这套流程的核心,是先判断瓶颈到底在不在GPU,而不是上来就调批处理、加并发。方向错了,参数调得再勤也是白费。6.2 容易误判的几种假低复盘时我们还总结了三种特别容易误判的假低利用率,每一种都踩过或差点踩过。第一是监控粒度太粗,监控面板五分钟取一次平均,把GPU忙碌的毛刺全抹平了,看起来只有12%,实际可能是很忙但没被看到。第二是流量本来就没到水位,比如刚上线还在引流阶段,利用低下说明不了任何问题。第三是显卡太强、模型太轻,单卡跑小模型,12%可能已经是性价比最高的状态,这时优化思路不是堆利用率,而是换小卡或合并服务。还有一种跟利用率最容易混淆的:显存占用高不等于利用率高。显存高说明模型占了大块显存,利用率低说明计算单元在休息。一个模型加载到显存里,不产生kernel,利用率就是零。排查时别盯着显存放不下心。6.3 最后再说一个容易反复踩的坑整晚排查里,我最想单独拎出来说的是:别单指标优化。中间有一轮,我盯着利用率调参,把最大批量从8改到16,利用率涨到82%,当时还挺高兴,结果p95延迟冲到1.4秒,差点把SLA捅破。后来才发现,利用率、吞吐、延迟这三者是同一组参数约束下的三个面,不能拆开来看。正确做法是:先把压测流量固定到目标水位,然后在预处理Worker数和批处理参数之间来回试探,找到一个利用率可接受、延迟达标、吞吐够用的交叉点。比如我们最后选定的批量8加12毫秒窗口,单独看利用率不是最高,但配合p95延迟520毫秒,才是当晚唯一可上线的组合。上线前夜的教训浓缩成一句话就是:显卡吃饱不是目的,服务在峰值流量下稳得住才是目的,GPU利用率只是帮你判断瓶颈在哪里的一个信号。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进