一、先搞懂:为啥在线推理服务会“卡成狗”?
很多做AI在线服务的人都遇到过这种情况:明明模型本身的计算速度稳定,比如一个10层的小模型,单条请求算一次要50毫秒,可实际跑在线服务时,有时一条请求100毫秒就返回,有时却要等500毫秒,甚至更久——这种“忽快忽慢”的问题,就是业内说的“时延抖动”。
很多人第一反应是“模型计算慢”“服务器算力不够”,但今天要讲的核心观点是:这种抖动,大概率和模型本身没关系,反而和你用的推理框架的“批量处理逻辑”有关。
先给大家补个基础认知:在线推理服务不会一条一条处理请求,因为GPU的特性是“批量计算比单条计算划算太多”。比如你用GPU算1条请求要50毫秒,算10条请求可能只要60毫秒,算100条可能只要120毫秒——相当于批量越大,单条请求的平均耗时越短,GPU的利用率也越高。
但批量处理不是随便攒一堆请求一起算的,得有个“攒请求的规则”,这个规则就是我们今天的主角:批处理窗口。
二、核心矛盾:vLLM的动态批处理窗口,为啥会搞出抖动?
现在业内最火的开源推理框架就是vLLM,它的核心卖点之一就是“动态批处理”——简单说,就是批处理窗口的大小是动态变的,不是固定死的。
我们先拿一个具体的例子拆解vLLM的动态批处理逻辑,用生活化的场景类比:假设你开了一家奶茶店,每个奶茶制作的流程是固定的(对应模型计算),但你做奶茶的方式是“攒到足够多的订单再一起做”(对应批量推理)。
2.1 vLLM动态批处理的真实逻辑(带代码示例)
先明确示例的技术栈:Python + vLLM(v0.4.0版本)。我们先看vLLM默认的动态批处理逻辑的核心代码片段(简化版,仅保留关键逻辑):
# vLLM动态批处理的核心简化逻辑(非源码,仅用于理解)
class DynamicBatchScheduler:
def __init__(self, max_batch_size=32, min_batch_size=1, wait_time_ms=10):
self.max_batch_size = max_batch_size # 最多攒32条请求一起算
self.min_batch_size = min_batch_size # 最少攒1条就可以算
self.wait_time_ms = wait_time_ms # 最多等10毫秒,没攒够也得算
self.current_batch = [] # 当前正在攒的请求列表
def add_request(self, request):
# 每来一条新请求,先加到当前批里
self.current_batch.append(request)
# 检查是否触发计算条件
if self._should_compute():
# 触发计算:清空当前批,返回要计算的请求
batch_to_compute = self.current_batch
self.current_batch = []
return batch_to_compute
return None
def _should_compute(self):
# 触发计算的两个条件:满足任意一个就触发
# 条件1:当前攒的请求数达到最大批大小(32)
condition1 = len(self.current_batch) >= self.max_batch_size
# 条件2:距离上一次计算已经过了wait_time_ms(10毫秒)
condition2 = (time.time() - self.last_compute_time) * 1000 >= self.wait_time_ms
return condition1 or condition2
这个逻辑看起来很合理:既不会让请求等太久(最多等10毫秒),又能尽量攒到更多请求(最多32条),最大化GPU利用率。但问题就出在“触发条件的不确定性”上。
2.2 动态批处理的抖动是怎么来的?
我们拿具体的请求序列来模拟两种极端情况,就能看出问题:
情况1:请求来得特别密
假设在某10毫秒内,突然来了32条请求(比如某个热点内容被大量用户同时访问),那么vLLM会立刻触发计算,这32条请求一起算,单条耗时可能只有60毫秒,平均每条的耗时是60/32=1.875毫秒,看起来很划算。
情况2:请求来得特别疏
假设接下来的10毫秒内,只来了1条请求,那么vLLM会等满10毫秒,然后触发计算。这条请求的总耗时是“等待的10毫秒 + 计算的50毫秒”=60毫秒,平均耗时是60/1=60毫秒。
你看,同样是计算单条请求,情况1的总耗时是60毫秒,情况2的总耗时也是60毫秒?不对,等一下,还有更极端的情况:
假设在某10毫秒内,来了31条请求,那么vLLM不会触发计算(因为没到32),然后在第11毫秒的时候,又来了1条请求,刚好凑够32条,触发计算。那这31条请求的等待时间是11毫秒,总耗时是11+50=61毫秒;而最后来的那条请求的等待时间是1毫秒,总耗时是1+50=51毫秒。
更夸张的是:如果在某10毫秒内,来了31条请求,然后接下来的9毫秒都没有新请求,那么vLLM会在第19毫秒的时候(距离上一次计算过了10毫秒)触发计算,这31条请求的等待时间是19毫秒,总耗时是19+50=69毫秒。
你发现问题了吗?同样是计算一条请求,它的总耗时(等待时间+计算时间)完全取决于它在批里的位置:是刚攒够就触发,还是等满了时间才触发。这就是动态批处理导致时延抖动的核心原因——等待时间是随机的,完全由请求的到达节奏决定。
再用奶茶店的例子类比:如果10分钟内来了32个订单,你立刻做,每个订单的等待时间为0,总耗时(等待+制作)是制作时间;如果10分钟内只来了1个订单,你得等10分钟(假设你设定的等待时间是10分钟),这个订单的总耗时是10分钟+制作时间,和之前的订单差了10分钟,这就是“抖动”。
三、定长批处理:为啥能平抑抖动?
定长批处理的逻辑很简单:批处理窗口的大小是固定的,不会动态变化。比如你设定“每次必须攒32条请求才一起算”,没有时间限制,攒够32条就触发计算。
3.1 定长批处理的真实逻辑(带代码示例)
同样用Python代码简化定长批处理的核心逻辑,技术栈还是Python + vLLM(v0.4.0版本):
# 定长批处理的核心简化逻辑(非源码,仅用于理解)
class FixedBatchScheduler:
def __init__(self, fixed_batch_size=32):
self.fixed_batch_size = fixed_batch_size # 固定批大小为32,不能改
self.current_batch = [] # 当前正在攒的请求列表
def add_request(self, request):
# 每来一条新请求,先加到当前批里
self.current_batch.append(request)
# 只有一个触发条件:当前攒的请求数达到固定批大小(32)
if len(self.current_batch) >= self.fixed_batch_size:
# 触发计算:清空当前批,返回要计算的请求
batch_to_compute = self.current_batch
self.current_batch = []
return batch_to_compute
return None
3.2 定长批处理的抖动为什么小?
还是拿之前的两种极端情况来模拟:
情况1:请求来得特别密
假设10毫秒内来了32条请求,立刻触发计算,总耗时是“等待时间(0毫秒) + 计算时间(60毫秒)”=60毫秒,和动态批处理一样。
情况2:请求来得特别疏
假设接下来的10毫秒内只来了1条请求,那么这条请求会一直在批里等着,直到攒够32条才会触发计算。比如接下来的1000毫秒内,陆续来了31条请求,刚好凑够32条,触发计算。那么这32条请求的等待时间是“从第一条请求到达开始,到第32条请求到达的时间”,总耗时是“等待时间 + 60毫秒”。
你可能会问:那这条请求的等待时间不是更长了吗?比如1000毫秒,总耗时是1060毫秒,比动态批处理的60毫秒差远了?
这里有个关键的误区:在线推理服务的时延指标,不是看某一条请求的最大耗时,而是看“绝大多数请求的耗时波动范围”。
我们拿1000条请求来模拟,分两种情况:
动态批处理的1000条请求耗时分布
假设请求的到达节奏是“密10毫秒(32条),疏10毫秒(1条),密10毫秒(32条),疏10毫秒(1条)……”循环,那么这1000条请求的耗时会是:
- 32条:60毫秒
- 1条:60毫秒(等满10毫秒)
- 32条:60毫秒
- 1条:60毫秒
- …… 看起来好像很稳定?不对,这是理想情况,实际的请求到达节奏是随机的,比如:
- 某10毫秒来了31条,接下来9毫秒没请求,第19毫秒触发计算:31条的耗时是19+50=69毫秒
- 某10毫秒来了30条,接下来10毫秒来了2条,第20毫秒触发计算:30条的耗时是20+50=70毫秒
- 某10毫秒来了1条,接下来9毫秒没请求,第10毫秒触发计算:1条的耗时是10+50=60毫秒
- 某10毫秒来了0条,接下来1毫秒来了1条,第11毫秒触发计算:1条的耗时是11+50=61毫秒
你会发现,动态批处理的请求耗时波动范围是“51毫秒(刚触发时来的请求)到70毫秒(等满时间时来的请求)”,波动差是19毫秒。
定长批处理的1000条请求耗时分布
同样的随机请求到达节奏,定长批处理的逻辑是“攒够32条才触发”,那么:
- 每32条请求的等待时间是“从第一条到达,到第32条到达的时间”
- 计算时间是固定的60毫秒
- 每32条请求的总耗时是“等待时间 + 60毫秒”
比如:
- 第一组32条:等待时间100毫秒,总耗时160毫秒
- 第二组32条:等待时间95毫秒,总耗时155毫秒
- 第三组32条:等待时间105毫秒,总耗时165毫秒
- 第四组32条:等待时间98毫秒,总耗时158毫秒
你会发现,定长批处理的请求耗时波动范围是“155毫秒到165毫秒”,波动差是10毫秒,比动态批处理的19毫秒小了一半。
更关键的是:定长批处理的等待时间,是“一组请求的共同等待时间”,而不是“单条请求的随机等待时间”。也就是说,一组里的所有请求,等待时间是一样的,不会出现“同组里有的等1毫秒,有的等19毫秒”的情况。
这就是定长批处理平抑抖动的核心原因:把单条请求的随机等待时间,变成了一组请求的共同等待时间,减少了等待时间的随机性。
四、定长批处理的应用场景、优缺点和注意事项
4.1 应用场景
定长批处理最适合的场景是“对时延稳定性要求极高,对最大时延有一定容忍度”的在线服务,比如:
- 实时语音翻译:用户对“翻译速度的波动”很敏感,比如有时候1秒出结果,有时候3秒出结果,体验很差,但如果能稳定在2秒左右,体验就很好。
- 在线智能客服:用户和客服的对话是实时的,希望回复速度稳定,不要忽快忽慢。
- 实时图像识别:比如自动驾驶的图像识别,对时延的稳定性要求很高,不能有时候识别快,有时候识别慢。
4.2 优缺点
优点
- 时延抖动小:同组请求的等待时间一致,减少了等待时间的随机性。
- 逻辑简单:没有复杂的动态调整逻辑,不容易出bug。
- GPU利用率稳定:因为批大小固定,GPU的计算负载稳定,不会出现“有时候负载很高,有时候负载很低”的情况。
缺点
- 最大时延可能更高:因为要攒够固定数量的请求,对于请求量很少的场景,等待时间会很长,比如固定批大小是32,请求量是1条/秒,那么等待时间可能是31秒,总耗时是31秒+计算时间,这对于实时性要求极高的场景(比如实时聊天)是无法接受的。
- 灵活性差:不能根据请求量的变化调整批大小,比如请求量突然增加,批大小还是固定的,可能会导致GPU利用率不够。
4.3 注意事项
- 固定批大小的选择很重要:不能太大也不能太小,太大的话,请求量少的时候等待时间太长;太小的话,GPU利用率太低。一般来说,固定批大小的选择要结合模型的计算时间、请求量的分布来确定,比如模型计算时间是50毫秒,请求量是100条/秒,那么固定批大小可以选16,这样等待时间最多是160毫秒,总耗时是210毫秒,既稳定,又不会太长。
- 要结合请求量的监控:如果请求量突然下降,固定批大小可能会导致等待时间太长,这时候可以考虑“动态调整固定批大小”,比如请求量降到10条/秒时,把固定批大小从32降到16,减少等待时间。
- 要注意“尾时延”:定长批处理的尾时延(比如99分位时延)可能会比动态批处理高,因为有时候会等满固定批大小才触发计算,这时候要根据业务的要求来权衡,比如业务对尾时延的要求不高,对抖动的要求高,那么定长批处理是合适的。
五、总结
在线推理服务的时延抖动,很多时候不是模型计算的问题,而是批处理逻辑的问题。vLLM的动态批处理虽然能最大化GPU利用率,但因为等待时间的随机性,会导致时延抖动;而定长批处理通过固定批大小,把单条请求的随机等待时间变成了一组请求的共同等待时间,减少了等待时间的随机性,从而平抑了时延抖动。
定长批处理不是万能的,它有自己的应用场景和优缺点,在选择的时候,要结合业务的需求来权衡:如果业务对时延稳定性要求高,对最大时延有一定容忍度,那么定长批处理是一个很好的选择;如果业务对最大时延要求极高,对抖动的要求不高,那么动态批处理更合适。
最后,给大家一个建议:如果你的在线推理服务出现了时延抖动的问题,可以先排查一下批处理逻辑,看看是不是动态批处理的问题,试试换成定长批处理,说不定能解决问题。
评论
围绕“在线推理服务时延抖动严重,根源可能不在模型计算,而在vLLM动态批处理窗口调整上,定长批处理为何能平抑波动”参与讨论