拓冰建站拓冰建站
首页 / 资讯中心 / 正文

AI服务并发一高就“堵车”?从排队到批处理的性能优化实战

先说一个我上个月真实遇到的线上事故。某个基于大模型做的“通用问答文档抽取路由分类”三合一API服务单条请求模型推理只要120ms看着完全能扛。可压测从5并发加到20并发P99延迟从220ms一路飙到3.8秒任务直接把CPU打满、GPU还时不时空转。我一开始也以为是模型不够快换了个更轻量的小模型结果延迟照样崩只是崩得没那么难看。后来把调用链路从头到尾捋了一遍才发现真正的瓶颈根本不在模型本身而在任务调度、请求排队、并发模型和批处理策略这堆“外围”系统上。这篇文章想说的就是这件事AI任务一多就“堵车”问题往往不在模型速度。模型推理只是整条链路的一个环节前面有数据预处理、请求排队、任务调度、显存分配后面有结果后处理、响应序列化、网络传输。任何一个环节设计不合理都会让整个服务的吞吐能力断崖式下降。下面我会从现象、根因、排查方法到优化方案把这类问题一步步拆开讲清楚。如果你正在维护AI推理服务或者准备把大模型接到业务系统里这篇内容值得你从头看到尾。1. 现象模型明明很快任务一多怎么就“堵车”了很多团队在刚上AI服务时都会先测一个“单请求延迟”。比如模型推理耗时120ms预处理20ms后处理10ms整体约150ms看起来非常乐观。但一旦把QPS从1提到10再把并发从1提到20整个服务的表现就完全变了。这种“单请求快、并发一高就崩”的现象几乎成了AI服务上线前的标配事故。1.1 一个典型的“崩溃现场”时间线我用一个模拟案例还原当时的情况。假设你有一个基于Transformer的文本分类模型输入一段文本输出一个类别。单独跑一次推理耗时120ms。模型加载到显存后推理脚本用FastAPI对外提供服务内部使用线程池处理并发请求。压测场景如下用wrk或locust以不同并发数打请求每个请求的payload大小差不多记录P50、P99延迟和整体吞吐。并发数平均延迟P99延迟实际QPSGPU利用率备注1150ms155ms6.7约10%很理想5190ms240ms25约40%开始有排队10350ms680ms27约60%吞吐没跟上并发增长201200ms3800ms25约70%P99爆炸402500ms9000ms22约50%服务濒临不可用对比单请求延迟150ms40并发时平均延迟已经翻了16倍P99延迟更是达到9秒。但有意思的是GPU利用率始终没跑满最高只有70%左右而且到了40并发反而掉到50%。这说明模型计算资源没有成为真正的瓶颈——卡点一定在别的地方。1.2 判断“堵车”的三个关键指标遇到这类问题别急着看“模型慢不慢”先看三个指标延迟分布P50还能接受P99突然飙高说明不是所有请求都慢而是有一批请求在排队等待。P50也高那就可能是系统资源已经整体吃紧。吞吐变化当并发翻倍而QPS不再上升甚至下降时系统已经进入过载状态。这时新增的请求不仅无法获得服务还会拖慢已经在处理中的任务。资源利用率GPU利用率低而CPU繁忙说明预处理、调度、数据搬运这些环节是瓶颈GPU利用率高但延迟也高说明模型计算确实是瓶颈GPU和CPU都没跑满但延迟高那就大概率是锁竞争、显存分配或网络IO在作怪。这三个指标组合起来看基本能判断出“堵没堵”以及“堵在哪一层”。1.3 为什么大家总是先怀疑模型速度因为模型速度是整条链路中最容易量化的指标。你跑一个benchmark输入输出都是固定的耗时清清楚楚谁都能看懂。而任务调度、队列策略、显存分配这些系统层面的问题往往需要专门的工具才能观测不跑压测根本暴露不出来。还有一个重要原因很多人默认“模型推理是串行的”。但实际上现代推理服务大多会带上并发和批处理。当多个请求同时到达时如果你的服务是“一个请求独占一次推理”那并发一高显存、计算单元、线程池就被反复切来切去性能自然会崩。模型还是那个模型快慢没变但系统不会用它了。2. 拆解瓶颈藏在哪五个环节里同样是“任务一多就堵车”具体卡点可能完全不同。我见过不少项目明明问题定位清楚了但因为方向错折腾几天都修不好。下面这五个环节是我排查AI服务性能问题时最常发现的瓶颈值得一个个过。2.1 调度器没配好GPU一边超载一边空闲很多AI服务用的是线程池线程池大小设置得很随意比如CPU核数的两倍。问题来了如果一个请求的预处理阶段比如分词、张量转换占用了CPU而推理阶段需要GPU那么在高并发下线程池里的线程都在等GPU返回结果CPU却已经空闲。但新的请求还是进不来因为没有空闲线程。反过来线程池开得太大比如100个线程同时把请求送到GPUGPU的上下文切换和显存分配就开始打架产生大量等待GPU利用率反而不升。调度环节的关键是确认“请求在哪一步等待最久”。用py-spy或perf对服务进程做采样能看到大多数线程阻塞在哪。如果发现大量线程都在等待某个锁或条件变量那调度就是瓶颈。2.2 队列与背压排队本身就会吃掉延迟很多AI服务用队列接收请求再交给后台worker处理。这本身没问题但队列长度不设上限或者没有背压机制就会酿成大祸。想象一个场景服务每秒能处理10个任务但前端每秒来了20个请求剩下的10个就会在队列里排队。如果持续一分钟队列里会积压600个任务。此时你看到的平均延迟不是单次推理的120ms而是120ms乘以排在队列前面的任务数再除以吞吐这数字很快就变成几十秒甚至几分钟。这类问题最明显的特征就是“延迟随时间线性增长”而且不管你怎么优化模型速度都没用因为瓶颈在队列积压不在推理本身。解决方向有两个要么给队列设置上限超出的请求直接拒绝或返回“稍后再试”要么引入动态伸缩根据队列长度自动扩容worker。2.3 并发模型锁、线程和进程各有各的坑用Python做AI服务的团队迟早会撞上GIL这堵墙。GIL只允许同一时刻有一个线程执行Python字节码而很多预处理、后处理逻辑恰好是Python写的。线程池开再大这些CPU密集型操作依然是串行的。有人改用多进程但进程之间的通信IPC成本很高尤其在需要把大量文本或张量在多个进程之间传来传去时序列化和反序列化的开销可能比推理还大。更隐蔽的问题是用多线程并发调用模型但模型预测函数内部有锁。比如你用某个第三方库加载模型后如果这个库的predict方法不是线程安全的内部加了全局锁那么无论你怎么扩线程实际推理依然是串行的。这种情况下正确的做法是部署多个模型实例每个实例单线程推理再用负载均衡分散压力。2.4 CPU与GPU之间的搬运成了隐藏瓶颈模型推理只是“GPU算一下”但在那之前你要把文本转成token ID再转成张量从内存搬到显存推理结束后还要把结果从显存搬回内存再做后处理。这个数据搬运的过程在高并发下会暴露出巨大问题。每次搬运虽然只有几毫秒但如果一个批次里塞了几十个样本而且每个请求都是独立搬运那么传输时间会线性叠加。有些框架的默认行为是每次请求动态申请显存、拷贝数据、释入显存这个过程在并发高时会触发显存碎片整理让GPU空闲等待。有一个经典指标叫“GPU空闲率”也就是GPU没有计算任务、但显存在频繁分配和释放的时间占比。这个值如果很高基本可以确定卡在数据搬运和显存分配上。2.5 显存分配与碎片等得不比推理少使用PyTorch或TensorFlow时动态分配显存是常规操作但它的开销比很多人想象中要大。当大量请求并发进来每个请求都动态申请显存再在推理结束后释放GPU驱动就需要不断维护显存映射表还会触发显存碎片整理。表现就是GPU计算利用率不高但程序整体的响应时间很长。更严重时显存碎片会导致OOM服务直接崩溃。常规解法是显存预分配和显存池化。训练场景下的做法推理场景同样适用。分配一块足够大的显存池让请求之间复用避免频繁申请释放能显著降低延迟波动。3. 实操怎么一步一步把瓶颈挖出来定位问题要用数据和工具说话不能靠感觉。我通常的排查流程分三步先压测拿到基线再通过火焰图和链路追踪找到等待点最后用工具组合确认根因。3.1 先做一轮有对比的压测压测不是简单地把并发拉高而是要有控制变量。推荐的做法是把并发从1逐步升到2、4、8、16、32每个档位稳定跑30秒以上记录延迟和吞吐数据。压测时重点记录每档并发下的P50、P95、P99延迟实际吞吐QPS服务的CPU使用率、内存和GPU利用率显存占用变化线程池/进程池的工作情况这些数据能帮你判断两件事一是系统是否有线性扩展能力——并发翻倍时吞吐是否基本翻倍二是最迟从哪一档开始延迟突然恶化。如果从2并发生到4并发QPS就上不去了说明问题可能在锁或单线程推理如果到16并发才崩那大概率是队列或资源分配的问题。注意压测时一定要把服务端日志的级别调到INFO以下避免日志IO成为瓶颈。我见过不止一次压测结果全是日志刷盘时间而不是真实推理时间。3.2 用火焰图和链路追踪找到“谁在等谁”火焰图是性能排查的神器它能直观展示CPU时间花在哪里。对于Python服务我常用py-spy配合flamegraph.pl对运行中的进程做采样生成火焰图。示例操作是先启动压测让服务处于高负载状态然后对服务进程做采样py-spy record -o profile.svg --pid 12345 --duration 30然后用浏览器打开生成的SVG文件重点看“栈宽度”最大的那些函数。如果发现大量的栈都停在某个锁上比如threading.Lock.acquire或asyncio.Lock.acquire恭喜你锁竞争实锤了。若是多机部署的微服务还需要做分布式链路追踪。给每个请求打上trace_id记录每一跳的耗时。比如某个请求总耗时3秒你可以看到它在网关耗时50ms在模型服务耗时2.8秒在数据库耗时150ms。模型服务耗时2.8秒里面再去细拆预处理、排队、推理、后处理各占多少。3.3 一套顺手好用的观测工具组合我用过不少工具最后沉淀出一套组合基本能解决90%的AI服务性能定位问题。工具用途使用要点py-spy对Python进程采样生成火焰图不需要重启服务生产环境也能用nvtop实时查看GPU利用率、显存占用比nvidia-smi更直观适合长时间观察nvidia-smi dmon按秒输出GPU利用率变化适合做时间序列分析top / htop看CPU和内存注意区分单核和多核负载iostat看磁盘IO和负载大模型加载场景特别有用wrk / locust / k6压测工具保持并发、时长、请求内容一致Grafana Prometheus看长周期趋势适合记录队列长度和延迟分布这套组合帮我定位过很多问题有些问题光靠猜是想不出来的。比如有一次线上服务延迟很高但GPU利用率和CPU都正常我用py-spy一采样发现大量线程卡在urllib.parse上——原来数据预处理里有个正则表达式在长文本上执行时间超长完全不是模型的问题。4. 优化方案把“堵车”变成“有序通行”定位到瓶颈之后就是对症下药。下面这几个优化方向基本覆盖了AI服务“并发一高就堵”的常见解法。你可以根据定位到的瓶颈选择对应的方案。4.1 给任务队列加上有界队列和超时队列不能是无限长的。一个无界队列会让你在过载时把延迟掩盖成“看起来还在处理”实际已经积压到天荒地老。有界队列配合超时是最简单的背压机制。比如用FastAPI asyncio时可以这样控制任务队列from fastapi import FastAPI, HTTPException from pydantic import BaseModel import asyncio app FastAPI() class Item(BaseModel): text: str task_queue asyncio.Queue(maxsize100) # 有界队列 async def worker(): while True: item await task_queue.get() try: # 执行推理任务这里用sleep模拟耗时 await asyncio.sleep(0.1) finally: task_queue.task_done() for _ in range(4): # 4个worker并发处理 asyncio.create_task(worker()) app.post(/predict) async def predict(item: Item): try: await asyncio.wait_for(task_queue.put(item), timeout1.0) except asyncio.TimeoutError: raise HTTPException(status_code503, detailService busy) # 简单起见这里的等待结果机制省略 return {status: queued}关键点是队列长度有限队列满了直接拒绝新请求同时给排队设置超时避免请求无限等待。这两条加在一起就能保证“最坏延迟”有上限而不是随积压任务数无限增长。4.2 动态批处理把并发任务合并成一次推理深度学习模型天生适合“批处理”。处理8个样本的耗时往往只比处理1个样本多30%-50%。如果能让同时到达的多个请求拼成一个batch再输入模型整体吞吐能提升好几倍。动态批处理continuous batching的做法是多个请求先进入队列后台调度器每隔一小段时间比如20ms收集一次队列中的请求凑成一个batch送入模型推理。如果20ms内请求不够就带着现有的请求先跑不必傻等。参考实现思路如下import threading import queue import time class DynamicBatcher: def __init__(self, model_fn, max_batch8, window0.02): self.model_fn model_fn self.max_batch max_batch self.window window self.queue queue.Queue() self.workers [] self.running True for _ in range(2): t threading.Thread(targetself._process_batch, daemonTrue) t.start() self.workers.append(t) def submit(self, data): # 返回一个Future简单起见用一个Queue作结果承接 result queue.Queue(maxsize1) self.queue.put((data, result)) return result def _process_batch(self): while self.running: batch [] deadline time.time() self.window while len(batch) self.max_batch: remaining deadline - time.time() if remaining 0: break try: data, result self.queue.get(timeoutremaining) batch.append((data, result)) except queue.Empty: break if not batch: time.sleep(0.001) continue inputs [data for data, _ in batch] outputs self.model_fn(inputs) for (_, result), output in zip(batch, outputs): result.put(output)这个实现的思路是worker线程在window时间片内从队列里尽可能多地收集请求最多收满max_batch然后统一跑模型推理。窗口越小单批推理的实时性越好窗口越大batch_size越高吞吐越好但单条请求的等待时间会变长。这个时间窗口就是实时性和吞吐之间的权衡点。4.3 请求分级别让低频任务挤占高频任务一个服务里往往混着多种类型的请求。有用户同步等待的在线查询也有后台批量处理的离线任务。如果它们共用一个队列离线任务大量涌入时在线请求就会被堵住体验瞬间崩掉。解法是给请求分级不同的任务走不同的队列和worker池。在线请求用高优先级队列设置较短的排队超时离线任务用低优先级队列可以排队更久但也不至于影响在线服务。实现上可以用优先级队列或者更简单粗暴地开两组workerimport asyncio online_queue asyncio.Queue(maxsize16) # 在线任务少量worker offline_queue asyncio.Queue(maxsize1000) # 离线任务后台慢慢跑 async def online_worker(): while True: item await online_queue.get() # 即时推理 await asyncio.sleep(0.03) async def offline_worker(): while True: item await offline_queue.get() # 可以多等一会儿比如攒批处理 await asyncio.sleep(0.3)还有更细的策略比如在线队列拒绝过载请求离线队列在积压严重时丢弃旧任务或降级处理。关键在于不要让任何一类任务无限制侵占另一类任务的资源。4.4 架构层面显存池化、多实例隔离、自动伸缩当单机的软件层优化走到头就得往架构层面看。显存池化是把显存分配变成一次性操作启动时申请一大块显存推理过程中在这块池子里复用避免频繁申请释放。这能显著降低显存碎片和分配等待在多个模型同时加载的场景下尤其明显。推理实例隔离指把一个GPU上的模型拆成多个实例每个实例一个独立进程。这样即使某个实例的显存或计算异常也不会拖垮其他实例。负载均衡器再把请求按实例的负载情况分发避免热点集中。自动伸缩则是根据队列长度或延迟指标动态扩容。比如队列积压超过阈值就增加worker实例延迟恢复正常再缩容。Kubernetes的HPA加上自定义指标就能实现关键是指标要选对。用CPU利用率做扩缩容对AI服务并不准确因为瓶颈经常在GPU和队列上建议用“队列积压数”或“GPU利用率”作为扩缩容依据。5. 常见误区与定位心得这部分没有太多新理论但踩过的坑都写在里面。如果你按前四章的思路排查完毕最后再看一眼这几条常见误区能少走不少弯路。5.1 定位堵车时最容易犯的五个错不停换模型模型从大换到小延迟从120ms降到80ms看着有进步但并发一高还是堵。本质是系统没处理好并发换模型治标不治本。盲目调大并发线程先开100个再说结果锁竞争和显存分配比推理还耗时QPS反而下降。并发不是越大越好对单模型推理服务建议起步用1-2个worker测到瓶颈再慢慢加。只测并发不测排队压测只看延迟不记录队列长度。其实队列长度才是“堵车”的第一信号延迟只是结果。忽略CPU预处理模型推理很快但分词、正则、字典映射、张量拼接这些CPU操作在高并发下会吞掉大量核资源。尤其用Python时GIL让这些操作更难并行。把显存当内存用每请求动态申请张量、动态释放短时间看不出问题一旦并发上来显存碎片的分配等待可能比推理耗时更长。5.2 一套自用的排查速查表症状可能原因第一个要看的证据并发一高GPU利用率低CPU高CPU预处理/调度/锁竞争py-spy火焰图看CPU栈分布并发一高GPU利用率高延迟仍高模型计算确实是瓶颈加大batch或上更快的硬件延迟随时间线性增长队列无界积压查看队列深度和QPS对比显存占用缓慢增长延迟波动显存碎片/泄漏观察显存分配与释放模式请求偶发超时重启后恢复死锁或线程爆炸检查线程数、锁等待时间多实例扩展后QPS不变共享数据库/外部服务瓶颈在压测时观察外部组件耗时排查顺序我习惯是先看延迟分布和队列长度再开火焰图和nvtop确认最后确定是调度、队列、锁、显存还是计算的问题然后再针对性优化。提示压测时别把请求内容设成一样。如果所有请求都是同一个长文本GPU缓存会让结果失真。建议准备至少几十个不同长度、不同内容的真实样本按线上比例混合。根据我的经验AI服务性能优化的优先级永远是先保证系统层的稳定性和确定性再追求模型层的极致速度。一个能让100个并发请求都能在500ms内返回的服务比一个单请求只要50ms但并发30就崩溃的“高性能模型”价值要高得多。排查这类问题时始终带着一个疑问去看数据“这个请求的时间都花在哪儿了”只要能把每一毫秒的去向对齐到具体的代码行问题基本就解决了一半。最后再分享一个小技巧给线上服务加一个“慢请求日志”只要响应超过你设定阈值的请求就自动把trace_id、队列等待时间、推理耗时、后处理耗时打印出来。这比事后翻监控省事太多也是我如今做性能优化的第一道防线。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门