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

AI推理服务高并发优化:从动态批处理到连续批处理

AI推理服务高并发优化:从动态批处理到连续批处理 ★ FEATURED ARTICLE
AI模型推理服务上线之后真正的难点从来不是让模型“跑起来”而是让它在高并发下“跑得满”。我做过一个某AI应用的后端推理服务最初单条请求的延迟数据很好看但并发一上来服务端显卡利用率始终在20%上下晃请求在队列里越积越多用户端等得越久我们就越慌。后来我把优化重点从“单条请求怎么更快”转向“一批请求怎么组织”整个服务才缓过来。批量执行优化简单说就是让同一块GPU在一次计算里同时吃进更多请求把算力和显存榨干。它不改变单次推理的理论计算量但能大幅提升吞吐、摊薄单位成本。别把它当成一个开关它实际覆盖了请求入口调度、批组装策略、显存管理、算子执行等多层内容。这套方案我在长短文本混杂、流式输出、多模型共存的多个场景里反复验证过说它是推理服务性能优化路上的必修课一点都不夸张。这篇文章就把我实践过的批量优化思路、参数设计、踩坑记录一次讲透。1. 为什么批量执行是推理服务的命门1.1 单条请求单独推理是最大的性能浪费很多刚接触模型部署的人都有一个思维惯性既然一条请求能在一个GPU上跑那几条并发请求自然也能跑得动无非是排队而已。可真去压测就会发现单条请求单独打满一次推理GPU的绝大多数计算单元其实是空转的。GPU擅长的是大规模并行计算尤其是矩阵乘。以Transformer类模型为例一次前向推理要把几十层网络跑完每一层都有几个巨型矩阵乘法。当批次大小是1的时候矩阵的宽度已经定型但计算单元的并发度并没有完全打开大量核心在旁边等着显存带宽也只用了一小部分。我见过最极端的场景某模型单请求推理利用率不到10%整张卡几乎在“散步”。打个不那么严谨的比方同样一辆大巴拉一个人上高速和拉四十个人上高速油费、过路费几乎一样。批量执行就是想办法让一辆刹车性能相近的“车”尽量多拉人把固定成本摊薄。这也是为什么所有主流推理框架都把批处理调度放在性能优化优先级最高的位置。1.2 批量不是只有好处它是一笔需要算清的账批量执行提升了吞吐但收益从来不是白来的。第一个代价是延迟为了凑一个更大批次调度器必须多等一会儿请求的端到端时间变长。第二个代价是木桶效应同一个批次里如果有一条极长的请求其他短请求也得陪它跑完这一整轮短请求的延迟会被长请求“绑架”。这里实际操作中最常见的三个指标要分清楚吞吐量单位时间能处理多少请求或者每秒生成多少token批量对它的提升最明显。首token延迟请求到达后到第一个输出token出现的时间受排队和预填充影响很大。每秒输出token速率衡量每条请求拿到结果的流畅程度受batch内干扰和显存带宽影响。批量优化本质上是在延迟上限的约束下尽量把吞吐顶上去。它不是把max_batch_size调得越大越好而是要找到“用户可接受延迟”和“GPU满载能力”之间的平衡点。很多团队一上来就把批次调满结果吞吐没涨多少p99延迟直接爆炸。1.3 动手之前先问自己三个问题同样是“批量执行”不同业务场景的解法差很多。我在项目里总结过三个必须提前想清楚的问题请求输入的长度是否接近如果长短差距大padding会带来巨大浪费可能要想分桶策略。业务允许等待吗有实时交互的流式服务和离线批量打分的容忍度完全不同。输出是整段返回还是流式逐token返回这决定了要不要上请求级调度。这三个问题分别对应了两种主力方案动态批处理和连续批处理。前者适合短请求、长度可控、可以等一轮推理结束的场景后者适合大模型生成式推理、输出长度不确定、有流式需求的场景。后面我分别拆开讲。2. 动态批处理的机制与参数设计2.1 让请求先“攒够一车”再一起走动态批处理是目前最普遍的批量优化手段核心机制并不复杂请求进入一个队列调度器在时间窗口内收集请求攒够一定数量或者等待时间到达阈值就把这批请求组装成一个batch一次性交给GPU推理。执行完之后结果再按请求拆开返还给各自调用方。这种“攒批”动作之所以是动态的是因为请求到达本身有波动。如果固定每0.5秒组一个批空闲时段就会空转如果固定到32个请求才组批低峰时可能永远凑不齐。动态批处理的价值就是既能吸收流量抖动又能让GPU持续有活干。我在实际项目中动态批处理通常用在两类场景一类是图像、向量等非生成式模型的单次前向推理输入长度稳定任务没有流式输出另一类是批量离线任务本来就可以容忍秒级等待时间。这两种场景对批处理的要求都比较简单不需要在推理中途插入新请求。2.2 两个核心参数决定了性能天花板动态批处理的调度效果几乎完全由两个参数决定最大批次大小和最大排队等待时间。最大批次大小就是一批最多塞多少请求。它受GPU显存限制最大显存装得下模型权重还要装得下中间激活值和输出。这里的一个常见误区是只算模型权重占的显存忽略了batch越大中间激活值占用增长越快。最大排队等待时间则是调度器为了凑批次愿意等多久。这个参数直接决定了延迟。理解它最简单的方式是看请求到达间隔。假设系统每秒来100个请求理想情况下每10毫秒来一个如果最大批次是32凑满32个请求的期望等待时间约为320毫秒。你把等待窗口设成300到400毫秒就既能稳定凑批又不会让用户等太久窗口设成2秒那延迟就不可控了。项目里我们一般用一条经验公式来估算基线值平均请求到达间隔约等于1000除以每秒请求数批次凑满时间约等于平均到达间隔乘以批次大小。然后把调度延迟窗口设为这个凑满时间的60%至80%留出一些余量。注意这只是起点最终一定要通过压测再收紧。2.3 Padding、请求分桶与优先级细节里全是坑动态批处理的操作不只是排队和合并padding问题才是最大的隐形浪费。一个批次里的请求长度通常不相等框架会把短请求填充到和最长请求一样的长度于是很多计算都在算无效的padding位。短请求和长请求混在一起填充率可能低得离谱。我踩过的坑是一开始不区分请求长度结果最长请求是5000字符其他请求平均只有100字符整批有效计算占比不到10%GPU看着忙实际全是空转。后来改用请求分桶长度相近的请求优先放进同一个批次长度相差过大的请求宁可拆到下一批。分桶能让padding率显著下降代价是凑批时间变长部分请求会等待更久。这里需要给不同桶设置不同的等待窗口短桶可以等久一点长桶尽量快发。优先级方面如果业务里有VIP通道或超时敏感请求可以在队列里加优先级字段保证高优请求不被普通请求堵住。2.4 一个可运行的动态批处理调度伪代码我基于项目实践整理过一版简化的调度逻辑供参考class DynamicBatcher: def __init__(self, max_batch_size, max_delay_ms): self.queue Queue() self.max_batch_size max_batch_size self.max_delay_ms max_delay_ms def enqueue(self, request): request.arrived_at time_ms() self.queue.push(request) def poll_batch(self, nowNone): now now or time_ms() # 检查是否有请求已经等待超过窗口阈值 overdue self.queue.has_overdue(self.max_delay_ms, now) if not overdue and self.queue.size() self.max_batch_size: return None # 还没到发车时机 batch [] # 按长度相近原则弹出请求直到凑满批次或队列见底 while len(batch) self.max_batch_size and not self.queue.empty(): req self.queue.pop_most_similar(batch) batch.append(req) return batch真实的调度器还要处理并发队列、后处理回调、超时剔除、多后端路由但核心就是这段逻辑有一批可以发车的时机就组装一个尽可能整齐的批次发出去。参数再复杂本质都是在时间轴和空间轴之间做取舍。3. 连续批处理把“整批等待”拆成“每步出发”3.1 传统批处理在长输出场景下的无力感动态批处理在分类、向量、图像等一次性前向任务上表现很好但放到大模型生成式推理里就会碰到一个致命问题一批请求进入模型后必须等整批全部生成完才能释放显存接收新请求。举个例子一批里有两条请求一条只输出20个token另一条要输出2000个token。第一条早早就完成了但它占用的显存和计算槽位不能释放只能干等着队友。这段时间GPU的很多单元都在空转批次越大这种“等短板”的浪费越严重。在大模型场景里这种动态批处理方式很快就成了瓶颈。如果改成把批次拆细每次只推进一个token又会让流程变得极度碎片化管理开销难以承受。所以行业里演进出了连续批处理方案核心思路是把批次从“请求级别”拆到“迭代级别”。3.2 连续批处理到底在连续什么连续批处理的关键特征是每个解码步骤生成一个token结束后调度器都重新检查一次批次组成。已经生成完的请求立刻摘出新到的请求马上插入批次不再是一次推理从开始到结束的固定组合而是每步都可能变化的动态列表。这里可以将GPU想象成一条流水线传统批处理是等一整箱工件全部完成再换下一箱连续批处理则是流水线上随时可以放新工件、拿走完工件机器始终在有效转动。这种调度方式对大模型推理尤其重要因为解码阶段的计算量主要由当前批次的总token数决定而不是由批次里的请求个数决定。连续批处理能让GPU每一步都处理尽可能多的活跃请求显存不闲置算力也不歇着整体吞吐比传统批处理高出一截。3.3 分页式KV Cache给显存做“虚拟内存”要让连续批处理跑得高效显存管理必须跟上。大模型推理过程中会产生一个很大的KV Cache也就是每一层自注意力保留的历史键值随请求不断变长而增长。传统做法是一次性为整条请求分配一段连续显存预留最大长度结果大量显存被未来可能用不上的位置占据。如果换成按块分配的思路问题就能缓解显存被切成固定大小的小块按需给请求分配不够了再加一块完成了就回收。这种分块调度和操作系统给进程分配虚拟内存的方式非常像业界把这种机制叫作分页式KV Cache。我实测过同样一台设备传统整块分配最多能同时跑20条长会话请求换成按块分配后可以跑到50条以上显存碎片减少后批量并发能力大幅提升。分页式KV Cache和连续批处理往往是配套出现的调度器每一步动态决定哪些块分配给哪些请求显存即用即取。3.4 流式输出与批处理只能二选一不需要很多业务要求大模型的输出像打字一样逐token返回传统批处理几乎无法实现因为一批请求要么一起生成完要么就整段返回。连续批处理天然解决了这个问题调度器在每个解码步骤生成一个token可以立刻把该token推给对应的调用方同时其他请求继续在批次里生成。我在做流式业务的时候一开始担心流式输出和批量优化不可兼得后来发现连续批处理正是为这个场景设计的。请求独立持有自己的生成状态调度器只在每步把结果拆包分发。只要调度器的单步处理能力够快流式体验和批吞吐可以同时成立。两类方案适合的场景有明显差异我做了一张对照表方便查阅对比维度动态批处理连续批处理批组装单位请求级解码步级输出方式整段返回支持逐token流式适用模型分类、向量、小模型大模型生成式推理显存分配多为整块预分配按块动态分配长尾请求影响短请求会等待长请求长请求不阻塞短请求调度复杂度较低较高需请求级状态管理4. 配套优化算子、预处理与工程选型4.1 把单一算子“拧成”一个大算子批量优化不只是在调度层发力底层算子层面同样有文章可做。GPU性能最怕的是频繁读写显存和切换kernel。像LayerNorm、残差连接、激活函数这类小算子单独执行时启动开销占比很高。把它们融合进一个更大规模的kernel里能明显减少中间结果的落盘和读回。举个例子一次Transformer层前向要执行矩阵乘、偏置、LayerNorm、残差、激活等操作如果每个操作都单独跑到GPU上一次每次都要把中间结果写一遍再读回来。融合后一次计算完成一整块逻辑内存访问量减少30%到50%都很常见。批处理规模越大这种融合带来的收益越明显因为大矩阵乘法本身已经快饱满了瓶颈就落在访存上。如果你是自研推理管线而不是直接用现成框架建议优先把注意力放在算子融合上。调试过的经验是先用性能分析工具看每个算子的实际耗时占比再挑耗时最大且是访存密集型的算子动手融合不要盲目改造所有算子。4.2 让CPU别抢GPU的活预处理后处理单独跑批量优化经常被忽略的一块是请求进入GPU之前的预处理和离开GPU之后的后处理。分词、padding、数据校验、协议解析这些事情如果都在GPU前端的主线程里做成本极其惊人。一条文本请求要做分词分词要查表、做正则这些操作如果在临界区内执行会直接推高所有请求的等待时间。我们当时的做法是把预处理放到独立的CPU线程池里甚至独立进程GPU后端只负责接收已经token化、长度规整好的输入。后处理也同理生成完的结果发到异步队列由回调线程做业务逻辑不占用GPU侧的时间窗口。这样批量调度器可以更专注于组批工作不会被业务代码拖住。另外请求文本内容往往有共同的前缀比如系统提示词、固定开场白、历史对话背景。这部分内容如果每次请求都重新在GPU里计算一遍等于反复做无用功。可以在推理引擎里做前缀缓存把公共的预填充结果缓存下来新请求只要公共前缀一致就能跳过重复计算。这个优化配合批处理效果往往比单纯调大batch更立竿见影。4.3 量化与编译批量优化的“额外弹药”批量优化的核心是让GPU装下更多请求、算得更快模型量化刚好在这两条路上都能帮上忙。权重从FP16压到INT8或FP8显存占用直接减半同一个GPU能同时容纳更多并发请求批次规模的上限也随之提升。KV Cache同样可以量化压缩之后一个长会话请求占用的显存更少。但量化不是零成本。我用低比特量化模型踩过精度坑尤其是在数学推理、专业领域问答这类任务上误差影响会被放大。上线前必须用代表性数据集做回归评测不能只看性能测试绿灯就放行。量化的理想状态是“精度损失在可接受范围显存占用下降一大截”。编译器层面的算子图优化则是另一条路把计算图重写、算子选择、内存规划都在编译期做完运行时省掉大量动态调度开销。编译后再配合批处理启动时间会变长但稳定运行时的吞吐比解释执行明显要高。对生产环境来说这个启动成本值得付。4.4 框架选型决定了你能走多远批量优化方案的落地深度很大程度取决于所选推理框架支持到哪一层。三类方案各有特点框架类型擅长场景典型能力主要短板通用型推理服务分类、检测、非生成模型动态批处理、多模型管理、健康检查长文本生成调度较弱大模型专用引擎LLM、长文本生成连续批处理、分页式KV Cache、前缀缓存定制复杂业务逻辑较难自研轻量服务高度定制业务调度策略完全可控工程量大稳定性要自己兜底如果你只是做标准大模型API服务大模型专用引擎几乎是省力最优解如果你在已有系统里集成多类模型通用型推理服务更灵活。我们团队的做法是通用任务走通用型服务大模型生成走专用引擎两者在入口处用统一路由层分流。自己从头造轮子的成本很高除非调度策略确实没法用现成机制表达否则不建议。5. 压测调优与现场实录5.1 先把指标定义清楚再谈优化没有统一指标优化工作很容易变成盲人摸象。我做批量优化时固定的几个监控项包括吞吐量每分钟完成请求数以及生成场景下每秒输出的token数。TTFT从请求发起到第一个token出现的时间反映调度和预填充环节是否拖沓。TPOT每个输出token的平均耗时反映解码阶段是否被批次内其他请求干扰。p99延迟最能暴露长尾问题的指标比平均值重要得多。GPU利用率虽然不能孤立看但利用率过低一定说明资源没吃满。压测工具不建议一上来就用复杂的压测平台。先用脚本按固定速率发请求逐步提高并发同时记录上述指标。这样可以清楚看到每个并发档位下吞吐和延迟的变化曲线判断系统在哪个点开始不稳。5.2 一次从基线到连续批处理的调参过程我在某模拟项目X上做过一次完整记录很有代表性。初始状态没有开任何批处理机制模型服务的并发上限是10GPU利用率徘徊在25%左右p99延迟800毫秒吞吐大约40条请求每分钟。从商业角度看这服务上线就是亏钱因为机器在空转。第一步先打开动态批处理max_batch_size设为16排队等待窗口设为200毫秒。GPU利用率立刻升到60%以上吞吐涨到110条每分钟但p99延迟也升到了1.2秒。原因是请求在队列里要等凑批等待时间直接叠加到延迟上。第二步把连续批处理和分页式KV Cache打开调度粒度从请求级切到解码步级。结果是GPU利用率冲到85%p99延迟不升反降回到了600毫秒左右吞吐再翻倍到接近300。这个对比让我确信在生成式推理场景连续批处理对动态批处理是降维打击。第三步是针对显存和KV Cache占比调参。分页式KV Cache的显存上限从默认的50%调到70%并发承载能力从50路升到80路。最后一步是微调最大并发和队列长度把p99稳定在可接受范围吞吐定型。5.3 常见参数的落地参考值给出一组我常用的起步配置参数参数项建议起始值调整方向max_batch_size根据模型和显存先放小再放大显存充足且延迟余量足够时增大排队等待窗口请求到达间隔的60%至80%延迟偏高就调小批次偏小就调大KV Cache显存上限设备显存的50%到70%模型权重占用小的场景可调高并发槽位上限看压测曲线拐点超过拐点后吞吐不再提升反而延迟陡增超时剔除时间业务可接受延迟上限的1.5倍防止个别请求长期占用槽位一定要记住这些参数不是独立存在。max_batch_size调大排队等待窗口可能反而要调小KV Cache分得多留给临时激活值的显存就变少。任何一次参数调整都要重新跑一轮完整压测用数据收尾。6. 常见问题与排查技巧实录6.1 现象与原因速查批量执行优化过程中我几乎把所有常见问题都踩了一遍。下面这张表是最典型的几个现象和排查方向现象常见原因排查思路GPU利用率高但延迟也高请求在GPU端排队或批次里长短差距过大看批次填充率和队列等待时间显存溢出批次过大或KV Cache占比过高调低并发检查KV Cache上限吞吐上不去批次太小、等待窗口太短看平均批次大小逐步放大窗口延迟剧烈抖动偶发超大请求挤占整批资源增加分桶策略限制单请求最大长度批次填充率低请求到达不规律窗口太短拉长等待窗口或改用连续批处理流式输出卡顿调度器单步处理时间过长定位是预填充阻塞还是批次过载出现问题时最忌讳的就是只看最终延迟指标必须逐层拆开需要打点记录入口入队时间、批组装等待时间、GPU执行时间、后处理时间看延迟到底堆积在哪一层。6.2 定位瓶颈从四个时间戳入手在某个模拟项目里我们排查过一次很迷惑的延迟抬升问题。表面上GPU利用率一直在80%以上但用户反馈说输出速度更慢了。我们把请求生命周期里的四个时间戳拉出来对比发现问题出在队列等待阶段大量请求在批次组装时被长时间搁置GPU其实一直在处理旧请求。这种问题如果只看GPU指标会完全误判。正确的体验链路应该是网络到达时间、入队时间、组批完成时间、GPU执行完成时间、网络返回时间。每个时间差对应一个环节。用分布式追踪把所有时间戳串起来瓶颈在哪一层一目了然。日志里最好能周期性输出平均批次大小和填充率这两项是批量调度健康状况的晴雨表。6.3 几个被我反复拿来救命的避坑心得第一不要在延迟敏感业务里追求100%的GPU利用率。经验上利用率到80%到90%是一个甜蜜区再往上冲利用率提升带来的收益会被排队延迟翻倍抵消。我们曾经试着把利用率压到95%p99延迟直接翻了三倍得不偿失。第二批次大小不是越大越好。大模型场景里batch增大虽然提升了每步的吞吐但单条请求生成每个token的速度会被明显拖慢。如果业务是强交互式的小批次反而体验更好。第三超时兜底必须做好。长尾请求一旦卡在队列或生成过程中不能让它无期限占用资源。每个请求进队时都要挂一个超时时钟超时直接摘除同时给调用方返回明确的降级响应。没有这个机制批量优化做得越好单点故障的引爆范围就越大。第四监控要到“每一步”。对连续批处理而言一个批次里每步实际参与计算的请求数量是在变的只看起始状态的批次大小没有意义。我会把每一步的有效token数、活跃请求数、新插入请求数都接入监控出了异常可以回放现场。我个人在实际操作中的体会是批量执行优化最考验的不是方案多先进而是能不能在延迟、吞吐、显存、成本这几个维度之间找到那个稳定平衡点。很多优化手段单独拿出来都是好东西组合在一起就需要一套清晰的可观测体系和迭代方法论支撑。最后再分享一个容易忽略的小技巧每次调整参数后把压测脚本固定下来形成回归基线所有优化效果用同一套数据说话否则很容易被偶然的流量波动带偏方向。保留好这一套优化流程以后换模型、换硬件、换业务场景时你都能快速复用而不是每次都从零开始试错。
阅读完成 · 觉得有帮助?
咨询建站