分块预填充
主线位置:调度这一段里「长 prompt 的预填充怎么和别人的解码分享一步」。vLLM 在
Scheduler.schedule(),SGLang 在PrefillAdder和get_next_batch_to_run(),两边都是 Python。
版本:vLLM v0.32.0、SGLang v0.5.22
结论:
- 分块预填充把一步里的预填充 token 数封顶。一个 1 万 token 的 prompt 不再一步算完,而是每步算一块。
- vLLM 默认开,块和其他请求的解码在同一步里。短请求每步都拿到解码 token,代价是这几步变大(每步至多 2048 个 token,24 GB 卡 OpenAI 服务的默认)。
- SGLang 默认也切块(24 GB 卡 4096),但默认不混批:每个块是一个纯预填充步,块与块之间解码不跑。要让解码搭车,得开
--enable-mixed-chunk。 - 下面的模拟按源码规则走:1 万 token 的长 prompt 混进 8 个解码中的短请求,vLLM 分块时短请求一步也不停;SGLang 默认配置下短请求连停 3 步,不分块时只停 1 步(但那一步有 1 万 token)。每一步多少毫秒没有实测,待 C3-4。
flowchart TB
subgraph v["vLLM 分块:预算 2048"]
v1["步 1:8 个解码 + 2040 预填充"] --> v2["步 2:8 个解码 + 2040 预填充"] --> v3["…… 共 5 步"]
end
subgraph s["SGLang 默认:块 4096、不混"]
s1["步 1:4096 预填充,解码停"] --> s2["步 2:4096 预填充,解码停"] --> s3["步 3:1808 预填充,解码停"] --> s4["步 4:解码"]
end
subgraph m["SGLang 打开混合块"]
m1["步 1:8 个解码 + 4088 预填充"] --> m2["步 2:8 个解码 + 4088 预填充"] --> m3["步 3:8 个解码 + 剩余"]
end
为什么要切
预填充一次处理整段 prompt,算力受限;解码每个请求一步只处理 1 个 token,访存受限。两者放进同一次前向,这一步的耗时主要由预填充 token 数决定。
- 不切:一个长 prompt 进来的那一步很长。和它同批的解码请求,这一个 token 等得也长(vLLM);或者这一步干脆不解码(SGLang)。
- 切:每步的预填充 token 数有上限,单步耗时有上限。解码请求每一步的间隔被限制住,代价是长请求的首 token 要多等几步。
「单步耗时有上限」是分块的全部收益。它不减少总的预填充计算量。
vLLM:开关、预算和长请求阈值
默认开
enable_chunked_prefill 在 SchedulerConfig 里写 True(config/scheduler.py)。EngineArgs 里不设时取模型是否支持(_set_default_chunked_prefill_and_prefix_caching_args):生成模型除了 encoder-decoder 都支持(is_chunked_prefill_supported)。对支持的生成模型手动关掉,会打一条警告「This model does not officially support disabling chunked prefill」(第 3006 行)。
预算怎么分
分块不是一段单独的逻辑,就是上一页的两段循环:
- running 段:按进入顺序,解码请求各拿 1 个,还在预填充的请求拿
min(剩余 prompt, 剩余预算)。长请求进入得晚,排在解码请求后面,它拿的是解码之后剩下的预算。 - waiting 段:新请求拿剩下的预算,不够就截断成一块(分块打开时);分块关闭时放不下就停止扫描:
vllm/v1/core/sched/scheduler.py
if 0 < long_prefill_token_threshold < num_new_tokens:
num_new_tokens = long_prefill_token_threshold
# chunked prefill has to be enabled explicitly to allow
# pooling requests to be chunked
if (
not self.scheduler_config.enable_chunked_prefill
and num_new_tokens > request_token_budget
and not self.is_mm_encoder_only
):
# If chunked_prefill is disabled,
# we can stop the scheduling here.
break
num_new_tokens = min(num_new_tokens, request_token_budget)
所以 24 GB 卡上默认每步的 2048 个 token,先扣掉所有解码行,剩下的给预填充。关掉分块且不手设预算时,预算会抬到 max_model_len(上一页),一步就能吃下整段长 prompt。
长请求阈值
long_prefill_token_threshold 默认 0,即不限(config/scheduler.py)。设成正数后,单个请求一步最多拿这么多 token,剩下的预算留给后面的请求。源码注释说它「是为了不让长预填充饿死别的请求」,只有一个请求时不生效(第 640 行)。另有 long_prefill_token_threshold_adaptive(默认关)把阈值下限设成「预算 / 请求数」。
SGLang:块大小、混合块
块大小按显存分档
--chunked-prefill-size 字段默认 None,-1 表示关闭(schedule.py)。不设时按显存填(handle_gpu_memory_settings):
| 显存 | 块大小 | 源码里举的卡 |
|---|---|---|
| < 20 GB | 2048 | T4、4080 |
| 20–35 GB | 4096 | A10、4090、5090 |
| 35–60 GB | 4096 | A100 40GB、L40 |
| 60–90 GB | 8192 | H100、A100 |
| 90–160 GB | 8192 | H20、H200 |
| ≥ 160 GB | 16384 | B200、MI300 |
| 读不到显存 | 4096 | — |
(20–35 GB 那一档)多模态模型走 Transformers 后端时调度器会关掉分块(scheduler.py)。
块大小是 PrefillAdder 的 rem_chunk_tokens。正在分块的请求每步先续上(add_chunked_req),剩下的块预算再给新请求;新请求放不下时截断,截断点按页对齐:
python/sglang/srt/managers/schedule_policy.py
extend_len = chunk_tokens_limit // self.page_size * self.page_size
# ...
end = (prefix_len + extend_len) // self.page_size * self.page_size
extend_len = end - prefix_len
if extend_len <= 0:
return AddReqResult.OTHER
is_chunked = True
max_new_tokens = 0
同一时刻只有一个请求处在「切了一半」的状态(调度器只有一个 chunked_req)。
默认不混,解码要等
上一页讲过:组得出预填充批,这一步就只跑预填充(第 3870 行)。切了一半的请求每步都能组出预填充批,所以长 prompt 有几块,运行中的请求就停几步。分块在这里限制的是单步耗时和激活显存(handle_gpu_memory_settings 按块大小估激活占用来定 mem_fraction_static),不是让解码插进来。
--enable-mixed-chunk(默认 False,schedule.py)打开后,预填充批组好之后把运行中的请求拼进去:
# Mixed-style chunked prefill
if (
self.is_mixed_chunk
and not running_batch.is_empty()
and not (new_batch.return_logprob or running_batch.return_logprob)
# ...
):
# ...
if not running_batch.is_empty() and running_batch.check_decode_mem():
running_batch.prepare_for_decode()
new_batch.mix_with_running(running_batch)
- 每个解码行占块预算的 1 个 token:
rem_chunk_tokens -= num_mixed_decode_tokens(schedule_policy.py)。这和 vLLM 的预算扣法一样。 - 有请求要 logprob、用了
input_embeds、有 beam 时不混;显存不够解码时这些解码行本步不混,下一步正常解码。 - 部分投机解码算法会把它关掉(
speculative_hook.py),DLLM、HiSparse 也不兼容。
另一个折中是 --prefill-decode-interval N:每个预填充批之后先跑 N 步解码。默认 0。
模拟:长 prompt 混进解码中的短请求
这是按源码规则写的离散步模拟,不是引擎实测。单位是「步」(一次调度 + 一次前向),不换算成毫秒。纯 Python 标准库,约 230 行。
负载与规则
- 8 个短请求:prompt 200 token,各生成 64 个,第 0 步到达。
- 1 个长请求:prompt 10000 token,生成 8 个,第 10 步到达。此时短请求都在解码。
- 2 个晚到的短请求:prompt 200、生成 16 个,第 12 步到达。
- 预填充最后一块的那一步采样出首 token。
| 模式 | 规则来源 | 参数 |
|---|---|---|
vllm-nochunk | 分块关,放不下就停扫描 | 预算 = max(max_model_len 16384, 2048) |
vllm-chunk | 默认 | 预算 2048 |
vllm-chunk-thr1024 | 设 long_prefill_token_threshold=1024 | 预算 2048 |
sglang-nochunk | --chunked-prefill-size -1 | max_prefill_tokens 16384 |
sglang-chunk | 默认 | 块 4096,不混 |
sglang-chunk-mixed | --enable-mixed-chunk | 块 4096 |
简化(和源码对不上的地方):
- KV 显存无限:没有抢占、retract,也没有 SGLang 的
new_token_ratio预留。 - 不查前缀缓存;页大小当 1,不做按页对齐。
- 不模拟 vLLM 的异步调度和 SGLang 的重叠调度:一步就是一次前向。
- 不模拟投机解码、多模态、优先级;队列都是 FCFS。
关键代码
vLLM 一步(对应 schedule() 的两段循环):
def vllm_step(running, waiting, step, budget, chunked, long_threshold):
plan = {}
for r in running: # 先 running:解码 1 个,预填充给剩余预算
if budget <= 0:
break
need = r.prefill_left() if r.prefill_left() > 0 else 1
if 0 < long_threshold < need:
need = long_threshold
n = min(need, budget)
plan[r.rid] = n
budget -= n
while waiting and budget > 0: # 再 waiting
r = waiting[0]
need = r.prefill_left()
if 0 < long_threshold < need:
need = long_threshold
if not chunked and need > budget:
break # 分块关:放不下就停止扫描
n = min(need, budget)
waiting.pop(0)
running.append(r)
plan[r.rid] = n
budget -= n
return plan
SGLang 一步(对应 get_next_batch_to_run 加 PrefillAdder):
def sglang_step(state, step, chunk_size, max_prefill_tokens, mixed):
running, waiting = state["running"], state["waiting"]
chunked = state["chunked"]
if chunked is None and not waiting:
return {r.rid: 1 for r in running}, "decode"
n_mixed = len(running) if mixed else 0
rem_chunk = None if chunk_size is None else chunk_size - n_mixed
rem_input = max_prefill_tokens - n_mixed
plan = {}
if chunked is not None: # add_chunked_req:切了一半的先续
n = min(chunked.prefill_left(), rem_chunk)
plan[chunked.rid] = n
rem_chunk -= n
rem_input -= n
state["chunked"] = chunked if n < chunked.prefill_left() else None
# ... 等待队列按序加入,超出 rem_chunk 的截断成新的 chunked
if not plan:
return {r.rid: 1 for r in running}, "decode"
if mixed:
for r in running:
plan[r.rid] = 1
return plan, "mixed"
return plan, "prefill" # 预填充优先:这一步运行中的请求不解码
结果
| 模式 | 长请求首 token(到达后第几步) | 长请求预填充期间,短请求两 token 最大间隔(步) | 短请求拿不到 token 的步数 | 含解码的步里最多处理的 token 数 | 晚到短请求首 token(到达后第几步) | 全部做完的总步数 |
|---|---|---|---|---|---|---|
vllm-nochunk | 1 | 1 | 0 | 10008 | 1、1 | 64 |
vllm-chunk | 5 | 1 | 0 | 2048 | 3、4 | 64 |
vllm-chunk-thr1024 | 10 | 1 | 0 | 1432 | 1、1 | 64 |
sglang-nochunk | 1 | 2 | 1 | 11 | 1、1 | 66 |
sglang-chunk | 3 | 4 | 3 | 11 | 1、1 | 67 |
sglang-chunk-mixed | 3 | 1 | 0 | 4096 | 1、1 | 64 |
第 9–15 步逐步看(每格是「本步 token 数 / 本步出解码 token 的请求数」):
| 步 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|
vllm-chunk | 8 / 8 | 2048 / 8 | 2048 / 8 | 2048 / 8 | 2048 / 8 | 2048 / 8 | 210 / 10 |
sglang-chunk | 8 / 8 | 4096 / 0 | 4096 / 0 | 2208 / 0 | 11 / 11 | — | — |
sglang-chunk-mixed | 8 / 8 | 4096 / 8 | 4096 / 8 | 2232 / 8 | 11 / 11 | — | — |
怎么读:
- vLLM 分块:短请求每步都出 token;长请求进来的 5 步里,每步从 8 个 token 涨到 2048 个。短请求的 TPOT 在这 5 步里会变大,变多少要看 2048 token 的一步比 8 token 的一步慢多少,待实测(C3-4)。
- vLLM 不分块:短请求同样每步出 token,但有一步要和 10000 token 的预填充一起算。步数上看不出差别,差别全在那一步的耗时里。
- SGLang 默认:长 prompt 切成 4096 + 4096 + 1808 三块,三步里解码全停。不分块时只停 1 步。也就是说,在 SGLang 默认不混批的配置下,分块增加了解码停顿的步数,换来的是每一步更短;总停顿时间是变长还是变短,取决于三块的耗时之和与一整块的耗时,待实测(C3-4)。
- SGLang 混合块:和 vLLM 分块同形,短请求不停,每步 4096 个 token。
- 晚到的短请求:vLLM 默认下排在切了一半的长请求后面,长请求每步把预算吃完,它们等到长请求最后一块才进来(3、4 步)。设
long_prefill_token_threshold=1024后它们立刻进来,代价是长请求的首 token 从第 5 步推到第 10 步。SGLang 的块预算更大(4096),第 12 步最后一块只用 1808,剩下的够它们进来。
待实测
- 长 prompt 混进短请求时短请求的 TPOT,vLLM 分块开 / 关,SGLang 默认 /
--enable-mixed-chunk/--chunked-prefill-size -1:待实测(C3-4)。 - 每步 token 数(2048、4096、10000)对应的单步耗时:待实测(C3-4)。
小结
- 分块只做一件事:给一步里的预填充 token 数封顶,从而给单步耗时封顶。
- vLLM 默认开,块和解码同批,预算先扣解码再给预填充;
long_prefill_token_threshold默认 0,设了可以给后来的请求留预算。 - SGLang 默认按显存切块(24 GB 卡 4096),但默认预填充批和解码批分开,长 prompt 有几块解码就停几步;要和 vLLM 同形得开
--enable-mixed-chunk。 - 模拟只说明「哪一步有谁」,不说明每步多久;毫秒数等 GPU。