1. 为什么静态批处理撑不住真实流量
1.1 一次推理请求在引擎内部到底跑了些什么
先把链路捋清楚,不然后面聊调度会一直隔靴搔痒。一个文本生成请求进到推理引擎里,会被拆成两个性质完全不同的阶段:预填充和逐字解码。预填充阶段把整段输入一次性喂进模型,算完所有输入位置的注意力,产出一个初始的 KV 缓存和第一个输出 token,这一步是典型的算力密集,矩阵乘法的并行度高,GPU 的算力单元基本能吃满;从第二个 token 开始进入解码阶段,每一步只处理一个新 token,但要读取全部历史 KV 缓存,这一步变成了访存密集,算力利用率掉得很厉害,很多时候只有百分之十几。
这个差异是后面所有优化的出发点。解码阶段每步的计算量很小,可它必须顺序执行,因为第 N 个 token 依赖前 N-1 个 token 的结果。也就是说,单个请求的解码过程天然是一条串行的长链,你没法把这条链拆开并行。想让加速卡别闲着,唯一的办法就是把多条链拼在一起跑,用批量去填满计算单元。批处理技术存在的全部理由,就是这句话。
我见过不少人一上来就盯着模型量化、算子融合这些手段,做了半天发现吞吐只提升了两三成,原因就在于他们优化的对象是单请求的延迟,而线上服务真正的瓶颈往往在批次的组织方式上。量化能让每步算得快一点,但如果你的批次里有一半的槽位在空转等待,那省下来的时间立刻又被浪费掉了。
1.2 静态批处理的两笔糊涂账:填充浪费与等待浪费
静态批处理的做法很朴素:攒够 N 个请求,或者等一个固定时间窗口,把它们打包成一个批次,一起送进模型,一直跑到这个批次里所有请求都生成完毕,才释放资源接下一批。这个方案在图像分类那种定长输入的场景里没问题,放到文本生成上就立刻暴露两个硬伤。
第一笔账是填充浪费。一个批次里的请求长度参差不齐,短的只有几十个 token,长的可能上千,为了能放进同一个张量,短的必须补齐到最长的那条。补出来的位置全是无效计算,白白消耗算力,还占了显存。真实流量的长度分布通常是很偏的,长尾请求占比例不大但极长,一条超长请求就能把整个批次的平均有效利用率拉到很低。
第二笔账更致命,是等待浪费,本质上就是队头阻塞。批次里最先完成的请求,它的槽位在整个批次结束之前都不能被复用,只能干等着最慢的那条。假设一个批次里有四条请求,分别需要生成 20、50、100、800 个 token,那么前三条在很早就跑完了,剩下的时间里加速卡上只有一条请求在跑解码,批大小等于 1,吞吐直接掉到谷底。这种情况在线上非常常见,因为用户输入的问题长度差异本来就大,"你好"和一篇三千字的文档摘要会同时进来。
这两笔账加起来,实际有效吞吐可能只有理论上限的两三成。你加机器、加卡,成本是线性上涨的,但吞吐提升却很有限,因为浪费的比例没变。这也是为什么很多人扩容之后发现 QPS 涨了但单位成本没降,问题不在硬件,在调度。
1.3 连续批处理改的是调度粒度,不是批大小
连续批处理这个名字容易让人误解,以为是把批次做得更连续、更平滑。它真正改的东西比这彻底:把调度的最小单位从一个完整请求,降到了单次前向计算,也就是一个解码步。业界常说的迭代级调度,指的就是这个意思。
具体来说,引擎不再维护一个"整批一起开始、整批一起结束"的大批次,而是维护一个动态的、每个解码步都可以增删成员的运行集合。每个解码步开始前,调度器做三件事:把已经生成结束标记或者达到长度上限的请求踢出去,释放它们占的 KV 缓存;把等待队列里的新请求补进来,前提是显存够、并发数没超限;然后把这个当前集合打包做一次前向计算。
这样做的效果是,槽位一旦空出来就立刻被填上,不再有"陪跑"的时间。上面那个 20/50/100/800 的例子,前三条跑完之后,它们的槽位马上会被新来的请求占用,整个过程中批大小始终维持在一个较高的水位,而不是断崖式下跌。填充浪费也顺带解决了大半,因为每个请求的 KV 缓存是独立管理的,不需要为了拼张量而把短的补齐到最长,不同长度的请求在同一个解码步里各自走各自的注意力计算。
影响范围其实比很多人想的大。它改变的不只是吞吐数字,还改变了服务的成本模型:单位 token 的推理成本会随着并发上升而显著下降,这让长上下文、高并发的应用场景第一次在经济上变得可行。它同时也改变了延迟的特性,首 token 延迟和后续 token 间隔需要分开对待,因为请求随时可能被插队或者被抢占。做容量规划的时候如果还把整批当成一个整体来估,算出来的数会完全对不上。
2. 连续批处理的核心机制拆解
2.1 迭代级调度的三个动作:回收、补位、组批
把调度器的每个解码步拆开看,其实是三段固定动作,理解了这三段,整个机制就没有黑箱了。
第一段是回收。检查运行集合里每个请求的状态,判断它是不是刚生成了结束符,是不是达到了最大生成长度,是不是被用户取消了。命中的请求从运行集合移除,同时通知缓存管理器把它占用的 KV 块归还到空闲池。这一步必须做在补位之前,否则新请求会因为拿不到块而被卡住,形成一个很蠢的自锁。
第二段是补位。从等待队列头部开始逐个尝试准入,每个候选请求要过几道关:当前运行请求数是否已经达到并发上限,缓存池里有没有足够的空闲块容纳它的输入长度,本步的 token 预算还剩多少。全部通过才允许进来,任何一项不通过就停下来,不再往后看,因为队列是先进先出的,跳着放会导致长请求被无限饿死。
第三段是组批计算。把运行集合里所有请求当前要算的 token 拼成一个扁平序列,配合块表做注意力计算。注意这里每个请求的查询长度可能不同:刚进来的请求是预填充,一次要算几百上千个位置;已经在跑的请求是解码,一次只算一个位置。混合批的处理方式有两种,一种是分开跑两个前向,另一种是拼成一个变长批次一次算完,后者效率更高但实现更复杂。
这三段动作串起来,就构成了一个不断循环的流水线。我见过一些自研实现,把回收放到异步线程里做,结果就是缓存释放和分配出现竞态,跑一段时间就偶发失败,排查了很久才发现是这里的问题。这类状态机的正确做法是单线程串行处理,别为了这点并行度引入不确定性。
2.2 准入判断到底该看哪几个数
准入策略是调度器的灵魂,写得太保守浪费吞吐,写得太激进就会频繁抢占甚至直接爆显存。我的经验是盯住三个数,按优先级依次判断。
第一个是并发请求数上限。这个上限不是拍脑袋定的,它跟批次里最坏情况的显存占用挂钩。因为连续批处理下每个请求的上下文长度是动态增长的,你得按最大可能长度来预留,否则跑到一半发现块不够用就只能抢占。通常这个值设得比理论最大并发小一些,留出余量。
第二个是空闲 KV 块的数量。这是硬约束,比并发数更精确。每个请求需要多少块,取决于它的输入长度加上已生成长度,再除以块大小向上取整。调度器在准入时必须精确核算,不能只估算个大概。有些实现偷懒,用"平均每请求多少块"来估,结果就是长请求集中的时候频繁触发驱逐。
第三个是本步的 token 预算。这个参数在混合批的场景里特别关键。预填充一个三千 token 的请求,可能一步就吃掉相当于几百个解码请求的计算量,如果不加限制,一个长请求进来会把这一整步的时间拉长好几倍,所有正在解码的请求的 token 间隔都会被打出一个尖峰。所以要让预填充和普通解码共享一个预算池,谁先申请谁先得,超了就排队。
这三者之间是有取舍的。并发数放宽能提高吞吐,但尾部延迟会变差;块数核得松能减少拒绝,但可能引发抢占;预算给小能保证延迟平滑,但吞吐会掉。没有一组放之四海皆准的参数,必须结合你服务的流量形态来调。
2.3 长度异构带来的 KV 缓存管理难题
静态批处理里,KV 缓存可以简单地按"批大小 × 最大长度 × 每 token 占用"预分配一整块连续显存,因为整批同生共死。连续批处理下这招行不通了,请求随时进出,长度各不相同,如果还按最大长度预分配,浪费会非常夸张,一个只需要两百 token 的请求也要占两千 token 的坑。
解决办法是把 KV 缓存切块管理,业界通行的做法是把显存切成固定大小的块,每个块装固定数量的 token 的 KV,比如十六个。每个请求维护一张逻辑块到物理块的映射表,注意力计算的时候按表去取。这个思路跟操作系统的虚拟内存分页是一回事,好处是请求增长时按需追加块,不需要预留连续空间,坏处是注意力计算的访存变得不连续,需要专门的内核去处理。
块大小这个参数很讲究。切得太小,块表变长,管理开销和访存跳转变多;切得太大,最后一个块内部的浪费变多,平均每个请求会浪费半个块左右。十六这个值是比较平衡的选择,块内浪费控制在个位数百分比,管理开销也可以接受。如果你的流量里超长请求特别多,可以适当调大,但别超过三十二。
还有一个容易被忽略的点:块表本身也要占显存和带宽。高并发场景下,几万个块的表项加起来也不是小数目,而且每一步都要读一遍。有些实现在这里没算清楚,按理论值配了很大的并发,实际跑起来光读块表就吃掉不少带宽,吞吐反而上不去。
2.4 调度器、执行器、缓存管理器各自管什么
一套完整的连续批处理实现,通常拆成四个组件,边界要划清楚,不然代码会烂成一团。
调度器只做决策,不碰张量。它持有等待队列、运行集合、交换出去的请求集合,每个解码步产出一个执行计划:这一批要算哪些请求,每个请求要算几个 token,各自的块表在哪。它不知道模型有几层,也不关心注意力内核怎么实现。
执行器负责把执行计划变成实际的计算。它要根据计划组装输入张量,处理变长序列的打包与拆分,调用模型前向,拿到 logits 之后做采样,把新生成的 token 追加到各请求的状态里。它不决定谁进谁出。
缓存管理器管块池的分配与回收,对外提供"能不能分配"和"分配"两个接口,内部维护空闲块链表和引用计数。有些实现还带前缀共享,相同前缀的请求共用同一批物理块,靠引用计数来管理生命周期,这个特性对系统提示词很长的场景收益巨大。
采样器相对独立,负责从 logits 里选 token,温度、top-p、重复惩罚这些参数在这里生效。它要注意的一点是批量采样时每个请求的参数可能不同,不能用一个全局参数糊弄过去。
边界划清楚的好处是替换成本低。想换调度策略只改调度器,想换注意力内核只改执行器,互不影响。我在早期项目里把这几个东西揉在一起,后来想加一个抢占策略,牵一发动全身,重构花了整整两周,这个教训值得记下来。
3. 显存核算与关键参数推导
3.1 单 token 的 KV 缓存到底占多少
这个数必须先算准,后面所有参数都建立在它之上。公式不复杂:
每 token 的 KV 占用 = 2 × 层数 × KV 头数 × 每头维度 × 数据类型字节数
前面的 2 是因为键和值各存一份。这里的关键是"KV 头数"而不是"注意力头数",如果模型用了分组查询注意力,KV 头数会比注意力头数少好几倍,这个差别直接决定了你能跑多大并发。
拿一个典型的七十亿参数模型举例,三十二层,三十二个注意力头,每头维度一百二十八,用半精度也就是两字节。如果是不做分组查询的标准多头注意力,每 token 占用是 2 × 32 × 32 × 128 × 2 = 524288 字节,也就是 512 KB。一条两千 token 上下文的请求,光 KV 缓存就要吃掉 1 GB。这个数字很多人第一次听到会吓一跳,但它是真的,也是长上下文服务成本高的根本原因。
如果这个模型改成分组查询注意力,KV 头数降到八个,每 token 占用立刻变成 128 KB,同样两千 token 只要 256 MB。差距是四倍。所以选模型的时候,KV 头数这个指标的重要性不亚于参数量,它直接决定了你的服务能承载多少并发。
3.2 从显存反推合理的最大并发
有了单 token 占用,就可以倒推。假设一张 80 GB 的加速卡,模型权重半精度占 14 GB,框架和运行时开销算 4 GB,剩下 62 GB。这里不能全给 KV 缓存,因为块管理会有内部碎片,长请求的峰值需求也会波动,我一般再留 20% 的安全余量,实际可用大约 50 GB。
用上面分组查询那个版本,每 token 128 KB,假设你的服务约定最大上下文四千 token,那么单请求最坏情况占 4000 × 128 KB = 500 MB。50 GB 除以 500 MB,理论上限是一百个并发。但这个数是按最坏情况算的,实际流量里大部分请求远到不了四千,如果按最坏情况配置,绝大多数时候显存是闲着的。
更实际的做法是按分位数来配。统计你线上流量的长度分布,取 P95 或者 P99 作为设计点,再留一点弹性。比如 P99 是两千 token,单请求 250 MB,50 GB 可以支持两百个并发。剩下的风险靠抢占机制兜底,真来了超长请求就临时把别的请求换出去,虽然会带来延迟抖动,但比直接拒绝服务要好。
这里还有一个隐性成本必须算进去:解码阶段的中间激活值。虽然比预填充小得多,但在大批量下也不是可以忽略的,尤其是批大小上百的时候,中间张量的显存占用能到几个 GB。做容量规划的时候如果只算权重和 KV,实际跑起来就会差那么一截,然后开始怀疑是不是有内存泄漏。
3.3 抢占、换出与重算,三种兜底策略怎么选
再精细的规划也挡不住流量突刺,所以必须有兜底机制。当新请求拿不到块,而运行集合又已经满了,调度器要做出选择,常见的三种做法各有代价。
第一种是换出到主机内存。把某个请求的 KV 块拷到主机侧的内存里,腾出显存给新请求,等它再次被调度时再拷回来。这个方案保留了全部计算成果,不浪费算力,代价是拷贝本身要时间,一次几 GB 的搬运在 PCIe 上可能要几十毫秒,而且主机内存的带宽和容量也是有限的,换出的请求多了会互相挤。
第二种是直接丢弃重算。把这个请求的 KV 全部释放,让它回到等待队列,下次被调度时重新做一遍预填充。这个方案实现最简单,不需要额外的内存池,代价是重复计算。对于短请求,重算成本可能比重载还低,因为预填充很快;对于长请求,重算一次可能要好几百毫秒,用户能明显感知到卡顿。
第三种是只保留前缀。丢弃一部分较远的 KV,保留最近的若干块,近似地继续生成。这个做法会损失一点生成质量,因为注意力看不到完整历史了,但在一些对质量不敏感的场景里可以接受,能换来稳定的吞吐。
我的建议是优先用换出,但要给换出设一个上限,比如最多同时换出请求总数的百分之十,超过就退化为重算。同时监控换出频率,如果这个指标长期高于阈值,说明你的并发配得太大或者请求长度估得太乐观,该调参数了,而不是靠兜底硬扛。
3.4 token 预算这个参数,调对了延迟曲线会好看很多
token 预算是混合批调度里的核心旋钮,它限制的是单个解码步里所有请求要计算的 token 总数。为什么需要它?因为预填充和解码的计算量差了两个数量级。一个三千 token 的预填充,计算量相当于三千个解码步的单个 token,如果不加限制,它一进来就会把这一步的耗时拉长几十倍。
所有的在线服务都在解码,本来每步只要十几毫秒,突然来一步要八百毫秒,表现出来就是所有用户的输出卡了一下。这种毛刺在监控上看就是一条尖刺,用户体验上是明显的"打字停顿"。
把预算设成一个固定值,比如两千零四十八个 token,那么每个解码步的计算量就有了上界,耗时也相对稳定。长请求的预填充会被切成多个块,分批塞进连续的解码步里,这个过程叫分块预填充。代价是这条长请求的首 token 延迟变长,因为它要等好几个解码步才填完,但换来的是所有已在线请求的流畅输出。
这个值的选取要看你的延迟目标。要极致的首 token 延迟,就调大预算,让预填充尽快完成,但解码会被拖慢;要平滑的流式输出,就调小预算,让预填充细水长流。我一般在推理服务里把它设在单请求平均上下文长度的两倍左右,实测下来首 token 和 token 间隔的 P99 都比较可控。
4. 一套可运行的简化实现
4.1 数据结构设计:请求状态机与块表
下面这套代码是我为了搞清机制自己写的教学版,跑不了生产负载,但把关键结构都摆出来了,照着改能省不少摸索时间。先定义请求对象,它需要携带输入、输出、当前状态和显存账本。
from dataclasses import dataclass, field from enum import Enum class State(Enum): WAITING = 1 # 在等待队列里 RUNNING = 2 # 正在参与解码 SWAPPED = 3 # 被换出到主机 FINISHED = 4 # 已结束 @dataclass class Request: rid: int prompt_ids: list max_new_tokens: int generated: list = field(default_factory=list) block_table: list = field(default_factory=list) state: State = State.WAITING arrival_ts: float = 0.0 def total_len(self): return len(self.prompt_ids) + len(self.generated) def is_finished(self): return (self.generated and self.generated[-1] == EOS_ID) \ or len(self.generated) >= self.max_new_tokens块管理器负责分配和回收,核心就是一个空闲队列加引用计数。块大小设成十六,每个请求按需申请。
class BlockManager: def __init__(self, num_blocks, block_size=16): self.block_size = block_size self.free = list(range(num_blocks)) self.ref = [0] * num_blocks def blocks_needed(self, n_tokens): return (n_tokens + self.block_size - 1) // self.block_size def can_allocate(self, req, extra_tokens=0): need = self.blocks_needed(req.total_len() + extra_tokens) return need - len(req.block_table) <= len(self.free) def allocate(self, req, extra_tokens=0): need = self.blocks_needed(req.total_len() + extra_tokens) while len(req.block_table) < need: blk = self.free.pop() self.ref[blk] = 1 req.block_table.append(blk) def free_request(self, req): for blk in req.block_table: self.ref[blk] -= 1 if self.ref[blk] == 0: self.free.append(blk) req.block_table.clear()这里省掉了前缀共享和写时复制,生产级实现要复杂得多,但扩展思路很直接:分配时先查哈希表看有没有相同前缀的块,有就加引用计数复用,写的时候判断引用计数大于一才复制。
4.2 调度主循环:一个解码步的完整决策
主循环把前面说的三段动作串起来。注意每步都要重新核算新 token 需要的块,因为生成一个 token 之后总长度涨了,可能跨过块边界需要新块。
def step(running, waiting, block_mgr, max_seqs, token_budget): # 第一段:回收 for req in list(running): if req.is_finished(): block_mgr.free_request(req) req.state = State.FINISHED running.remove(req) # 第二段:补位,按先进先出,遇阻即停 while waiting and len(running) < max_seqs: cand = waiting[0] # 预留一个块的余量给即将生成的 token if block_mgr.can_allocate(cand, extra_tokens=1): waiting.popleft() block_mgr.allocate(cand) cand.state = State.RUNNING running.append(cand) else: break # 第三段:组批,先满足解码,剩余预算给预填充 plan = [] budget = token_budget for req in running: if req.state == State.RUNNING and not req.generated: # 预填充请求,按预算切块 take = min(budget, len(req.prompt_ids)) if take == 0: continue plan.append((req, take, "prefill")) budget -= take elif req.generated: if budget >= 1: plan.append((req, 1, "decode")) budget -= 1 return plan这段代码有几个地方值得多说一句。补位时预留了一个 token 的余量,是因为准入通过的请求在下一步就要生成 token,如果不预留,立刻就会触发一次分配失败,白折腾一趟。组批时优先给已经在解码的请求分配预算,是因为它们的延迟最敏感,用户盯着屏幕等字呢;预填充请求本来首 token 就慢,再慢一点感知反而没那么强。
4.3 混合批怎么喂给模型:变长打包的坑
真正难的不是调度逻辑,是把长度不一的请求打包成一个批次喂进去。预填充的查询长度是几百,解码的查询长度是一,这两类放在一起,注意力的掩码完全不同,键值序列长度也各不相同。
常规做法是构造一个累积长度的索引数组,把所有的查询拼成一维,再记录每个请求的起点和长度。注意力计算时按索引切分,各自跟自己请求的键值块做计算。这套逻辑如果自己写内核,难度很高,通常的做法是调现成的变长注意力接口,或者干脆把预填充和解码分成两次前向。
分成两次前向的代价是两次启动开销,在批很小的时候影响不大,批大了就明显了。我实测过一个场景,把预填充和解码合并成一次前向,端到端吞吐能提升百分之十五左右,这个比例在成本敏感的服务里不算小。但如果你的负载里预填充占比很低,合并的收益就没那么明显,不值得为此把代码复杂度翻倍。
提示:自研时先做分次前向的版本跑通,确认调度和缓存管理没问题,再考虑合并。上来就啃变长打包,很容易在掩码上出错,而这类错误不会报异常,只会让生成结果变得莫名其妙。
4.4 压测方法与指标解读
压测这件事,方法比工具重要。很多人直接开一堆并发猛灌,然后看总吞吐,这个数字没意义,因为你不知道延迟长什么样。正确的做法是控制并发数,观察三个指标随并发的变化曲线。
| 指标 | 定义 | 关注点 |
|---|---|---|
| 首 token 延迟 | 请求发出到收到第一个 token | 反映预填充效率和排队时间 |
| token 间隔 | 相邻输出 token 的时间差 | 反映解码阶段的调度质量 |
| 端到端吞吐 | 每秒成功返回的 token 总数 | 反映整体成本效率 |
在低并发区,首 token 延迟基本等于预填充时间,曲线平缓;并发升高后开始排队,曲线抬头,但吞吐还在涨,这个区间是甜点区。继续加并发,首 token 延迟快速恶化,吞吐增长趋缓甚至停滞,说明已经进入抢占区,再压下去只会让所有请求都变慢。
我一般会找到吞吐曲线斜率明显变缓的那个点,把线上并发控制在这个点的七成左右,留出应对突刺的余量。另外压测时输入长度必须按真实分布来造,如果全用等长输入,测出来的数字会严重偏乐观,因为等长恰好是连续批处理最擅长的场景,异构长度下的表现完全是另一回事。
5. 常见问题与排查实录
5.1 吞吐上不去,但加速卡利用率也不高
这是最典型的症状。监控上看到加速卡利用率只有三四十,你以为是算力不够,想加卡,其实调度层有问题。我排查过的案例里,排在前两位的原因是并发上限配得太小和准入判断过严。
并发上限太小,通常是因为参数是从别的场景抄来的,没有按自己模型的 KV 占用重算。一个用了分组查询注意力的模型和一个标准多头注意力的模型,同样的显存能支持的并发差四倍,参数照抄必然吃亏。检查方法很简单,在压测里打印每步实际的批大小,如果它长期远低于你设的上限,且显存还有大量空闲块,那就是限得太死。
准入判断过严的常见表现是,明明有空闲块,但新请求就是进不来。这多半是因为准入时按最坏情况估算,把最大上下文长度当成了每请求必然占用。改成按当前实际需要加上一个预估生成长度来核算,放宽效果立竿见影。不过要小心别矫枉过正,我见过一个版本改成按当前长度核算,结果长生成任务跑到一半块不够用,频繁抢占,尾延迟爆炸。
还有一种隐蔽的情况是预填充把预算吃光了。如果你的 token 预算设得很小,而流量里长输入又很多,那么每个解码步的预算几乎全被预填充占掉,解码请求反而排不上号,表现出来就是吞吐低、token 间隔抖动大。这种情况要么调大预算,要么给预填充单独设一个上限。
5.2 尾部延迟突然飙高,像心电图
只要用了抢占,就一定会遇到这个问题。表现是绝大多数请求很正常,但每隔一段时间就有一批请求的延迟冲出天际,P99 是 P50 的十几倍。
先确认是不是抢占引起的。在调度器里加个计数器,记录每个请求被换出或者被重算的次数,压测后看分布。如果 P99 延迟的请求恰好对应抢占次数大于零的那些,基本可以锁定。接下来看抢占的触发原因,是块不够、还是并发满了。
块不够通常意味着并发配置超过显存实际能力,或者请求长度估计偏小。我的做法是把并发上限下调百分之二十,重测一次,如果抢占消失而吞吐只降了一点,说明之前的配置本来就在超载运行,牺牲一点吞吐换稳定性是划算的。如果下调后吞吐掉得很厉害,那就得考虑上换出机制,或者接受一定比例的抢占,通过优先级调度让重要请求不被换出。
还有一种尾延迟不来自抢占,而来自批次同步。有些实现里,一个超长请求进来后虽然被分块预填充了,但它的块分配请求会和大量解码请求争抢缓存管理器的锁,形成局部热点。这个问题的解法是把分配操作批量化,一次算清楚整批需要多少块,统一分配,而不是每个请求单独加锁。
5.3 跑几个小时就 OOM 一次,重启就好
偶发性的显存不足最难查,因为它不可复现。我踩过的坑里,一大半出在块的回收不彻底。比如请求被取消的时候,走了异常分支,没调用释放接口,那批块就永远泄漏了。跑得越久泄漏越多,最后爆掉。
排查方法是加一个泄漏检测:每处理完一千个请求,对比一下空闲块的数量和启动时的数量,如果持续下降就是漏了。定位到具体位置,重点看异常路径、超时路径、客户端断开连接这几条非正常结束的分支,这些地方最容易漏掉释放。
第二个常见原因是碎片化。块管理本身是按固定大小切的,理论上不该有外部碎片,但如果你的实现里还留着按需分配的大块,比如激活值的缓存池,那就可能出现"总量够但没有一块连续空间"的情况。这种情况的表现是空闲显存看着不少,但一分配就失败。解法是把所有分配都改成固定大小,或者用统一的内存池管理。
第三个原因是最大长度参数设得太乐观。序列长度上限设成三万二,每条请求在分配时都按这个上界预留,实际上线根本用不到,白白占着。这个参数应该按流量的真实上界来设,而不是照抄模型支持的极限。
5.4 问题速查表
| 现象 | 优先怀疑 | 快速验证 | 处理方向 |
|---|---|---|---|
| 利用率低、吞吐低 | 并发上限偏小 | 打印每步实际批大小 | 按 KV 占用重算上限 |
| 新请求迟迟进不来 | 准入估算过保守 | 看空闲块数与拒绝计数 | 改为按实际长度核算 |
| 输出卡顿、间隔抖动 | 预算被预填充吃光 | 统计每步预算消耗构成 | 调大预算或限制预填充占比 |
| P99 延迟异常高 | 抢占频繁 | 统计每请求抢占次数 | 降并发或启用换出 |
| 运行数小时后崩溃 | 块泄漏 | 对比空闲块数量变化 | 补异常路径的释放逻辑 |
| 显存够但分配失败 | 碎片或预留过大 | 看最大序列长度参数 | 统一切块、下调上界 |
6. 落地时的取舍与经验
6.1 什么场景其实不该用连续批处理
这话听着别扭,但确实存在。如果你的服务是严格单请求、低并发、要求确定性延迟的场景,比如某个离线任务每次只处理一条数据,那么连续批处理带来的复杂度完全不值。它的收益来自多请求共享计算资源,只有一条请求的时候,批大小恒为一,调度器做的全是无用功,还多了状态维护的开销。
另一个不太适合的场景是超长上下文加严格顺序依赖的推理。当单条请求的上下文长到接近显存上限,一个请求就占满了整张卡,根本没有空间塞第二条。这种情况下连续批处理退化成单请求串行,调度开销反而是纯亏。这种场景该考虑的是把上下文切分、或者用更省显存的注意力近似方案,而不是在批处理上做文章。
还有一类是对输出确定性要求极高的离线批处理。连续批处理引入了不同请求之间计算顺序的不确定性,虽然数学上结果应该一致,但浮点运算的累积顺序变化在极端情况下会让输出有细微差异。如果业务方要求同一输入必须逐字节一致,那就要慎重,或者用固定批次的模式跑。
6.2 和投机解码、前缀缓存放一起会怎样
这几个优化不是互斥的,叠起来用收益还能放大,但要注意它们的相互作用。
先说前缀缓存。连续批处理下,块是可复用的,如果两个请求的开头一样,比如共享同一段很长的系统提示,那么它们的预填充完全可以只算一次,块直接共享。这个组合的收益在大系统提示的场景里非常夸张,能把首 token 延迟砍掉一大半。实现上的关键是块表要支持引用计数,写的时候要做写时复制,别把共享的块改坏了。
再说投机解码。它的思路是用一个小模型先草拟几个 token,再用大模型一次性验证,验证通过就一次前进多个位置。这个机制跟连续批处理叠在一起,会让每个请求每步前进的 token 数不一样。调度器原本可以假设"每步每个请求推进一个位置",现在这个假设不成立了,块分配的逻辑要跟着改,要按最大可能的推进数来预留。如果不改,就会出现验证通过了但没地方放 KV 的尴尬情况。
实测下来,两者叠加的吞吐提升不是简单相加,而是介于两者单独收益之和与乘积之间,具体取决于草拟的接受率。接受率高的时候,相当于每个解码步做更多有效工作,批处理的效率也跟着上去。接受率低的时候,草拟的计算全是浪费,还会干扰批次的均匀性,反而可能拖累整体。
6.3 上线后该盯哪些指标,容量怎么留
调度层面的指标必须暴露出来,否则出了问题只能靠猜。我一般要求至少有这么几个:每步的批大小分布、等待队列长度、空闲块数量、抢占次数、预填充与解码的预算占比。这几个数配合起来看,基本能还原出系统当时的状态。
容量规划不要按平均值算。按平均值算出来的配置,在流量高峰一定撑不住,因为排队是非线性的。应该按峰值的并发请求数和峰值时的平均上下文长度来算,再乘一个一点三左右的安全系数。如果成本压力大,那就做分级,核心业务给足余量,非核心业务允许排队甚至降级。
还有一个容易被忽略的点是冷启动。服务刚开始跑的时候,缓存池是空的,第一批请求全部要预填充,这一刻的负载是最重的。如果启动时的并发配置和稳态一样,很容易在冷启动瞬间触发大量抢占。稳妥的做法是启动时把并发上限设低一点,跑几十秒预热之后再放开。
我个人在实际操作中的体会是,连续批处理这套东西,原理看一遍就能懂,真正难的是参数和边界条件。同一份代码,参数调得好和调不好,吞吐能差一倍以上。所以别急着上生产,先用真实长度的流量压够时间,把抢占率、块利用率、尾延迟这三个数摸清楚,再决定参数。还有个小技巧,把每个请求的完整生命周期日志打出来,包括等待时长、预填充时长、被抢占次数、总生成时长,出问题的时候翻日志比看监控曲线有用得多,因为你能看到单条请求到底经历了什么,而不是只看一个聚合后的平均值。