动态批处理的“无人区”:模型服务化部署中的调度博弈与显式控制

这不是一篇关于“什么是动态批处理”的文章

如果你期待看到“动态批处理显著提升了吞吐量”这类正确的废话,现在可以关闭页面了。

过去两年,从HuggingFace的文本生成推理库到各大云厂商的MaaS平台,动态批处理(Dynamic Batching)几乎成了大模型服务化部署的标配。但“会用”和“搞定”之间,隔着一条叫作“生产环境”的深沟

当请求分布从理想的“均匀泊松流”变成“长尾+突发”,当序列长度从固定值变成[1, 32768]的随机变量,当SLO从“尽力而为”变成“P99 < 100ms”——那些在演示环境跑得很美的代码,开始批量暴露问题:OOM不期而至、高优先级请求被长序列堵死、吞吐量上去了延迟却失控了

这不是优化技巧的匮乏,而是调度哲学的失位。本文试图穿透“动态批处理”这层糖衣,深入三个核心命题:

  1. 决策论命题:批处理本质上是一个序贯决策问题,为何绝大多数实现却只用if-else?
  2. 异构负载命题:当连续批处理遭遇极长序列,为何吞吐量提升23倍的同时,P99延迟可能恶化?
  3. 资源抽象命题:我们究竟是在调度“请求”,还是在调度“Token”?

一、从“凑满”到“对齐”:动态批处理的范式跃迁

1.1 静态批处理的“对齐税”

传统静态批处理存在一个隐性的成本项——我称之为**“对齐税”(Alignment Tax)**。

当序列长度分别为[128, 1024, 4096]的三个请求被塞进同一个批次,GPU必须将所有序列填充至最大长度4096。这意味着:

  • 短序列(128)实际只有3%的计算是有效的,97%的算力在填充位(Padding)上做无效的浮点运算;
  • KV缓存按最大长度预分配,短序列的显存利用率极低;
  • 更致命的是,长序列阻塞了调度器——整个批次必须等最慢的那个生成完毕才能释放资源。

这并非技术能力不足,而是抽象层次的错配:我们在硬件层面追求SIMD的整齐划一,却在请求层面放任长度自由。这种错配在BERT时代尚可容忍(输入被严格截断至512),但在上下文窗口动辄32K的大模型时代,已构成系统性危机。

1.2 连续批处理的本质是“取消等待”

连续批处理(Continuous Batching)的真正贡献并非“动态调整批次大小”,而是将批处理从“同步屏障”模式转变为“流水线”模式

传统批处理隐含一个同步点:必须等当前批次所有请求的prefill+decoding全部完成,才能释放资源给下一批。连续批处理之所以能实现23倍吞吐量跃升,是因为它通过迭代级调度(iteration-level scheduling)把这个同步点拆碎了——每生成一个Token,都重新评估“谁该进入这个批次”。

但这种拆碎是有代价的。

图1:连续批处理的迭代级调度。每个小方块代表一个请求的单次解码迭代,深色表示计算,浅色表示内存访问。通过交错执行,消除了传统批处理末尾的长尾空闲。


二、决策形式化:动态批处理为何需要控制论视角?

2.1 当下实现的“短视”困境

翻阅当前主流推理框架的源码,动态批处理的决策逻辑大多呈现以下模式:

def form_batch():
    if len(high_pri_queue) > 0:
        batch = pop_n(high_pri_queue, max_batch_size)
    elif len(normal_queue) > 0 and time_since_last_batch > delay_threshold:
        batch = pop_n(normal_queue, max_batch_size)
    else:
        wait()  # 或者直接返回空

这是一种贪婪策略(Greedy Policy):最大化当下批次的填充率,或最小化当下请求的等待时间。但它没有回答一个更本质的问题:如果为了凑满当前批次而延迟5毫秒,这5毫秒的等待能否在未来的吞吐收益中得到补偿?

这正是控制论视角的切入点。

2.2 SMDP形式化:批处理的序贯决策本质

将动态批处理系统建模为半马尔可夫决策过程(Semi-Markov Decision Process),我们可以得到更严格的表述:

  • 状态空间:当前队列长度、各请求已等待时间、剩余序列长度分布、可用显存容量;
  • 动作空间:立即执行批次(选择哪些请求)、继续等待(等待时长);
  • 奖励函数:负的加权和——(平均响应时间 × α)+(平均功耗 × β)+(SLO违例惩罚);
  • 转移概率:新请求到达率的概率分布、各阶段计算耗时的分布。

求解这个SMDP可以得到一个显式的最优策略:在何种系统状态下,即使批次未满也应该立即执行;在何种状态下,即使批次已满也应该继续等待更高优先级的请求

学术界已有研究表明,这种SMDP策略在不同参数配置下均显著优于固定阈值策略。但业界实现几乎从未采用。原因很直接:状态空间爆炸,在线求解不现实。

2.3 工程妥协:从“最优”到“近似最优”

真正的破局点在于状态聚合(State Aggregation)

将连续的系统状态(如队列长度分布)离散化为若干“宏状态”,例如:

  • 状态A:队列长度 < 8,且无长序列(>2K tokens);
  • 状态B:队列长度 ≥ 8,且无长序列;
  • 状态C:存在长序列(>4K tokens),且长序列位于队首;
  • 状态D:显存压力 > 85%;

针对这4~8个宏状态,通过离线仿真或强化学习预计算出最优动作映射表。运行时只需查表,决策延迟可控制在微秒级,却保留了SMDP策略的长期收益意识

这不再是“动态批处理”,而是显式策略控制


三、异构负载下的分桶策略:对抗“极端值污染”

3.1 长尾分布的调度灾难

当请求长度呈现幂律分布(大量短请求+少量极长请求),连续批处理会暴露出一个致命缺陷:长请求污染调度队列

假设一个长请求(4096 tokens)正在解码。由于连续批处理允许新请求随时加入,后续短请求会源源不断地被塞进这个批次。结果:

  • 长请求因批次变大,每次迭代的计算延迟增加;
  • 短请求虽“加入”了批次,却必须等长请求的本次迭代完成才能拿到第一个Token;
  • 短请求的尾部延迟(tail latency)急剧恶化

实测数据显示,在长短混合负载下,短请求的P99延迟可能比纯短请求场景高出3-5倍。这不是资源不足,而是调度公平性的失效

3.2 BucketServe的解法:物理隔离与优先级反转

近期提出的**分桶批处理(Bucket-Based Batching)**提供了另一种范式:不追求最大程度混批,而是在“相似长度”的请求内部组成同质批次

核心设计如下:

class BucketManager:
    def __init__(self, bucket_boundaries=[64, 256, 1024, 4096]):
        self.buckets = {f"{low}-{high}": [] 
                        for low, high in zip([0]+bucket_boundaries, bucket_boundaries)}
        self.active_batch = None
    
    def assign_request(self, request):
        length = len(request.input_ids)
        for bucket_name in self.buckets:
            low, high = map(int, bucket_name.split('-'))
            if low <= length < high:
                self.buckets[bucket_name].append(request)
                break
    
    def schedule_batch(self):
        # 优先服务高优先级桶(通常是小请求桶)
        for bucket_name in sorted(self.buckets.keys(), key=lambda x: int(x.split('-')[0])):
            if len(self.buckets[bucket_name]) >= self.min_batch_size:
                return self._pop_batch(bucket_name)
        # 无桶达到阈值,则从最长等待桶取
        return self._pop_oldest()

这种设计的精髓在于:

  1. 显式隔离:长请求不再污染短请求的调度窗口;
  2. 填充率优化:同长度组批,无需padding或极少padding,有效算力利用率提升;
  3. 优先级反转:在长请求桶资源紧张时,可将长请求暂时挂起,优先执行短请求桶——这是传统连续批处理无法做到的

实验表明,在80% SLO达标率约束下,分桶策略可承受的请求负载是对照系统的1.93倍。这不是量变,是质变

[示意图:传统混批模式与分桶批处理模式的对比。左侧是混批,长短请求交错导致短请求等待;右侧是分桶,短请求形成独立批次快速输出,长请求被分到独立桶处理]
(https://via.placeholder.com/800x400?text=Bucket+Batching+vs+Mixed+Batching+-+Short+Requests+Bypass+Long+Ones)

图2:分桶批处理与混合批处理的调度对比。左侧混合批处理中,短请求(绿色)被长请求(红色)阻塞;右侧分桶策略通过物理隔离实现短请求快速响应。


四、面向“Token”的调度:量纲统一的资源抽象

4.1 请求是不平等的

如果说动态批处理2.0解决了“何时批”的问题,分桶策略解决了“与谁批”的问题,那么下一个命题是:我们究竟在以什么为单位分配资源

当前几乎所有调度器都以“请求”为粒度。这是错误的抽象

一个10K tokens的长请求和一个100 tokens的短请求,计算量相差两个数量级。但在大多数调度器中,它们在队列里各占一个槽位。这就像操作系统以“进程”为单位分配CPU时间片,却不考虑进程是计算密集型还是I/O密集型——这种粗放调度早在20年前就被Linux的CFS调度器抛弃了。

4.2 Token记账与显式成本

更合理的量纲是Token

具体实现路径:

  1. 预算预分配:每个租户/每个优先级等级获得每秒X个Token的计算预算;
  2. 按Token计费调度:调度决策时,比较的不是“队列中有几个请求”,而是“队列中所有请求的剩余Token数”;
  3. 显式成本反馈:当用户提交超长请求时,系统返回预期延迟和Token消耗,而非默默死扛。

这种量纲统一带来的不仅仅是公平,更是可预测性。在一个Token感知的调度器中,你可以回答“这个请求为什么等了2秒”——因为系统日志会显示:该请求进入队列时,前面还有1.2M个Token等待处理。


五、设计哲学升维:从“优化”到“显式契约”

写到这里,我想提出一个可能引起争议的观点:

过去五年,模型服务化部署的进步主要是“隐藏复杂性”——让用户以为推理很便宜、很快、很弹性。下一个五年的进步,应当是“显式暴露契约”——让用户为自己的请求特征支付对等的延迟成本。

动态批处理不应是黑盒里的魔术,而应是:

  • 可解释的:系统能告诉你当前批处理策略是什么,以及为什么;
  • 可干预的:高优先级任务可以“插队”,但需明确插队的代价;
  • 可问责的:当SLO违例时,能追溯到是调度策略失效,还是资源不足,还是请求本身过于极端。

这需要我们在“效率”之外,重新引入“确定性”作为核心设计目标。


写在最后

我见过太多团队把动态批处理当成了一个“开箱即用”的加速插件。他们困惑于:为什么同样的吞吐量,别人的P99是80ms,自己是800ms?

答案往往不是代码写得不够高效,而是对调度问题的理解停留在“凑满批次”的层面

从静态批处理到连续批处理,是第一次认知跃迁——从“同步”到“异步”。
从连续批处理到分桶/显式策略调度,是第二次认知跃迁——从“贪婪”到“规划”。
而面向Token的调度,是正在发生的第三次跃迁——从“请求平等”到“成本显式”。

搞定模型服务化部署中的动态批处理,从来不是一个技术问题,而是一个认识问题。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐