批处理策略:动态批处理大小、请求排队与优先级调度、最大批处理延迟控制

批处理,说白了就是“攒一波再干”。

在vLLM里,这是控制成本最直接的手段。GPU算力很贵,你让它一次只处理一个请求,那跟开法拉利去菜市场买菜没啥区别——浪费。我刚开始做推理优化时,就犯过这个错。当时一个客户抱怨延迟高,我一看监控,GPU利用率不到15%。嗯,问题找到了。

动态批处理大小:别死脑筋

固定批处理大小,是最容易想到的方案。但实际跑起来,你会发现它很蠢。

为什么?因为请求大小不一样。有的请求就几个字,有的请求能写一篇论文。你固定batch size=8,遇到小请求时,GPU算力闲置;遇到大请求时,显存直接爆掉。

我个人习惯的做法是:动态调整

核心思路:根据当前请求的token长度总和,动态决定这次批处理能塞多少个请求。

具体来说,我会设置两个阈值:

  • 最大token数上限:比如4096个token。所有请求的token加起来不能超过这个数。
  • 最大请求数上限:比如64个请求。防止请求太小导致调度开销过大。

代码实现其实不复杂:

def dynamic_batch_size(requests, max_tokens=4096, max_requests=64):
    batch = []
    total_tokens = 0
    
    for req in requests:
        req_tokens = len(req['input_ids'])
        if total_tokens + req_tokens <= max_tokens and len(batch) < max_requests:
            batch.append(req)
            total_tokens += req_tokens
        else:
            # 当前批次满了,先处理
            process_batch(batch)
            batch = [req]
            total_tokens = req_tokens
    
    if batch:
        process_batch(batch)

我在项目中遇到过一个问题:有些请求特别长,比如几千个token。如果它一来就把整个batch占满了,其他小请求就得等着。后来我加了个最大请求长度限制,超过512token的请求单独处理,或者拆分成多个小请求。效果好了很多。

请求排队与优先级调度:谁先谁后,得有个规矩

请求多了,就得排队。但排队不是简单的先来后到。

你想想看,一个用户在线聊天,他等不了3秒。但一个后台批量生成报告的任务,等个30秒也无所谓。如果一视同仁,那在线用户全跑了。

我建议的做法是:多级优先级队列

优先级 适用场景 最大等待时间
P0(最高) 在线推理、实时对话 500ms
P1 半实时任务、数据分析 5s
P2 批量处理、离线生成 30s

调度逻辑也很直接:

  • 每次取batch时,优先从P0队列取。P0队列空了,再从P1取。
  • 但如果P1队列里的请求等了太久(超过最大等待时间),就把它提升到P0。
  • 这叫优先级老化,防止低优先级请求永远得不到处理。

小技巧:我曾经遇到一个坑——P0请求太多,导致P2请求一直饿死。后来我加了个“最低保障”机制:每处理10个P0请求,必须处理1个P2请求。这样既保证了实时性,又不至于让后台任务完全卡死。

最大批处理延迟控制:别让请求等到天荒地老

动态批处理有个副作用:你为了攒够一个batch,可能会让请求等很久。

比如现在只有1个请求,你非要等到凑够8个才处理。那这个请求就得等。如果后续请求一直不来,它可能等上好几秒。这在在线场景里是致命的。

解决方案很简单:设置一个最大等待时间

import time

class BatchScheduler:
    def __init__(self, max_batch_size=8, max_wait_ms=200):
        self.max_batch_size = max_batch_size
        self.max_wait_ms = max_wait_ms
        self.queue = []
        self.last_process_time = time.time()
    
    def add_request(self, request):
        self.queue.append(request)
        now = time.time()
        wait_time = (now - self.last_process_time) * 1000
        
        if len(self.queue) >= self.max_batch_size or wait_time >= self.max_wait_ms:
            self.process_batch()

这里有个权衡:max_wait_ms设得太小,batch size上不去,吞吐量低;设得太大,延迟高。

我个人习惯的做法是:动态调整max_wait_ms。如果当前系统负载低,我就把等待时间设长一点,多攒几个请求;如果负载高,我就缩短等待时间,优先保证延迟。

注意:我曾经在生产环境里踩过一个坑——把max_wait_ms设成了500ms,但某个时段请求量突然暴增,导致batch size一直达不到上限,每个请求都等了500ms才被处理。后来我改成“如果队列长度超过某个阈值,立即处理”,才解决了这个问题。

三者如何配合?一张图说清楚

动态批处理大小、请求排队与优先级调度、最大批处理延迟控制,这三者不是孤立的。它们需要协同工作。

批处理策略核心流程 请求到达 优先级队列 P0 P1 P2 优先级老化 + 最低保障机制 动态批处理大小 最大token数上限: 4096 | 最大请求数上限: 64 最大等待时间: 200ms(动态调整) GPU推理执行

流程其实很清晰:

  1. 请求先进入优先级队列,按P0/P1/P2分类排队。
  2. 调度器从队列中取请求,按照动态批处理大小的逻辑组装batch。
  3. 如果等待时间超过最大批处理延迟,不管batch满没满,直接处理。

这三者配合好了,你就能在延迟吞吐量之间找到一个平衡点。我个人经验是:先保证延迟满足SLA,再尽量提高吞吐量。毕竟用户等不起,但GPU可以多跑一会儿。

总结一下:

  • 动态批处理大小——别死板,根据请求大小灵活调整。
  • 请求排队与优先级调度——分清楚轻重缓急,别让重要请求等着。
  • 最大批处理延迟控制——设置一个底线,别让任何请求无限等待。

这三板斧砍下去,你的vLLM推理成本至少能降30%。我亲测有效。


公众号:蓝海资料掘金营,微信deep3321