continuous-batching-core-idea

你以为“把请求凑成大批次”就一定更快?在LLM推理里,这个直觉经常害人。很多团队一味加大batch size,结果GPU吃满了,吞吐量却上不去,p99延迟还莫名飙升。问题往往不在模型,而在调度器:批次怎么组、token怎么分配、KV缓存怎么抢,这些细节决定了你能不能吃满一块A100。

传统批处理:为什么在LLM上突然失灵

批处理在经典模型里到底做了什么

在传统机器学习推理中,一个批次可以被看成一个规则的矩阵。每条样本被填充或截断到相同长度,堆叠成一个张量,一次前向传播就能为每一行给出一个预测结果。每一行的计算成本几乎一致,所有行同时开始、同时结束,而且在前向传播启动前,张量的形状是完全已知的。

批处理的真正价值在于摊薄权重读取成本。模型权重从高带宽内存(HBM)加载到GPU是一个固定开销,据一些部署经验数据,单次权重加载可以占到推理时间的30%–40%。把64行输入堆在一次前向里共享这次读取,就能显著提升吞吐量:

traditional-ml-batch-as-matrix

实际部署时的调度器逻辑非常简单:等到凑满一个批次,跑一遍前向,返回结果,然后开始下一个批次。请求之间没有跨批次的状态传递,也不存在“半算完”的请求在GPU里长期占坑。这种模式在图像分类、CTR预估等场景里已经被验证了很多年。

LLM解码为什么打破了这套逻辑

LLM的解码过程完全不按这个剧本走。底层机制是:每次前向传播,每个序列只生成一个token,因此一个请求需要的前向次数等于它最终输出的token数,而这个数量只有在模型发出停止token时才知道。填充在这里帮不上忙,因为不匹配的不是输入宽度,而是每个请求在GPU上“待多久”。

llm-decoding-one-token-per-pass

如果把一个生成30个token的请求和一个生成400个token的请求放在同一个固定批次里,前者会在第30步就结束,但它的位置会一直被占用到第400步。GPU仍然要为这个几乎空闲的批次支付完整的权重读取和算子开销。真实流量里,输出长度差异往往非常大,有用户反馈在聊天场景中,p95输出长度可以是p50的3–5倍,这种浪费就被放大了。

waste-from-varying-output-lengths

传统批处理假设“大家一起上车、一起下车”,而LLM更像是每个人在不同站点下车。如果你不在中途补人,后面几十站都在开“空车”。

很多团队一开始用静态批处理跑LLM,看到GPU利用率不高、吞吐量却上不去,就误以为是模型太大或显存不够,其实调度方式才是关键瓶颈。

连续批处理:把“批次”变成一个流

连续批处理的核心想法

连续批处理是目前大多数LLM推理引擎采用的解决方案,vLLM、SGLang、TGI、TensorRT-LLM等都默认启用。它的核心思想是:在每一次前向传播时动态决定批次成员,而不是在批次开始时一次性锁死。已经完成的请求在下一次前向传播时离开,等待中的请求立刻顶上空位,避免GPU为“空洞”付费。

serving-engines-using-continuous-batching

从系统视角看,这依赖一个迭代级调度器:每次前向传播结束后,控制权回到调度器,由它重新决定下一步要跑哪些请求、每个请求分配多少token。这个循环不断重复,批次在每一步都被“重建”,而不是一次性固定下来。

continuous-batching-core-idea

我第一次在生产环境打开连续批处理时,直观感受是:GPU利用率曲线变得更平滑了,尾部延迟也不再动不动炸到几秒以上。说实话,当时我也不太确定是哪个细节起了决定性作用,后来对着调度日志和KV缓存指标看了几天,才慢慢把这套机制摸清楚。

传统调度器 vs LLM调度器

在传统推理场景里,调度器的任务很简单:

  • 收集请求,填满一个批次;
  • 把批次送进模型做一次前向;
  • 拿到结果,返回给调用方;
  • 重复上述过程。

batching-amortises-weight-read

LLM推理则多了三件事:

  • 每次前向只生成一个token;
  • KV缓存要在多次前向之间传递状态;
  • 请求需要多少步前向,只有在生成停止token时才知道。

这意味着调度器不再只是“打包输入”,而是要在整个生命周期里跟踪每个请求的进度、KV缓存占用和token预算。

选择性批处理:把能一起算的先打包

展平token流:批处理的“新单位”

如果每一步都要重建批次,会遇到一个新问题:不同请求此刻的张量形状可能完全不同。比如,一个请求正在预填充4096个token,另一个只是在解码第900个token,这两者没法像传统那样直接堆叠成一个矩阵。

解决思路叫“选择性批处理”。做法是:在每个调度步骤中,把所有要计算的token,不管来自哪个请求,全部展平成一个长序列,形状大致是(总token数 × 隐藏层大小)。

selective-batching-flattened-stream

像层归一化、QKV投影、前馈网络这类对每个token独立操作的算子,其实并不关心token属于哪个请求,它们可以一次性高效处理整个展平序列。这样就把“批处理”的单位,从“请求”换成了“token”,只要总token数足够大,GPU就能吃满。

注意力层的特殊处理

注意力机制没法这么粗暴地展平处理。每个token只能关注同一请求的早期token,而每个请求的KV缓存长度都不一样。如果直接在展平序列上做注意力,会把不同请求的上下文混在一起,语义就乱了。

attention-split-per-request

实际做法是:

  • 在进入注意力层前,把展平序列按请求切分;
  • 针对每个请求,使用它自己的KV缓存独立执行注意力;
  • 把各自的输出再拼回展平序列,交给后续可批处理的算子。

这种“能一起算的就打包,不能一起算的就拆开”的策略,就是选择性批处理的精髓。很多人以为连续批处理只是调度层的小优化,忽略了模型执行路径里这种细粒度的改造,结果在自研引擎时总感觉性能差一截。

一次调度步骤:vLLM V1的四个动作

第一步:设定本步token预算

调度器在两次前向传播之间要回答一个问题:下一步运行哪些请求,每个请求分配多少token。以vLLM的V1调度器为例,这个过程被拆成四步。第一步是设定本步预算:

  • max_num_batched_tokens:本次调度可分配的token总数;
  • max_num_seqs:本次最多同时运行的序列数。

这两个参数相当于给GPU画了一个“算力和显存的盒子”,后续所有分配都不能超出这个盒子。很多线上服务的延迟/吞吐量调优,其实就是在反复试探这两个数字的边界。

第二步:优先照顾正在运行的请求

接下来,调度器会优先把预算分配给已经在运行的请求。每个请求会记录两个数字:

  • num_computed_tokens:已经计算过的token数;
  • num_tokens_with_spec:目标token数(包括预填充和解码)。

调度器会按“缩小差距”的原则分配token:

# 简化自 vllm/v1/core/sched/scheduler.py

tokens_this_step = min(
    request.num_tokens_with_spec - request.num_computed_tokens,
    remaining_token_budget,
)

预填充和解码在这里被统一处理,分块预填充和前缀缓存也不需要额外分支逻辑。如果预算不足以一次性分配完所有token,就拆成多步分配:

chunked-prefill-and-prefix-caching

这种“先照顾老请求,再考虑新请求”的策略,能避免长请求被饿死,也能让已经跑到一半的预填充尽量一次性完成,减少中途被打断的概率。

第三步:为分配好的token预留KV缓存

在确定了每个请求本步要算多少token之后,调度器会立刻为这些token预留KV缓存块。关键点在于:预留发生在调度决策期间,而不是等到GPU执行时再临时申请。如果缓存块不足,调度器会从最近运行的一些请求中回收缓存块,这就引出了后面的抢占机制。

这种“先算好内存,再下发任务”的做法,能避免GPU执行到一半才发现显存不够,被迫报错或同步等待。很多线上“偶发性超时”问题,追根溯源就是内存管理和调度顺序没处理好。

第四步:用剩余预算启动新请求

当正在运行的请求都分配完本步token后,如果还有剩余预算,调度器会用它来启动等待队列中的新请求。新请求会拿到一部分token预算和对应的KV缓存块;如果预算已经用光,本步就不会再启动新请求。

scheduler-output-to-gpu

最终,调度器会输出一个“执行计划”给GPU:每个请求本步要算多少token、这些token对应哪些KV缓存块。GPU只管按这个计划执行前向传播,执行完再把控制权交还给调度器,进入下一轮循环。

Token预算:延迟和吞吐量的拨盘

一个参数,两个维度的影响

每一步的执行时间,主要由本步要处理的token总数决定。因此,max_num_batched_tokens既是延迟参数,也是吞吐量参数。需要注意的是,这个参数是“本步所有请求token数之和”,而不是单个请求的上限:

  • 解码请求每步贡献1个token;
  • 预填充请求每步贡献它本步分配的token数。

token-budget-latency-vs-throughput

据一些公开测试数据,在A100上把max_num_batched_tokens设在约2048时,单步执行时间较短,交互式对话的延迟体验很好,但GPU经常吃不满;把这个值拉到16384左右,GPU利用率接近饱和,吞吐量可以提升数倍,但批次中的所有请求都要等待更长时间才能拿到下一个token。

预算还会改变调度优先级

预算设置不仅影响“算多快”,也会改变“先算谁”。在vLLM默认启用分块预填充时,调度器会优先满足正在解码的请求,把剩余预算分配给预填充。这样做的好处是:

  • 交互式对话的token流更顺滑,用户感知延迟更低;
  • 大段预填充被拆成多个小块,减少一次性占用过多KV缓存的风险。

但代价是:在极端高并发、长上下文的场景里,大量预填充会被切得很碎,调度器和KV缓存管理的开销会变大。如果你在做批量离线生成任务,可能需要反过来调优,让预填充拿到更高优先级。

抢占:吞吐量的隐形杀手

抢占是怎么发生的

当调度器在第三步为token预留KV缓存块时,如果发现缓存池已经不够用,就会触发抢占。具体动作是:

  • 选择某个正在运行的请求,释放它占用的缓存块;
  • 把这个请求标记为“被抢占”,将num_computed_tokens重置为0;
  • 清除它的推测token,把请求重新放回等待队列前端。

preemption-resets-computed-tokens

重置为0意味着巨大的隐性成本。比如,一个请求已经预填充了3900个token,被抢占后,这3900个token需要重新计算一遍。如果负载持续偏高,这个请求可能会经历“抢占 → 重新入队 → 再次抢占”的循环,GPU在重复做已经做过的工作。

抢占对延迟的真实影响

vLLM在V1版本中默认采用“重新计算抢占”模式,弃用了旧的交换路径。外部表现看起来像是:GPU资源不够用,负载一高,整体延迟就上升,很多人第一反应是“要不要再加几块卡”。

latency-climbs-under-preemption

但如果你去看vLLM暴露的Prometheus指标total_cumulative_preemption_cnt,往往会发现抢占次数在高负载时急剧上升。平均延迟可能变化不大,因为大部分请求没被抢占,问题主要集中在尾部延迟(p99、p999)。有一次我排查线上抖动,就是先看p99曲线,再对比抢占计数,几乎是同步抬头。

三个关键设置,缓解抢占问题

要减少抢占带来的浪费,可以从三个方向调参:

  • 提高 gpu_memory_utilization:让更多显存用于KV缓存池,减少因缓存不足触发的抢占;
  • 降低 max_num_seqs:减少同时并发的序列数,降低KV缓存峰值占用;
  • 提高 tensor_parallel_size:把权重切分到多块GPU上,每块设备能腾出更多空间给KV缓存。

这些调整并不是免费的:

  • 提高gpu_memory_utilization可能挤压权重或激活的空间,增加OOM风险;
  • 降低max_num_seqs会牺牲并发度,影响整体吞吐量;
  • 提高tensor_parallel_size会增加跨GPU通信开销,对小模型反而不划算。

所以更现实的做法,是先用抢占计数和p99延迟确认问题,再小步调整参数,观察一两轮流量波动后的效果,而不是一上来就把参数拉满。

连续批处理带来的真实收益

一个典型基准:23倍吞吐量从哪来

把前面的机制串起来,可以看到连续批处理其实就是一个循环:

  • 调度器在每次前向传播前重建批次;
  • 按固定token预算分配给各个请求;
  • 在决策时预留KV缓存块,决定GPU本步的真实工作量。

vllm-throughput-benchmark

Anyscale在OPT-13B上的公开基准显示:在输出长度差异很大的场景下,静态批处理的吞吐量会掉到大约81 tokens/s,而vLLM在同一块A100上可以做到传统Hugging Face推理的约23倍吞吐量提升。模型本身并没有变快,真正的差别来自调度器和KV缓存管理。

从我自己的观察看,很多团队在“模型选型”和“量化方案”上花了大量时间,却忽略了调度层的优化空间。结果就是:换了更大的模型、做了更激进的量化,吞吐量还是上不去。如果你现在正被p99延迟或QPS卡住,调度器往往比模型本身更值得优先排查。

那些看起来“玄学”的性能抖动,大多都能在调度日志和抢占指标里找到解释。把这套判断方法留在手边,用得上的时候会比问十个朋友都更直接。

常见问题

Q:如何判断我的服务是否需要开启连续批处理?

A:如果你的LLM服务存在明显的输出长度差异(比如聊天、代码生成、文案创作),并且在高并发时GPU利用率不高但延迟偏大,就非常适合使用连续批处理。原因在于静态批处理会让短请求为长请求“陪跑”,GPU在后半程大量做无效计算,而连续批处理可以在请求结束后立刻填补空位。建议先在预发环境开启连续批处理,对比同一流量下的吞吐量、p95/p99延迟和GPU利用率,如果吞吐量显著提升且尾部延迟更稳定,就可以逐步放量到生产。

Q:max_num_batched_tokens 应该怎么选,才不会把延迟拉爆?

A:可以先从一个相对保守的值开始,比如略高于你典型请求的预填充长度,再根据延迟和吞吐量曲线逐步调大。原因是这个参数直接决定每步前向的耗时:值太小,GPU吃不满,吞吐量上不去;值太大,每步执行时间拉长,所有请求的下一个token都会等更久。比较实用的做法是:在压测环境下固定并发,分别测试几个阶梯值(如2k、4k、8k、16k),记录QPS、p95/p99延迟和GPU利用率,选取“延迟还能接受、吞吐量明显提升”的那个点作为起始配置。

Q:抢占次数很多时,一定要加卡吗?

A:不一定,很多时候可以先通过调参缓解。抢占次数高说明KV缓存池经常不够用,调度器被迫反复回收并重算部分请求,导致尾部延迟飙升。直接加卡当然能缓解,但成本很高。更精细的做法是:先提高gpu_memory_utilization,让更多显存用于KV缓存;如果仍然频繁抢占,再适度降低max_num_seqs,减少并发序列数;在多卡环境下,还可以适当提高tensor_parallel_size,为每块卡腾出更多缓存空间。每次调整后都要观察一段时间的p99延迟和total_cumulative_preemption_cnt,确认趋势再决定是否继续扩容。

Q:预填充和解码混在一起,会不会让交互式对话变慢?

A:如果调度器没有区分优先级,确实可能出现大批量预填充“挤占”解码预算的情况,导致对话场景的token间隔变长。很多现代引擎(如vLLM)会在调度策略里优先满足解码请求,把剩余token预算分配给预填充,从而保证交互体验。判断是否被预填充拖慢,可以观察在混合负载下的token间隔时间(如每个新token到达客户端的间隔),如果明显长于纯对话场景,就需要调整调度策略或限制单批次预填充的规模。实操上,可以通过分队列或限流,把大段预填充任务与在线对话隔离开。

Q:如何快速定位是模型慢,还是调度器配置不当?

A:可以从三个信号入手:GPU利用率、抢占计数和静态小批次基准。先在单请求、小batch(如batch=1)下跑一轮基准,记录单位token的平均耗时,作为“模型本身速度”的参考;再在真实流量下观察GPU利用率,如果利用率长期低于60%,但延迟依然偏高,多半是调度或批处理策略有问题。接着查看total_cumulative_preemption_cnt和p99延迟,如果抢占次数在高负载时明显上升,而平均延迟变化不大,就说明尾部请求被反复重算。此时优先调参(token预算、并发序列数、KV缓存比例),只有在GPU利用率已经接近饱和、抢占次数也不高时,再考虑模型压缩或加卡扩容。

写到这里,其实还留着不少可以展开的细节,比如多租户场景下的公平性、推测解码和连续批处理的组合效果,这些都值得单独聊一篇。如果你现在正卡在“延迟和吞吐量怎么两头兼顾”的选择上,不妨先按文里的思路把调度器这层摸清楚,很多看似棘手的问题,会在日志和几个关键指标里慢慢变得有迹可循。