Rust in Everything

分块预填充

主线位置:调度这一段里「长 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 行)。

预算怎么分

分块不是一段单独的逻辑,就是上一页的两段循环:

  1. running 段:按进入顺序,解码请求各拿 1 个,还在预填充的请求拿 min(剩余 prompt, 剩余预算)。长请求进入得晚,排在解码请求后面,它拿的是解码之后剩下的预算。
  2. 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 GB2048T4、4080
20–35 GB4096A10、4090、5090
35–60 GB4096A100 40GB、L40
60–90 GB8192H100、A100
90–160 GB8192H20、H200
≥ 160 GB16384B200、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)打开后,预填充批组好之后把运行中的请求拼进去:

scheduler.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 -1max_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-nochunk110100081、164
vllm-chunk51020483、464
vllm-chunk-thr1024101014321、164
sglang-nochunk121111、166
sglang-chunk343111、167
sglang-chunk-mixed31040961、164

第 9–15 步逐步看(每格是「本步 token 数 / 本步出解码 token 的请求数」):

步9101112131415
vllm-chunk8 / 82048 / 82048 / 82048 / 82048 / 82048 / 8210 / 10
sglang-chunk8 / 84096 / 04096 / 02208 / 011 / 11——
sglang-chunk-mixed8 / 84096 / 84096 / 82232 / 811 / 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。

💬 关联讨论

发起讨论
加载中...