LLM Infra 101 v0.3: 静态批处理
系列的第二集,前面的可以看:
这一期的代码在 https://github.com/iFurySt/nanoLLMServe/tree/release/v0.3.1
上期过完我们对于KV Cache已经有了认知和实现了,现在我们要继续看一个问题,我们现在每次收到请求是把这个请求单独处理,下个请求也是单独处理,但是实际生产中这样会带来一些问题诸如吞吐低、时延高、资源利用率低这些问题。
因为每次请求一个请求,可以理解为是串行的处理,GPU算力空闲率高,并且每个请求独立做prefill。因此自然而然我们就会做一些批量(Batching)的优化动作,这样可以并行处理多个请求,提高整体的GPU利用率,也能批量化内存和算子调度。
Batching这块核心就是两种:
-
静态批处理(Static Batching):相对传统的Batching,但是对于我们理解机制原理很有帮助
-
连续批处理(Continuous Batching):现在主流的infra采用的技术
我们这次主要针对Static Batching,下一集会推进到Continuous Batching。
Static Batching的原理是,一次固定处理一批请求,整批一起forward,大概如下:
req1
req2
req3
↓
组成一个固定 batch
↓
一起 forward
↓
等整个 batch 全部结束
↓
再处理下一批
对比一下,原来是这样的
时间轴 →
Req A ───── GPU forward ─────
Req B ───── GPU forward ─────
Req C ───── GPU forward ─────
现在是
┌───────────────┐
Req A ───────▶│ │
Req B ───────▶│ Batch=3 │───▶ GPU Forward
Req C ───────▶│ │
└───────────────┘
实现
改动涉及的文件:
.
├── src/
│ └── nanollmserve/
│ ├── api/
│ │ ├── openai_server.py # Responses 子集对齐:服务层接口行为和路由处理扩展
│ │ └── protocol.py # 协议模型更新:响应子集相关字段、请求/响应结构收敛
│ └── engine/
│ └── engine.py # 在现有 generate_one 的基础上引入静态批处理路径与调度
└── tests/
├── test_benchmark_generate.py # benchmark 汇总项与静态/响应场景回归覆盖
├── test_engine.py # engine 行为回归(含批处理/状态路径)
└── test_openai_server.py # OpenAI 兼容层改造后的接口回归(含 Responses 子集)
Batching
原来只有generate_one(model, tokenizer, prompt, ...) ,这次新增了generate_batch(model, tokenizer, prompts, ...) ,现在调用变成了一组prompt
results = generate_batch(
model,
tokenizer,
["hello", "world"],
max_new_tokens=32,
temperature=0.0,
)
但是我们没有把generate_batch对接到API和CLI里,因为马上我们就要做Continuous Batching了,这边就做一个过渡
因为这边prompt进来是一组的,prompt长度可能都不一样:
A input_ids: [101, 102]
B input_ids: [201, 202, 203, 204]
因此tokenizer需要打开Padding
encoded = tokenizer(
prompts,
return_tensors="pt",
padding=True,
)
padding后会变为类似这样的:
A input_ids: [101, 102, 0, 0]
A attention_mask: [1, 1, 0, 0]
B input_ids: [201, 202, 203, 204]
B attention_mask: [1, 1, 1, 1]
这个其实我们第一章的时候已经说过一次了,当时我们没有batching机制,所以当时默认bacth 1,现在就会有多个batch了
Batch Prefill
之前我们做了prefill,不过当时针对的是单个请求:
prompt -> model -> past_key_values
现在变成了batch prefill
[prompt A, prompt B, prompt C] -> model -> batch past_key_values
第一次forward变成了batch,这个其实之前也都有,所以机制上是已经有了,只不过有个小地方需要调整,原来logits是从-1取的,但是现在-1可能是padding,所以需要调整一下
def _select_last_token_logits(logits, attention_mask):
indices = torch.clamp(attention_mask.sum(dim=1) - 1, min=0)
batch = torch.arange(logits.size(0), device=logits.device)
return logits[batch, indices, :]
现在是根据attention_mask来找最后一个真实token的位置,因为掩码里有对应的信息
batch decode
decode也是一样,原来已经有batch的机制了:
input_ids = [[last_token]]
现在是
input_ids = [
[last_token_for_A],
[last_token_for_B],
[last_token_for_C],
]
现在会在某个step里分别去生成batch里的请求的下一个token
step 1: A生成一个token,B生成一个 token,C生成一个token
step 2: A生成一个token,B生成一个 token,C生成一个token
step 3: ...
但是因为实际序列都不一样长,有一些请求会更早结束
Req A → 10 tokens
Req B → 500 tokens
Req C → 50 tokens
Req A: finished
Req B: running
Req C: running
在Static Batching里,先遇到EOS结束的请求会标记成finished,后续不会再往它的generated_token_ids里追加token了。但是这个batch里已经有请求结束了,GPU就会出现空洞的情况:
batch = [EMPTY, B, C]
[ _, B, C ]
batch = [EMPTY, B, EMPTY]
[ _, B, _ ]
这样GPU计算的利用率到后面是越来越少的,也就是退化回单条请求。但是同时显存的slot并不会释放,造成了显存的浪费
# step1: GPU0 KV Memory
┌────┬────┬────┐
│ A │ B │ C │
└────┴────┴────┘
# step2: GPU0 KV Memory
┌────┬────┬────┐
│idle│ B │ C │
└────┴────┴────┘
# step3: GPU0 KV Memory
┌────┬────┬────┐
│idle│ B │idle│
└────┴────┴────┘
这个其实也是我们下一章Continuous Batching要解决的!(大体解决思路是谁结束了谁滚蛋,谁来了谁补位)这边我们先不展开
推理
这次我们基本也是在bench里观测一下
(base) gpu-A100-05 nanoLLMServe # for BS in 1 2 4 8; do
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=src /data/anaconda3/bin/python -m benchmarks.benchmark_generate \
--model /data2/nanoLLMServe/models/Qwen3-8B \
--prompt "Explain static batching in one sentence." \
--max-new-tokens 64 \
--runs 5 \
--warmup 1 \
--batch-size "$BS" \
--device cuda \
--dtype bfloat16 \
--local-files-only \
--skip-naive-baseline
done
Loading checkpoint shards: 100%|██████████████| 5/5 [00:00<00:00, 142.39it/s]
{
"batch_size": 1,
"device": "cuda",
"dtype": "bfloat16",
"kv_cache_decode": {
"generated_tokens": [
64,
64,
64,
64,
64
],
"mean_decode_seconds": 1.8507717087864877,
"mean_elapsed_seconds": 1.9322640344500541,
"mean_prefill_seconds": 0.030887942016124725,
"mean_tokens_per_second": 33.12221489162687,
"mean_tpot_seconds": 0.029377328710896627,
"mean_ttft_seconds": 0.03308003842830658
},
"model": "/data2/nanoLLMServe/models/Qwen3-8B",
"prompt_tokens": 8,
"runs": 5,
"warmup": 1
}
Loading checkpoint shards: 100%|██████████████| 5/5 [00:00<00:00, 139.83it/s]
{
"batch_size": 2,
"device": "cuda",
"dtype": "bfloat16",
"kv_cache_decode": {
"generated_tokens": [
64,
64,
64,
64,
64
],
"mean_decode_seconds": 1.8655244752764701,
"mean_elapsed_seconds": 1.9476028025150298,
"mean_prefill_seconds": 0.03145704716444016,
"mean_tokens_per_second": 32.86332616672236,
"mean_tpot_seconds": 0.02961149960756302,
"mean_ttft_seconds": 0.03360582888126373
},
"model": "/data2/nanoLLMServe/models/Qwen3-8B",
"prompt_tokens": 8,
"runs": 5,
"static_batch": {
"batch_size": 2,
"generated_tokens": [
64,
64,
64,
64,
64,
64,
64,
64,
64,
64
],
"mean_batch_elapsed_seconds": 1.9898763984441756,
"mean_batch_tokens_per_second": 64.32565239699788,
"mean_decode_seconds": 1.9065125167369843,
"mean_generated_tokens": 64,
"mean_prefill_seconds": 0.03188993483781814,
"mean_prompt_tokens": 8,
"mean_tpot_seconds": 0.03026210344026959,
"mean_ttft_seconds": 0.034097179770469666
},
"warmup": 1
}
Loading checkpoint shards: 100%|██████████████| 5/5 [00:00<00:00, 141.49it/s]
{
"batch_size": 4,
"device": "cuda",
"dtype": "bfloat16",
"kv_cache_decode": {
"generated_tokens": [
64,
64,
64,
64,
64
],
"mean_decode_seconds": 1.8458494618535042,
"mean_elapsed_seconds": 1.9274740874767304,
"mean_prefill_seconds": 0.031040719151496886,
"mean_tokens_per_second": 33.204527220435416,
"mean_tpot_seconds": 0.029299197807198477,
"mean_ttft_seconds": 0.033210942149162294
},
"model": "/data2/nanoLLMServe/models/Qwen3-8B",
"prompt_tokens": 8,
"runs": 5,
"static_batch": {
"batch_size": 4,
"generated_tokens": [
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64
],
"mean_batch_elapsed_seconds": 1.9469317257404328,
"mean_batch_tokens_per_second": 131.49626546592532,
"mean_decode_seconds": 1.8595415592193603,
"mean_generated_tokens": 64,
"mean_prefill_seconds": 0.030785535275936127,
"mean_prompt_tokens": 8,
"mean_tpot_seconds": 0.029516532686021592,
"mean_ttft_seconds": 0.033043819665908816
},
"warmup": 1
}
Loading checkpoint shards: 100%|██████████████| 5/5 [00:00<00:00, 141.55it/s]
{
"batch_size": 8,
"device": "cuda",
"dtype": "bfloat16",
"kv_cache_decode": {
"generated_tokens": [
64,
64,
64,
64,
64
],
"mean_decode_seconds": 1.8693151980638505,
"mean_elapsed_seconds": 1.9508710712194444,
"mean_prefill_seconds": 0.031239084899425507,
"mean_tokens_per_second": 32.80601897231402,
"mean_tpot_seconds": 0.02967166981053731,
"mean_ttft_seconds": 0.0333852082490921
},
"model": "/data2/nanoLLMServe/models/Qwen3-8B",
"prompt_tokens": 8,
"runs": 5,
"static_batch": {
"batch_size": 8,
"generated_tokens": [
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64,
64
],
"mean_batch_elapsed_seconds": 1.989836023747921,
"mean_batch_tokens_per_second": 257.34328723459373,
"mean_decode_seconds": 1.8920533761382103,
"mean_generated_tokens": 64,
"mean_prefill_seconds": 0.031170780956745147,
"mean_prompt_tokens": 8,
"mean_tpot_seconds": 0.030032593272035085,
"mean_ttft_seconds": 0.03355635851621628
},
"warmup": 1
}
(base) gpu-A100-05 nanoLLMServe #
解读一下,跑了4次,分别是batch为1、2、4、8的场景
| Batch | batch elapsed | total tokens/s | 单请求等效 tokens/s |
|---|---|---|---|
| 1 | ~1.93s | ~33.1 tok/s | ~33.1 tok/s |
| 2 | ~1.99s | ~64.3 tok/s | ~32.2 tok/s |
| 4 | ~1.95s | ~131.5 tok/s | ~32.9 tok/s |
| 8 | ~1.99s | ~257.3 tok/s | ~32.2 tok/s |
可以看出,吞吐是变多了,单个请求的时候是33tokens/s,8个请求一批的时候,系统整体的吞吐达到了257token/s,也就是每个请求得到的吞吐一样的情况下,并行的去推理导致系统整体吞吐量得到的极大的提升。这个就是批处理带来的提升!
总结
这一波聊了Batching技术,这个特性的出发点就是从infra的角度去提升系统整体的吞吐量并减少接口调用时延。因为batch了,所以GPU的利用率也得到了提升。
但是Static Batching也留下了一下问题,比如固定批次,导致已经结束的req也还是不断被带着一起forward,GPU的显存也要保留已经结束的req对应的KV Cache等不能释放,需要等到这批请求都结束后才能被释放,显存利用率降低了。这些都会在下一章Continuous Batching里解决
Enjoy Reading This Article?
Here are some more articles you might like to read next: