原创解读 📅 2026-10-02 ✍️ MagicNetWorld 编辑部

分块预填充与 Stall-free 调度:把 LLM 推理的吞吐-延迟权衡算清楚

为什么把整段 prompt 一次性喂给 GPU 反而会把线上服务拖垮?本文从 prefill 与 decode 的算术强度差异出发,用 roofline 推导出「分块大小」的可用闭式估计,解释 SARATHI 的 decode-maximal batching、Sarathi-Serve 的 stall-free 调度与 DeepSpeed-FastGen 的 Dynamic SplitFuse 各自在解哪一个约束,并给出 vLLM V1 里 max_num_batched_tokens 的调参依据与失效边界。文末附本文自行推导的数值算例与一手来源核验记录。

LLM 推理推理系统调度数学推导

线上跑 LLM 服务的人迟早会撞上同一个矛盾:为了保证单个用户的体感,我们希望每一轮迭代都尽快返回;为了让 GPU 不要闲着,我们又希望批次尽可能大。这两个目标在同一个 GPU 上打架,而且打架的方式很具体——它不是一个「调参玄学」问题,而是一个由算术强度(arithmetic intensity,每读一字节内存能摊到多少次浮点运算)决定的、可以被算清楚的资源分配问题。

这篇文章要回答的核心问题是:**为什么「把 prompt 整段一次性算完」这种最自然的做法,会在有并发请求时把延迟搞坏?**以及工程上真正有效的那一类解法——分块预填充(chunked prefill)——为什么能把吞吐和延迟同时往好的方向推、分块又该分多大。

本文的推导与数值算例为本文自行展开(自算数字在文末单独列出),涉及他人结论处以引述方式给出并标注出处;所有引用的一手来源均为本轮实地打开核验。

一、先给直觉:一次请求要过两道完全不同的关

不严谨地说,一次 LLM 推理请求要过两道关:第一道是读题,第二道是答题。

读题阶段叫 prefill:模型一次性把整段 prompt(比如 4096 个 token)并行喂进去,算出第一个输出 token,同时把每一层的 Key/Value 写进 KV cache。因为整段 prompt 是并行处理的,这个阶段能把 GPU 的计算单元喂得很饱——它是计算受限(compute-bound)的。

答题阶段叫 decode:模型一次只生成一个 token,生成第 t+1t+1 个 token 需要用到前面所有 tt 个 token 的 KV。这个阶段每一步的计算量小得可怜,但必须把全部模型权重和全部历史 KV从显存里读一遍——它是访存受限(memory-bound)的。

直觉的结论先摆在这里:prefill 浪费的是算力(大 batch 下算力不够用),decode 浪费的是带宽(算力闲置、带宽打满)。既然一个缺算力、一个缺带宽,把它们放进同一个批次,理论上就能同时利用两类资源——这就是 chunked prefill 类方法的全部动机。而「把整段 prompt 一次性算完」正好破坏了这件事:一段长 prompt 的 prefill 会独占整个批次,期间所有正在 decode 的请求只能干等。

这张图说明了什么:请求生命周期里 prefill 与 decode 的资源画像完全不同,前者吃算力、后者吃带宽,因此调度的目标不是「让某一段更快」,而是让每一轮迭代里两类资源都不闲置。

flowchart LR
  A["请求到达"] --> B["Prefill 并行处理整段 prompt"]
  B --> C["产出首 token 并写入 KV cache"]
  C --> D["Decode 每步生成一个 token"]
  D --> E["直到 EOS 结束"]
  B -. "计算受限: 算力吃满" .-> F["资源画像"]
  D -. "访存受限: 带宽吃满" .-> F

二、符号与术语约定

术语/符号表(后文统一使用,避免量纲混乱):

记号含义典型单位
PP模型参数量(不含 embedding 的细账先忽略)个
TT一次 prefill 处理的 token 数(prompt 长度)token
BB一个批次里的序列条数(batch size)条
SS序列当前长度(decode 时要读的历史 KV 长度)token
CC分块预填充的块大小(chunk size)token
FFGPU 的浮点峰值(bf16,密集)FLOP/s
WWGPU 的显存带宽Byte/s
RR机器平衡点 F/WF/W(roofline 拐点)FLOP/Byte
kk每个 token 的 KV 字节数(含 K 与 V、全层)Byte/token
τ\tau单轮迭代耗时(一次前向的墙钟时间)s
TTFT\text{TTFT}首 token 延迟(time to first token)s
TPOT\text{TPOT}每输出 token 耗时(time per output token)s

其中 KV 单 token 字节数是可算的:

k=2⋅L⋅Hkv⋅dh⋅bk = 2 \cdot L \cdot H_{\text{kv}} \cdot d_h \cdot b

LL 是层数,HkvH_{\text{kv}} 是 KV 头数,dhd_h 是每头维度,bb 是每元素字节数(bf16 取 2),前面的 2 来自 K 与 V 两份张量。

两点提前说明,它们决定了后面推导的适用范围。其一,本文把注意力部分当作与序列长度近似线性,这在长上下文下会低估计算量——真实的前向里有 O(S2)\mathcal{O}(S^2) 的注意力项,而它只影响计算侧、不影响权重与 KV 的访存量,因此长上下文会把系统更推向计算受限一侧。其二,kk 只与模型结构有关,与批大小无关;凡是「每个 token 一份、不随 batch 摊销」的显存占用,都归到这里。

按上面的符号,一个 7B 级模型(L=32L = 32、Hkv=32H_{\text{kv}} = 32、dh=128d_h = 128、bf16)的 kk 为:

k=2×32×32×128×2=524288 Byte/token≈0.5 MB/tokenk = 2 \times 32 \times 32 \times 128 \times 2 = 524288\ \text{Byte/token} \approx 0.5\ \text{MB/token}

这个 0.5 MB 的数字后文会反复出现——它是「并发上限」与「decode 访存压力」两个关键结论的共同来源。

三、形式化:prefill 与 decode 的算术强度

先算计算量。对 decoder-only 模型,一次前向的浮点运算量近似与「参与计算的 (token × 参数) 乘积」成正比,每个参数每次乘加计 2 次浮点运算:

FLOPsprefill≈2PT,FLOPsdecode≈2PB\text{FLOPs}_{\text{prefill}} \approx 2PT, \qquad \text{FLOPs}_{\text{decode}} \approx 2PB

注意 decode 的 2PB2PB 里那个 1 是「每序列 1 个 token」——BB 条序列各生成一个 token,所以计算量是 2P×B2P \times B。

再算访存量。无论哪个阶段,权重都必须完整读一遍(2P2P 字节,bf16);decode 还要读出历史 KV:

Bytesdecode≈2P+B⋅S⋅k\text{Bytes}_{\text{decode}} \approx 2P + B \cdot S \cdot k

prefill 要写 KV、读 prompt 的激活,但它的激活访存与 TT 同阶,可以写成 2P+αT2P + \alpha T(α\alpha 是每 token 的激活字节数)。于是两阶段的算术强度分别是:

AIprefill≈2PT2P+αT,AIdecode≈2PB2P+BSk\text{AI}_{\text{prefill}} \approx \frac{2PT}{2P + \alpha T}, \qquad \text{AI}_{\text{decode}} \approx \frac{2PB}{2P + BSk}

这两个式子的行为截然不同。令 T→∞T \to \infty,prefill 的算术强度趋于上界 2P/α2P/\alpha——它是一个与 TT 同阶增长然后饱和的量,很容易越过机器平衡点 RR,从而落在 roofline 的「计算受限」一侧。

decode 则相反。BB 增大时分子线性增长,但分母里的 BSkBSk 也线性增长,两者竞争的结果是:

AIdecode→B→∞2PSk\text{AI}_{\text{decode}} \xrightarrow{B \to \infty} \frac{2P}{Sk}

也就是说,decode 的算术强度存在一个由上下文长度 S\text{S} 决定的天花板,它根本不随 batch 增大而无限提高。用一台 RR 远大于该天花板的机器(现实中的 GPU 几乎都如此)去跑 decode,就是把它按在访存受限的那一侧。

由此得到第一个可执行的判断:batch size 只能把 decode 从「带宽利用率个位数」拉起来,拉不到计算受限区;想让算力不闲置,唯一的办法是往批次里塞 prefill。

四、把权衡写成一个约束优化问题

设服务有延迟约束:TPOT 不得超过 τmax⁡\tau_{\max}。单轮迭代的时间由「计算时间」与「访存时间」的较大者决定(这是常见的 roofline 近似,忽略了二者在实机上的部分重叠):

τ≈max⁡(2P(C+D)F,  2P+D⋅S⋅kW)\tau \approx \max\left(\frac{2P(C + D)}{F}, \; \frac{2P + D \cdot S \cdot k}{W}\right)

这里 CC 是本轮要处理的 prefill token 数,DD 是本轮放进去的 decode 序列数。没有分块时 CC 就是整段 prompt 长度,比如 C=4096C = 4096,那么第一项会瞬间压倒第二项,τ\tau 被拉长几十倍,批次里所有 decode 请求的 TPOT 一起被拖坏。这就是「高峰期上线的长 prompt 请求,会让同时在线的短对话集体卡顿」的机制解释。

有了分块,CC 变成一个可以自己选的决策变量,于是可以反解出「让两项相等」的块大小——这是让单轮迭代同时吃满算力与带宽的临界点:

2P(C+D)F=2P+DSkW\frac{2P(C + D)}{F} = \frac{2P + DSk}{W}

整理后:

C∗=R(1+DSk2P)−DC^{*} = R\left(1 + \frac{DSk}{2P}\right) - D

这个 C∗C^{*} 的含义很直白:分母里的 DSkDSk 是「本轮被陪跑的 decode 所携带的 KV 流量」,它越大,就越需要更多的 prefill token 来把算力填满,块就该越大;而减去 DD 是因为 decode 本身也占了 DD 个 token 的算力份额,要从块里扣掉。

代入一组具体数字(模型与硬件设定见下节):R=150R = 150 FLOP/Byte、D=32D = 32、S=2048S = 2048、k=0.5k = 0.5 MB/token、2P=142P = 14 GB:

C∗=150×(1+32×2048×52428814×109)−32≈150×3.45−32≈486C^{*} = 150 \times \left(1 + \frac{32 \times 2048 \times 524288}{14 \times 10^{9}}\right) - 32 \approx 150 \times 3.45 - 32 \approx 486

约 486 个 token——这就是「块不该是长度几千的大块,也不该是几十的小块」的来源。它是一个由机器平衡点、并发数、上下文长度和模型规模共同决定的量,而这个量在实际系统里往往表现为一个固定的调度预算(vLLM 的 max_num_batched_tokens)。

五、分块预填充:把 prefill 切成可以插队的碎片

分块预填充的做法一句话说完:把一个长 prompt 的 prefill 拆成若干近似等长的块,每一轮迭代只处理一块,并把剩余的批位让给正在 decode 的请求。

它带来的结构变化是:批次从「非纯 prefill 即纯 decode」的两种极端,变成一种混合批次(mixed batch)。第一份把这件事讲透的工作是 SARATHI,它的摘要里这么描述自己的机制:

SARATHI employs chunked-prefills, which splits a prefill request into equal sized chunks, and decode-maximal batching, which constructs a batch using a single prefill chunk and populates the remaining slots with decodes.

译:SARATHI 采用分块预填充,把 prefill 请求切成等长的块,并用「decode 最大化批处理」构造批次——一个 prefill 块加用 decode 填满剩余槽位。它还给出了一个关键的成本观察:prefill 块饱和 GPU 计算时,搭车的 decode 请求「cost up to an order of magnitude less compared to a decode-only batch」(成本比纯 decode 批次低至多一个数量级)。

同一个思路在不同系统里有不同名字,但对约束的处理是一致的:

系统名称块/批的构造方式主要解决
SARATHI(arXiv:2308.16369)chunked-prefills + decode-maximal batching一个 prefill 块 + 尽可能多的 decodedecode 算力利用率、流水线气泡
Sarathi-Serve(arXiv:2403.02310)chunked-prefills + stall-free scheduling近似等长块,新请求随时加入而不暂停 decode吞吐-延迟权衡、块长不均导致的流水线气泡
DeepSpeed-FastGen(arXiv:2401.08671)Dynamic SplitFuse动态拆分 prompt 与生成,重组进固定预算的批次长 prompt 场景的吞吐与尾延迟
vLLM V1(官方文档)chunked prefill(默认开启)先排完所有 decode,再用剩余 token 预算排 prefill同时改善 ITL 与 GPU 利用率

这张图说明了什么:不分块时,长 prompt 会独占若干轮迭代,decode 被迫让路;分块后每一轮都保持「少量 prefill + 大量 decode」的同构批次,迭代时间被压得平稳。

flowchart TB
  A["不分块: 迭代 1 整段 prompt 独占批次"] --> B["迭代 2 到 3: decode 才能继续"]
  B --> C["迭代 4: 下一个长 prompt 再次独占"]
  D["分块: 每轮 1 个 chunk 加 N 条 decode"] --> E["迭代 1 到 4: 每轮结构相同"]
  E --> F["长 prompt 与短对话的延迟互不干扰"]

分块带来的是一种延迟换稳定的交易,但交易的方向可以自己控制:块越小,单轮迭代越短(TPOT 越稳),但一个长 prompt 需要的轮数越多,它的首 token 来得越晚:

TTFT≈⌈TC⌉⋅τ,TPOT≈τ\text{TTFT} \approx \left\lceil \frac{T}{C} \right\rceil \cdot \tau, \qquad \text{TPOT} \approx \tau

这两个式子是理解调参的全部依据:CC 减小,τ\tau 减小,TPOT 变好,但 ⌈T/C⌉\lceil T/C \rceil 增大,TTFT 变差。vLLM 官方文档给出的经验正好落在这条曲线上:max_num_batched_tokens 取较小值(文档举例 2048)能获得更好的 inter-token latency,「because there are fewer prefills slowing down decodes」;取较大值则改善 TTFT,「as you can process more prefill tokens in a batch」;文档建议追求吞吐时把它设到大于 8192,尤其在小模型配大 GPU 时。

六、Stall-free 调度与流水线气泡

分块只是半件事。如果调度器仍然按「先来一个 prompt 就把它整块塞进去」的方式工作,块长就会参差不齐,迭代时间跟着忽长忽短。这对单卡是延迟抖动,对流水线并行(pipeline parallelism)则是气泡(bubble)——每个微批次的时间不齐,流水线里就会出现空转的级。

Sarathi-Serve 正是把这两件事合起来解:它用近似等长的块构造「stall-free」的调度,即在不暂停正在进行 decode 的前提下把新请求加进批次。论文摘要的表述是:

Sarathi-Serve introduces chunked-prefills which splits a prefill request into near equal sized chunks and creates stall-free schedules that adds new requests in a batch without pausing ongoing decodes.

并进一步指出均匀批次的价值:「uniform batches in Sarathi-Serve ameliorate the imbalance between iterations resulting in minimal pipeline bubbles」(均匀批次缓解了迭代之间的不平衡,使流水线气泡最小化)。

论文给出的实测口径(厂商/作者论文口径,非本文实测):Mistral-7B 单卡 A100 上服务容量提高 2.6 倍,Yi-34B 双卡 A100 上最高 3.7 倍;配合流水线并行跑 Falcon-180B 时,端到端服务容量提升最高 5.6 倍。更早的 SARATHI 报的是:LLaMA-13B 配 A6000 时 decode 吞吐最高 10 倍、端到端吞吐最高 1.33 倍;LLaMA-33B 配 A100 时端到端 1.25 倍、decode 吞吐最高 4.25 倍;GPT-3 上用流水线并行把气泡减少 6.29 倍、端到端吞吐提升 1.91 倍。

DeepSpeed-FastGen 走的是同一约束的另一种参数化。它的 Dynamic SplitFuse 不做「固定块长 + 填 decode」,而是动态地决定一个 prompt 这一轮往前走多少 token、生成侧这一步继续与否,把两者重组进一个固定 token 预算的批次。论文摘要给出的口径是:相对当时的 vLLM,有效吞吐最高 2.3 倍、平均延迟低 2 倍、token 级尾延迟低至多 3.7 倍。

把所有做法放在一起看,它们解的是同一个约束:批次的总 token 预算是有限的,prefill 与 decode 要在这个预算里分割,而分割方式决定了延迟的分布形状。固定等长块是保守解(延迟方差小、实现简单),动态拆分是激进解(更能贴合突发流量,但调度器复杂度与公平性风险都更高)。

六·补一、把公式换成秒:一轮迭代的逐步数值推导

抽象式子只有在能算出秒数时才有工程意义。下面用一组明确标注为假设的机器与模型参数走一遍完整计算(本例为本文自行推导,非任何来源的实测值):

设定:P=7BP = 7\text{B}(bf16 权重 2P=142P = 14 GB)、k=0.5k = 0.5 MB/token、F=300F = 300 TFLOP/s、W=2W = 2 TB/s,于是 R=F/W=150R = F/W = 150 FLOP/Byte;并发 D=32D = 32 条 decode,各自上下文 S=2048S = 2048。

第一步,算「与 prefill 无关的那部分访存耗时」——它是任何调度都省不掉的底噪:

τmembase=2P+DSkW=14×109+32×2048×5242882×1012≈24.2 ms\tau_{\text{mem}}^{\text{base}} = \frac{2P + DSk}{W} = \frac{14 \times 10^{9} + 32 \times 2048 \times 524288}{2 \times 10^{12}} \approx 24.2\ \text{ms}

拆开看:权重 14 GB ÷ 2 TB/s = 7.0 ms,KV 32 × 2048 × 0.5 MB = 34.4 GB ÷ 2 TB/s = 17.2 ms,合计 24.2 ms。

第二步,把第四节的平衡点条件代进去,得到本轮应有的 prefill 份额:C∗≈486C^{*} \approx 486 token。验算计算侧耗时是否与访存侧相等:

τcompute=2P(C∗+D)F=14×109×5183×1014≈24.2 ms\tau_{\text{compute}} = \frac{2P(C^{*} + D)}{F} = \frac{14 \times 10^{9} \times 518}{3 \times 10^{14}} \approx 24.2\ \text{ms}

两侧相等——这正是 C∗C^{*} 的定义,例子里它落在 24.2 ms 这个「带宽决定的地板」上。

第三步,读出延迟指标。τ≈24.2\tau \approx 24.2 ms,因此每条 decode 序列的 TPOT 约 24 ms(约 41 token/s/序列);一段 4096 token 的 prompt 需要 ⌈4096/486⌉=9\lceil 4096/486 \rceil = 9 轮才能出首 token,TTFT ≈ 9 × 24.2 ≈ 218 ms。

第四步,与「不分块」对照。整段 4096 token 的 prefill 独占批次时,计算侧变成:

τ整段=max⁡(2P(4096+32)F, 24.2 ms)=max⁡(192.6 ms, 24.2 ms)=192.6 ms\tau_{\text{整段}} = \max\left(\frac{2P(4096 + 32)}{F},\ 24.2\ \text{ms}\right) = \max(192.6\ \text{ms},\ 24.2\ \text{ms}) = 192.6\ \text{ms}

它的 TTFT 确实更短(约 193 ms,比 218 ms 快约 12%),但代价是这一轮里 32 条 decode 请求的 TPOT 被从 24 ms 拉长到 193 ms——8 倍。整段时间里这些用户什么都没等到。这就是「长 prompt 拖垮并发短对话」的定量形态。

第五步,检查「块是不是越小越好」。故意取一个远小于平衡点的块,比如 C=128C = 128:

τcompute=14×109×1603×1014≈7.5 ms<24.2 ms\tau_{\text{compute}} = \frac{14 \times 10^{9} \times 160}{3 \times 10^{14}} \approx 7.5\ \text{ms} < 24.2\ \text{ms}

此时迭代时间由图中的访存项决定,τ\tau 仍然是 24.2 ms——TPOT 一点没变好,但轮数涨到 ⌈4096/128⌉=32\lceil 4096/128 \rceil = 32 轮,TTFT 恶化到约 774 ms。反过来取 C=2048C = 2048:τcompute≈97\tau_{\text{compute}} \approx 97 ms 反超访存项,TPOT 恶化到 97 ms(约 4 倍),TTFT 反而降到约 194 ms。

把三种取值并列,规律就清楚了:

块大小 CC迭代时间 τ\tau受限于TPOT4096 prompt 的 TTFT
128(远小于平衡点)≈ 24.2 ms访存≈ 24 ms≈ 774 ms(32 轮)
486(平衡点 C∗C^{*})≈ 24.2 ms算力与访存同时吃满≈ 24 ms≈ 218 ms(9 轮)
2048(远大于平衡点)≈ 97 ms算力≈ 97 ms≈ 194 ms(2 轮)
4096(不分块)≈ 192.6 ms算力≈ 193 ms≈ 193 ms(1 轮)

这张表给出两条可能反直觉的结论。第一,在平衡点以下继续减小块是纯亏损:迭代时间已经被访存地板锁住,块再小也换不来更好的 TPOT,只会让长 prompt 的 TTFT 线性变差。第二,平衡点附近是「TPOT 最优 + TTFT 可接受」的交点:往下走 TTFT 变差而 TPOT 不变,往上走 TTFT 略好而 TPOT 变差数倍,所以调参的合理起点是算出的 C∗C^{*},再按业务对 TTFT 的容忍度向上微调。

这张图说明了什么:迭代时间对块长的依赖是分段函数——左侧被访存地板压平(块再小也没收益),右侧由算力线性抬升(吞吐升、TPOT 降级),平衡点就是两段的分界。

flowchart LR
  A["块极小: 访存受限, TPOT 不变, TTFT 恶化"] --> B["块等于平衡点: 两类资源同时吃满"]
  B --> C["块偏大: 计算受限, TPOT 恶化, TTFT 略优"]
  C --> D["整段不分块: TPOT 被 prefill 独占拖爆"]

六·补二、调度器每一轮在做什么(伪代码)

把上面的推导落到调度循环里,逻辑其实只有几行。注意它体现的是「先满足 decode,再用剩余预算切 prefill」这一顺序(vLLM V1 文档所述的策略),以及「块长随本轮剩余预算浮动」这一 Sarathi-Serve 式做法:

# 一轮迭代的批次构造(示意,非任何实现源码)
def step_batch(waiting, running, budget_tokens, kv_capacity_left):
    batch = []

    # 1) decode 优先:能跑的 decode 全部进入本轮批次,每条占 1 个 token
    for seq in running:
        if kv_capacity_left <= 0:          # KV 不够 → 抢占(V1 默认 RECOMPUTE)
            preempt(seq)                   # 被抢占的序列后续从头重算
            continue
        batch.append(seq.step_one_token())
        budget_tokens -= 1

    # 2) 剩余 token 预算全部用作本轮的 prefill 块
    for req in waiting:
        chunk = min(budget_tokens, req.remaining_prompt_len)
        if chunk <= 0:
            break
        batch.append(req.prefill(chunk))   # 切块:这一轮只算 chunk 个 token
        budget_tokens -= chunk
        if not req.finished_prefill:
            waiting.requeue(req)           # 没算完的放回,下一轮继续切

    # 3) 批内既有访存受限的 decode,又有计算受限的 prefill → 单轮迭代时间被压平
    return batch

这段伪代码解释了为什么分块调度器的核心复杂度都落在两处:一是 KV 预算的实时核算(决定哪些 decode 必须被抢占,而抢占会带来重算成本),二是 块的切分与排队公平性(块长浮动时,谁先拿到预算直接决定不同请求的 TTFT 分布)。论文里那句「stall-free」的含义也在这里:只要本轮批次永远同时容纳 decode 与 prefill 块,就不会出现「为了服务新 prompt 而暂停所有 decode」的时刻。

七、成本与显存:分块不能解决的那一半问题

分块调度优化的是「算力与带宽的分配」,它不增加任何显存。而并发能力的硬上限来自 KV cache 的容量:

Scap=Mkvk,Bmax⁡≈ScapSS_{\text{cap}} = \frac{M_{\text{kv}}}{k}, \qquad B_{\max} \approx \frac{S_{\text{cap}}}{S}

MkvM_{\text{kv}} 是分给 KV cache 的显存,ScapS_{\text{cap}} 是它能容纳的总 token 数,Bmax⁡B_{\max} 是在平均上下文 SS 下能同时驻留的序列数。例:Mkv=20M_{\text{kv}} = 20 GiB(显存与 KV 在本节与第十三节按 1 GiB = 2302^{30} 字节计,与第四节 SI 口径的 2P=14×1092P = 14 \times 10^{9} 字节不混用)、k=0.5k = 0.5 MiB/token 时 Scap=40960S_{\text{cap}} = 40960 token;若 S=2048S = 2048,则 Bmax⁡≈20B_{\max} \approx 20 条——并发二十条,与分块与否无关。

一旦请求数超过 Bmax⁡B_{\max},调度器只能抢占(preempt)。vLLM 官方文档对此的说明是:KV 空间不足时会抢占请求以腾出空间,被抢占的请求在空间恢复后被重新计算;并明确指出 V1 的默认抢占模式是 RECOMPUTE 而非 SWAP,「as recomputation has lower overhead in the V1 architecture」(因为在 V1 架构下重算的开销更低)。文档给出的常用处置包括提高 gpu_memory_utilization、降低 max_num_seqs 或 max_num_batched_tokens、提高 tensor parallel size 或 pipeline parallel size。

这里有一个容易踩的坑:max_num_batched_tokens 同时是吞吐旋钮和显存旋钮。调大它会同时提高每轮处理的 token 数(吞吐)与瞬时激活占用(更容易触发抢占),而抢占引发的重算又会反过来吞掉吞吐收益。这就是为什么「按第四节算出的平衡点给一个中间值」通常比「一口气调到上限」更稳。

八、工程上怎么用:三条可操作的判据

第一条,先量清楚瓶颈在哪一侧。用实测的 TPOT 与批次规模反推:如果提高并发后吞吐上升但 TPOT 几乎不变,说明仍在访存受限区,加 batch 有效;如果 TPOT 随批次线性恶化,说明已经进入计算受限区,此时要继续吃吞吐只能靠分块把算力填满,而不是继续加 batch。

第二条,块大小优先按延迟约束反解,再按吞吐微调。以第四节的形式化为例,给定 τmax⁡\tau_{\max} 与并发 DD,可以先解出允许的最大块(保证 τ≤τmax⁡\tau \le \tau_{\max}),再用第五节的 TTFT 式子检查首 token 是否还在 SLO 之内。两个约束都满足的区间通常不宽,这解释了为什么生产环境里 max_num_batched_tokens 经常需要按业务流量画像反复校准,而不是一次设死。

第三条,别把分块当成万能加速。它的收益来自「把 decode 的闲置算力用起来」和「把迭代时间压平」,因此它在混合负载(长 prompt 与短对话并发)下收益最大;如果线上流量全是同质的长 prompt,或者批次本来就小到算力从未跑满,分块的边际收益会明显变小,此时更该先看显存与抢占。

九、边界与未解

第一,第四节的 C∗C^{*} 推导用的是「计算时间与访存时间取较大者」这一 roofline 近似,忽略了 kernel 级重叠、CUDA graph 的捕获尺寸约束、注意力在长上下文下的二次项,以及张量并行引入的通信时间。实机上的最优块长通常比解析值更小,因为通信与调度开销会吃掉一部分预算。

第二,本文的算例把注意力计算当作与序列长度线性,这在超长上下文(几万 token 以上)不再成立——此时 prefill 的 FLOPs 有 O(S2)\mathcal{O}(S^2) 项,算术强度形态会变,块长的最优点随之移动。这也是长上下文服务把「块长」与「上下文分片」一起调的原因。

第三,延迟口径本身有争议。论文里的吞吐提升都是在尾延迟约束下测得的(例如 Sarathi-Serve 明确写「under tail latency constraints」),而尾延迟高度依赖流量模型:请求长度分布、到达过程、SLO 定义稍有不同,倍数就会变。把论文倍数直接套到自己的业务上是不成立的,这也是本文不把它们当作预期收益、只当作机制有效性证据的原因。

第四,分块与其它优化存在耦合:投机解码要求 decode 侧批量结构稳定,前缀缓存(如 SGLang 的 RadixAttention 所代表的复用思路)会改变 KV 的复用率继而改变访存侧压力,PD 分离则干脆把 prefill 与 decode 放到不同实例上——那种架构下本文的「混批」前提不再成立,权衡形态也随之变化。

十、小结

回到开头那个矛盾。prefill 吃算力、decode 吃带宽,而「整段 prefill 独占批次」的做法把两件事串行化了,于是长 prompt 一到、decode 就集体卡顿。分块预填充并不神秘,它只是把 prefill 这一个不可分割的大块,切成可以与自己批次里其它请求共享迭代的碎片,让每一轮迭代都存在可以并行调度的两类工作。

真正的工程含量在两处:一是块长不是拍脑袋定的,它由机器平衡点、并发数、上下文长度与模型规模的比值共同决定(第四节给了闭式估计);二是块长同时受 TTFT 与 TPOT 两个方向拉扯(第五节给了两个式子),所以任何「统一推荐值」都只是某个流量画像下的取值。

最后一句提醒:分块调度只在算力真的被闲置时才有收益。先确认瓶颈在算力侧,再动这个旋钮。

十一、线上诊断单:症状对机制

理论只有变成排查动作才有用。下表把本文推导过的机制与线上常见症状一一对应(每一行的「验证」都是可当场执行的一行动作):

症状对应机制一行验证
长 prompt 到达时,在线短对话集体卡顿数秒整段 prefill 独占批次,decode 被暂停把 TPOT 时间序列与 prefill 批次起始时刻对齐,看是否同相
并发提高后吞吐上升但 GPU 利用率仍低decode 处于访存受限区按第三节估 2PB/(2P+BSk)2PB/(2P+BSk),看是否远低于机器平衡点
提高 max_num_batched_tokens 后吞吐先升后降计算受限加剧 + 抢占重算反噬观察抢占计数(vLLM 会打印 preemption 警告)与 KV 使用率
TPOT 抖动明显、分位数长尾厚块长不均、批次耗时参差统计每轮批次耗时分布(而非均值),看方差
TTFT 在流量突发时暴涨,但 GPU 利用率不高瓶颈在排队而非算力看等待队列长度与入队到开算的时间差
并发上不去、单请求很快但整体吞吐低KV 容量上限(Bmax⁡≈Scap/S\text{B}_{\max} \approx S_{\text{cap}}/S)反算可用 KV 显存 ÷ k÷S\div\ k \div S,与实测并发上限比对

三条使用纪律:一次只动一个旋钮(max_num_batched_tokens 与 max_num_seqs、gpu_memory_utilization 互相影响,同时改就无法归因);看分布不看均值(TPOT 的 P99 才对应本文讨论的「被拖坏」);先算后调(第四节的 C∗C^{*} 至少能给出量级,避免在错误区间里反复试)。

还有一个容易被忽略的观察点:抢占本身是有成本的。被抢占的请求要重新计算已生成的 KV,所以重算代价近似与「被抢占时的序列长度」成正比:

Costpreempt≈2P⋅Spreempt FLOPs\text{Cost}_{\text{preempt}} \approx 2P \cdot S_{\text{preempt}} \ \text{FLOPs}

也就是说,越到生成后期被抢占,浪费越大。这解释了为什么「把并发压到刚好不超过容量」通常比「超卖再靠抢占兜底」更划算——后者会把算力大量花在重复劳动上。

十二、三类耦合:分块不是孤立的旋钮

线上的推理栈很少只跑一种优化,而几乎所有优化都会改变本文用到的两个变量(每轮算力需求、每轮访存需求),因此必须一起看。

前缀缓存(前缀复用)。 若一批请求共享相同前缀(系统提示词、少样本示例、多轮对话历史),缓存命中率 hh 会让真正需要计算的 prefill token 数从 TT 降到 T(1−h)T(1-h)。代入第五节的 TTFT 式子:

TTFTcache≈⌈T(1−h)C⌉⋅τ\text{TTFT}_{\text{cache}} \approx \left\lceil \frac{T(1-h)}{C} \right\rceil \cdot \tau

它的效果是同时改善 TTFT(轮数变少)与访存侧(省掉重复 KV 写入),因此是少见的「两头都赚」的优化。代价是缓存查找与淘汰逻辑的复杂度,以及命中率对前缀格式的敏感——SGLang 的 RadixAttention 正是把这类复用做成树结构来提升命中率的代表工作(其论文报告相对当时系统最高 6.4 倍吞吐)。

投机解码。 投机解码一次前向验证 γ\gamma 个候选 token,于是 decode 的算术强度近似变成:

AIspec≈2PBγ2P+BSkγ′\text{AI}_{\text{spec}} \approx \frac{2PB\gamma}{2P + BSk\gamma'}

分子乘以 γ\gamma,分母里的 KV 项只按实际接受的token 增长(记为 γ′≤γ\gamma' \le \gamma)。效果是把 decode 往计算受限一侧推——这在「算力闲着、带宽吃满」时是纯收益,但它会和分块挤同一份算力预算。所以在启用投机解码的实例上,C∗C^{*} 必须重算:可用于 prefill 的算力份额变小了,块应更小。

PD 分离。 把 prefill 与 decode 放到不同实例(或不同 GPU 池)上以后,本文「混批以填满两侧资源」的前提就不成立了——两类工作不再共享同一轮迭代。权衡随之转移:prefill 实例要处理长 prompt 的算力峰值,decode 实例要维持稳定的 TPOT,而两者之间需要把生成的 KV 搬过去,单请求传输量与上下文长度成正比:

Bytestransfer≈S⋅k\text{Bytes}_{\text{transfer}} \approx S \cdot k

这也解释了为什么 PD 分离在长上下文、高并发下才划算:它用一次显存的搬运,换掉了混批调度里无法完全消除的相互干扰。

三类耦合的共同结论是:分块预填充解决的是「同实例内两类工作如何共享一轮迭代」,一旦这一前提被别的优化改变(复用让 prefill 变少、投机让 decode 变重、分离让两者不再同池),块长就必须重新推导,不能沿用旧值。

十三、三类真实负载下怎么选块长

同一套公式在不同流量画像下会给出不同答案,这也是「官方推荐值」不能直接照搬的原因。下面按三类常见负载各推一遍(数字沿用第六节的假设机器)。

负载一:同质长文档处理(长 prompt、短输出)。 大量请求都是 8000 token 输入、200 token 输出,且并发不高(D=8D = 8)。代入第四节:

C∗=150×(1+8×2048×52428814×109)−8≈150×1.61−8≈234C^{*} = 150 \times \left(1 + \frac{8 \times 2048 \times 524288}{14 \times 10^{9}}\right) - 8 \approx 150 \times 1.61 - 8 \approx 234

块长只有 234 token,而 prompt 有 8000 token,意味着每个请求要切 35 轮。因为并发低、decode 少,访存地板本来就低,块长得小反而让 TPOT 更稳。这类负载的调参重点在 TTFT:并发上不去时,块大一点让 prompt 更快算完,整体体验更好——在没有 decode 可保护的场景里,分块的收益主要来自「不阻塞新请求」,而不是「填满算力」。

负载二:客服对话(短 prompt、长输出、高并发)。 prompt 只有 200 token,输出 800 token,并发 D=64D = 64。此时:

C∗=150×(1+64×2048×52428814×109)−64≈150×5.9−64≈821C^{*} = 150 \times \left(1 + \frac{64 \times 2048 \times 524288}{14 \times 10^{9}}\right) - 64 \approx 150 \times 5.9 - 64 \approx 821

注意这里的含义变了:C∗C^{*} 远大于实际 prompt 长度(200),说明这批请求根本不需要分块——它们天然就不会独占批次。真正的问题在访存侧:64 条 × 2048 token × 0.5 MiB = 64 GiB 的 KV(按 2302^{30} 计,约合 68.7 GB SI 口径),远超单卡可容纳量,系统会陷入抢占重算。于是这类负载的正确动作不是调块长,而是增大并行度或降低 max_num_seqs。

负载三:混合突发(少量长 prompt 混进大量短会话)。 这正是分块预填充设计时的目标场景,也是它收益最大的地方:DD 大(比如 32)而 prompt 长度分布很宽。此时固定块长的做法会两头不讨好,动态拆分(DeepSpeed-FastGen 的 Dynamic SplitFuse 一类)按「本轮实际剩余预算」决定切多少,更贴合;代价是调度器的公平性更难保证,需要额外关注超长 prompt 是否被反复插队导致饿死。

三类负载对照:

负载画像C∗C^{*} 量级真正瓶颈优先动作
长 prompt 低并发数百 tokenTTFT 与算力峰值适当放大块,减少轮数
短 prompt 高并发远大于 prompt 长度(无需分块)KV 容量 / 抢占提高并行度、限制并发数
混合突发数百 token 且随流量浮动两类资源同时动态拆分 + 公平性保护

十四、实现层面的三个坑

第一,预算单位是 token,不是请求数。max_num_batched_tokens 限制的是一轮里参与前向的 token 总数,而 decode 每条只占 1 个 token。于是同一组参数在「长 prompt 多」与「纯对话」两种流量下的实际批次形态完全不同——这也是为什么按请求数写容量规划会算错。

第二,KV 是预分配的,改并发不等于改速度。gpu_memory_utilization 决定预留给 KV 的显存比例,它一旦设好,可用 token 槽位(ScapS_{\text{cap}})就定了;调大 max_num_seqs 只是允许更多序列同时排队,并不凭空增加槽位,超出部分照样触发抢占与重算。

第三,捕获尺寸与批次形状互相牵制。CUDA graph 需要按固定的批次尺寸提前捕获,而分块调度下每轮的批次形状是浮动的,于是捕获尺寸集合不可能覆盖全部组合,只能挑常用尺寸。这就是「理论上最优块长」与「实机实测最优块长」总有偏差的一个具体来源,也提醒我们:解析推导用来定区间,最终取值必须靠压测。

十五、五条常见误解

最后把工程上最容易走偏的五句话逐条校正,判断依据都来自前文推导。

误解一:「分块预填充提高吞吐,是因为它省了计算量。」 不对。分块不减少任何 FLOPs——同样的 prompt 照样要算完,只是被分散到多轮迭代里。它提高吞吐的唯一机制是让每轮迭代同时存在计算受限(prefill 块)与访存受限(decode)两类工作,把原本闲置的那一侧资源用起来。

误解二:「块越小,延迟一定越好。」 只在平衡点以上成立。第六·补一的表格已经给出反例:块从 486 降到 128,TPOT 一点没变(被访存地板锁住),而 TTFT 从约 218 ms 恶化到约 774 ms。盲目调小块是把延迟往坏的方向推。

误解三:「分块能提高并发上限。」 不能。并发上限由 KV 容量决定(Bmax⁡≈Scap/SB_{\max} \approx S_{\text{cap}}/S),分块只改调度、不改显存。要提并发只有三条路:加显存、加并行度、缩短上下文。

误解四:「论文里的 2.6 倍可以直接当我的收益预期。」 不能。这些倍数是在特定模型、特定硬件、特定尾延迟约束下测得的,且高度依赖流量画像。它们的正确用途是证明机制有效,不是估算收益。

误解五:「既然 decode 是访存受限的,那 decode 就没救了。」 也不对。decode 的算术强度存在由上下文长度决定的上限(第三节的 2P/(Sk)2P/(Sk)),但提高 batch 仍能显著改善带宽利用率——只是它永远到不了计算受限区。真正的解法组合是:分块(填充算力)+ 大 batch(填满带宽)+ 前缀复用(减少无谓访存),三者作用在资源画像的不同位置,互不替代。

📋 来源与核验记录

下列一手来源均为本轮实地打开核验(2026-10-02):

  1. Agrawal, A. et al. SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills, arXiv:2308.16369(2023-08-31 提交)——分块预填充与 decode-maximal batching 的原始出处;LLaMA-13B/A6000 上 decode 吞吐最高 10 倍、端到端最高 1.33 倍;LLaMA-33B/A100 上端到端 1.25 倍、decode 吞吐最高 4.25 倍;GPT-3 配流水线并行气泡减少 6.29 倍、端到端 1.91 倍。https://arxiv.org/abs/2308.16369
  2. Agrawal, A. et al. Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve, arXiv:2403.02310(v3,2024-06-17 修订)——stall-free 调度与近似等长块;Mistral-7B/单卡 A100 服务容量 2.6 倍、Yi-34B/双卡 A100 最高 3.7 倍、Falcon-180B 配流水线并行最高 5.6 倍;明确在尾延迟约束下测量。https://arxiv.org/abs/2403.02310
  3. Holmes, C. et al. DeepSpeed-FastGen: High-throughput Text Generation for LLMs via MII and DeepSpeed-Inference, arXiv:2401.08671(2024-01-09)——Dynamic SplitFuse 的 prompt/生成组合策略;相对当时 vLLM 的有效吞吐最高 2.3 倍、平均延迟低 2 倍、token 级尾延迟低至多 3.7 倍。https://arxiv.org/abs/2401.08671
  4. Zheng, L. et al. SGLang: Efficient Execution of Structured Language Model Programs, arXiv:2312.07104(v2,2024-06-06)——RadixAttention 的 KV 复用与结构化输出解码;文中报告的相对吞吐最高 6.4 倍(用于说明 KV 复用对访存侧压力的影响)。https://arxiv.org/abs/2312.07104
  5. vLLM 官方文档 Optimization and Tuning(vLLM V1,文档页更新于 2026-08-20)——chunked prefill 在 V1 中「enabled by default whenever possible」;调度策略「prioritizes decode requests… batches all pending decode requests before scheduling any prefill operations」,剩余 token 预算再排 prefill,装不下就自动切块;max_num_batched_tokens 较小值(文档举例 2048)改善 ITL(原文理由:fewer prefills slowing down decodes)、较大值改善 TTFT、追求最优吞吐建议大于 8192;V1 默认抢占模式为 RECOMPUTE 而非 SWAP,并建议用 Prometheus 指标监控抢占次数。https://docs.vllm.ai/en/latest/configuration/optimization/

本文自行推导与自算数字(非上述来源,属本文计算,假设值已随算式列出):

  • 第三节的算术强度两式与 decode 强度上界 2P/(Sk)2P/(Sk):由「每参数每 token 计 2 FLOPs」与「权重 2P 字节、KV 为 BSk 字节」推出。
  • 第四节平衡点块长 C∗=R(1+DSk/2P)−DC^{*} = R(1 + DSk/2P) - D,以及算例:R=150R = 150、D=32D = 32、S=2048S = 2048、k=0.5k = 0.5 MB/token、2P=142P = 14 GB(对应约 70 亿参数的 bf16 权重)→ C∗≈486C^{*} \approx 486 token。
  • 第五节 TTFT/TPOT 与块长的关系式,以及第七节并发上限算例:Mkv=20M_{\text{kv}} = 20 GB、k=0.5k = 0.5 MB/token、S=2048S = 2048 → Scap=40960S_{\text{cap}} = 40960 token、Bmax⁡≈20B_{\max} \approx 20 条。
  • 第六·补一的全部时间算例:τmembase≈24.2\tau_{\text{mem}}^{\text{base}} \approx 24.2 ms(权重 7.0 ms + KV 17.2 ms)、τcompute(C∗)≈24.2\tau_{\text{compute}}(C^{*}) \approx 24.2 ms、整段 prefill 约 192.6 ms、C=128C=128 时计算侧约 7.5 ms、C=2048C=2048 时约 97 ms,以及由此得到的 TTFT 对照(约 774 ms / 218 ms / 194 ms / 193 ms)与「TTFT 相差约 12%」这个比值——12% 是本文用 218 ms 与 193 ms 自行算出的相对差,不出自任何来源。
  • 第十三节的三类负载块长算例:C∗≈234C^{*} \approx 234(D=8)、C∗≈821C^{*} \approx 821(D=64)、以及 64 条 × 2048 token × 0.5 MB = 64 GB 的 KV 估算。

假设与限制:机器平衡点 R=150R = 150 FLOP/Byte 与 k=0.5k = 0.5 MB/token 均为算例假设值(未在本文中逐项核验具体硬件与模型的官方规格),结论(块长为数百 token 量级、decode 强度存在上限、并发受 KV 容量约束)不依赖这两个具体取值的精确度,但算例数字本身不应被当作任何特定 GPU 或模型的实测值。