连续批处理策略(Continuous Batching)

连续批处理策略(Continuous Batching)

在调用本地昇腾部署的大模型时,观察到一个很有意思的现象:在执行高并发问答时,所有的回答都趋于同一个短暂的时间窗口完成,即使有不同的首字延迟时间。

关键词: 批处理、大语言模型、本地部署

1
2
3
4
2025-10-10 11:04:08 [INFO] LLM图像分析455成功响应,用时 86.08秒 | 首字延迟: 63.554秒 | 输入tokens: 872 | 输出tokens: 8 | 总tokens: 880 | tokens/秒: 0.09
2025-10-10 11:04:08 [INFO] LLM图像分析471成功响应,用时 85.91秒 | 首字延迟: 26.541秒 | 输入tokens: 872 | 输出tokens: 8 | 总tokens: 880 | tokens/秒: 0.09
2025-10-10 11:04:08 [INFO] LLM图像分析263成功响应,用时 88.34秒 | 首字延迟: 7.496秒 | 输入tokens: 872 | 输出tokens: 8 | 总tokens: 880 | tokens/秒: 0.09
2025-10-10 11:04:08 [INFO] LLM图像分析375成功响应,用时 87.13秒 | 首字延迟: 45.048秒 | 输入tokens: 872 | 输出tokens: 8 | 总tokens: 880 | tokens/秒: 0.09

一、批处理策略

LLM推理服务器(尤其是使用vLLM、TensorRT-LLM等框架)通常会采用 连续批处理(Continuous Batching) 策略:

1. 时间轴

1
2
3
4
5
6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
请求A: [等待.......][首字][生成...]
请求B: [等待..........][首字][生成]
请求C: [等待....][首字][生成......]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
← 所有请求在同一批次中完成 →

虽然首字返回时间不同,但服务器会:

  • 将同时到达的请求组成一个批次
  • 在GPU上并行处理这个批次
  • 批次内的请求会相互等待,直到整批完成

总时间 = 首字延迟 + 生成时间,这也就意味着部分请求可能已经很早就完成了,但是需要等待其他请求。

💡部分请求的首字token仅需不到1秒,但是后续需要时间过长,且总时间与其他请求相差不多

1
2
请求2:  首字 0.870秒 + 后续 10.48秒 ≈ 11.35秒总时间
请求27: 首字10.824秒 + 后续 0.26秒 ≈ 11.08秒总时间

2. 服务器调度策略

可能使用了公平调度批次同步机制:

  • 保证同一批次的请求不会因为完成早就立即返回
  • 而是等待批次内所有请求都处理完
  • 这样可以优化GPU利用率简化调度逻辑

3. 为什么不是”快的先完成”?

虽然是异步流式响应,但服务器端的批处理机制决定了:

  • 即使客户端异步接收
  • 服务器端仍然是批量同步推理
  • 这导致同一批次的请求几乎同时完成

二、测试

1
2
3
# 在循环开始时记录每个chunk到达的时间
async for chunk in self.llm.astream([("system", system_prompt), message]):
print(f"请求{request_id} 收到chunk时间: {time.time()}")

1. Chunk 完全交错到达(最重要的证据)

看第一批请求(115-128)的chunk顺序:

1
2
3
4
5
6
7
8
时间戳 1760063687.956-958:
127119117120121128126115 (第1轮)

时间戳 1760063687.959-961:
127119117120121128126115 (第2轮)

时间戳 1760063687.979-981:
119127121117115126120128 (第3轮,顺序变化)

这说明服务器并非逐个处理请求,而是:

  • GPU批次中并行生成所有请求的token
  • 每生成一轮,就轮流返回每个请求的chunk
  • 类似”时间片轮转”的调度方式

2. 首字延迟差异巨大,但总时间趋同

请求ID首字延迟总时间首字后耗时
1290.913秒5.70秒4.79秒
1313.676秒5.68秒2.00秒
1405.396秒5.60秒0.20秒
1425.374秒5.58秒0.21秒

公式:首字延迟 + 首字后耗时 ≈ 恒定值(~5.6秒)

3. 批处理的三个阶段

阶段1:等待队列调度(首字延迟)

1
2
3
1760063688.79秒  ← 请求129, 135先进入
1760063691.57秒 ← 请求143, 138, 133, 131... 批量进入(相隔2.78秒)
1760063693.41秒 ← 请求141, 139, 140, 142, 144 进入(相隔1.84秒)

阶段2:并行推理(chunk交错返回)

1
2
3
1760063693.46-52秒: 所有16个请求的chunk交错返回
1760063693.51-52秒: 第2轮chunk
1760063693.56-57秒: 第3轮chunk

每轮间隔约50毫秒,说明GPU批次推理速度很快

阶段3:同步完成

1
2
所有请求在 10:34:53 的同一秒内完成
总时间:5.55-5.74秒(误差仅0.19秒)

三、结论

1. 服务器采用了 Continuous Batching + 同步返回策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 伪代码:服务器端推理逻辑
batch = [req129, req131, req140, ...] # 组成批次

while not all_completed(batch):
# 在GPU上并行生成所有请求的下一个token
tokens = gpu_parallel_generate(batch)

# 轮流发送每个请求的token
for req, token in zip(batch, tokens):
if token != EOS:
send_chunk_to_client(req, token)

# 等待最慢的请求
wait_for_slowest() # 关键:批次同步点

# 所有请求一起返回完成状态
return_all_together(batch)

2. 这样设计的优势

  1. GPU利用率最大化:所有请求并行推理,充分利用GPU并行能力
  2. 吞吐量最优:批处理比逐个处理快得多
  3. 调度简化:批次内同步,避免频繁的上下文切换

3. 代价

快的请求需要等待慢的请求,所以即使首字延迟只有0.9秒,也要等到5.7秒才能完成。

总结

观察到的现象:首字快的应该先完成,但实际没有

原因:服务器端批处理策略强制同步了所有请求,为了最大化吞吐量,牺牲了单个请求的延迟优化。这是典型的 吞吐量优先(Throughput-oriented) 而非 延迟优先(Latency-oriented) 的设计。

如果想要”快的先完成”,需要服务器配置改为更小的batch size禁用批处理,但这会大幅降低整体吞吐量。