静态批处理(Static 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

在我的实践中,本地部署的服务可能由于某些原因退回到了**静态批处理:**因为张量矩阵必须对齐,早生成的请求会一直输出 Padding(填充)字符,直到最长的那个请求生成完毕,这会导致“快的等慢的”。

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

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

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

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

服务器调度策略可能使用了公平调度批次同步机制:

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

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

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

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

通过以下代码测试了一下

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
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)

这样设计的优势

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

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

总结

观察到的现象:首字快的应该先完成,但实际没有。原因:服务器端批处理策略强制同步了所有请求,为了最大化吞吐量,牺牲了单个请求的延迟优化。这是典型的**吞吐量优先(Throughput-oriented)而非延迟优先(Latency-oriented)**的设计。

在高并发场景下,虽然使用了流式输出和异步请求,但由于算力瓶颈(特别是 Prefill 抢占)以及调度策略更偏向于吞吐量(允许新请求打断老请求的生成节奏),最终导致用户端的体验是“早连接的请求被拖慢,最终大家一起完成”。

然而,这种策略现在看来已经不适用于现在的计算设备了,vLLM 等框架之所以能称霸,恰恰是因为它们打破了这种等待。它是在 Token 级别(迭代级别) 进行调度的。只要某个请求生成了结束符(EOS),它会立刻被踢出当前的 Batch,并将结果返回给客户端,然后引擎会立刻把队列里的新请求塞进这个空出来的 Batch 槽位中。这就引申出了一个新的概念:连续批处理(Continuous Batching)。