大模型推理优化学习笔记
本文内容由Authropic Claude Opus 5-5模型生成。信息仅供参考,不构成专业建议。
尽管AI尽力确保信息的准确性,但并不能保证其完全无误或适用于所有场景,请结合实际情况审慎使用。
前言
个人学习记录文档,如有谬误请帮忙甄别和指正!
上一篇《从零开始的大模型(Transformer)学习笔记》是从 Transformer 原理、显存构成、GPU 硬件一路往上讲的,博主隔了一段时间回头复习推理优化,发现连 prefill 是做什么的都想不起来了,翻回去一看,那篇笔记里本来就没有 prefill、decode 这一层,两篇的目的不一样,硬插进去结构也别扭,所以单开一篇
这篇换成推理服务的视角:一条请求进了推理引擎之后怎么排队、怎么计算、占多少显存、什么时候被抢占,以及这些事分别体现在哪个指标上、能调哪个参数
本文的数字都来自下面的公开资料,每处都写明了出处和当时的实验条件,没有出处的地方只讲机制,不给数字:
- 「探索云原生」公众号的三篇:为什么需要 PD 分离、vLLM PD 分离实战、vLLM 多副本如何复用 KV Cache,下文简称公众号第一、二、三篇
- vLLM 官方文档(docs.vllm.ai,2026-10 抓取的 latest 版本)
- vLLM 2023 年发布 PagedAttention 的博客
- DistServe 的博客和论文(PD 分离的出处)
阅读顺序:第一部分是基础概念,第二部分是单条请求的执行过程,第三部分是单卡多请求的调度与显存管理,第四部分是多实例部署,第五部分是按指标排查,复习时可以直接翻第五部分,看不懂再往前找
一、基础概念
推理和训练的区别
推理(inference):权重已经训练好,只做前向计算,不算梯度,也不存优化器状态,上一篇训练显存那一节里的激活值、梯度、Adam 状态,在推理时基本都不存在
推理时显存主要被三样东西占住:模型权重(固定)、KV Cache(随请求数和上下文长度变化)、临时激活值(算完就释放),推理优化里的大部分问题都在跟第二样打交道
推理引擎(inference engine):负责接请求、排队、调度、调用 GPU 计算、返回结果的那一层软件,比如 vLLM、SGLang、TensorRT-LLM,本文以 vLLM 为例
Prefill 与 Decode
自回归(autoregressive):模型每次只生成一个 token,把它拼回输入,再生成下一个,直到出现终止 token 或者达到长度上限(上一篇 KV Cache 一节讲过)
因为这个特点,一次请求会先后经过两个阶段:
- Prefill(预填充):把用户输入的整段 prompt 一次性送进模型做一遍前向计算,为 prompt 里的每个 token 算出 K、V 存进 KV Cache,同时产出第一个输出 token
- Decode(解码):从第一个 token 之后开始,每一步只为这条请求生成一个新 token,新 token 的 Q 和 KV Cache 里所有历史 token 的 K、V 做注意力计算,再把自己的 K、V 追加进去,一直重复到结束
同一条请求只做一次 prefill,之后做很多步 decode:第一个输出 token 由 prefill 产出,输出 n 个 token 就是 1 次 prefill 加 n−1 步 decode
计算密集与访存密集
计算密集(compute-bound):时间主要花在矩阵乘法上,瓶颈是 GPU 的算力(Tensor Core)
访存密集(memory-bandwidth-bound):时间主要花在把数据从显存(HBM)搬到计算单元上,算力在等数据,瓶颈是显存带宽
模型每做一次前向计算,都要把全部权重从 HBM 读一遍(上一篇”寄存器 -> L1 -> L2 -> HBM”一节),两个阶段的差别在于读进来的这份权重被多少个 token 用:
- prefill 一次处理整段 prompt,prompt 有多少个 token,这份权重就被用多少次,所以算力先被打满
- decode 每条请求每步只有 1 个 token,权重读进来只用一次,算力大部分时间在等权重搬完
DistServe 博客的说法:prefill 是计算密集的,一小批 prefill、甚至一条足够长的 prefill 就能占满 GPU 算力;decode 要很大的 batch 才能达到算力上限,通常先受限于显存带宽
后面大部分优化手段都从这里出发:decode 要提高算力利用率,就要把很多条请求放进同一步计算(合批);prefill 则要控制单步的计算量,避免增加同一步里 decode 请求的延迟
推理指标
TTFT(Time To First Token,首 token 时延):从请求到达到收到第一个输出 token 的时间,包含排队、prefill,以及前面的分词、路由开销
TPOT(Time Per Output Token):第一个 token 之后,平均每个输出 token 花的时间,常见算法是 (端到端时延 − TTFT) ÷ (输出 token 数 − 1)
ITL(Inter-Token Latency,token 间延迟):相邻两个输出 token 之间的时间。TPOT 是平均值,会把毛刺抹平,流式输出卡不卡要看 ITL 的高分位(P95、P99)
端到端时延(E2E latency):从请求到达到最后一个 token 返回,等于 TTFT 加上 decode 阶段的总时长
吞吐(throughput):单位时间处理的量,常见口径有输出 token/s、总 token/s(输入加输出)、请求/s,看压测报告先看清是哪个口径
SLO(Service Level Objective,服务等级目标):业务对延迟的要求。DistServe 博客举的例子:聊天机器人要求首字响应快(例如 0.2 秒以内),decode 跟上人的阅读速度就行;代码补全则要求整段生成都快
Goodput(有效吞吐):DistServe 提出的口径,每秒完成的、同时满足 SLO 的请求数,吞吐很高但大部分请求超时,goodput 就很低
按阶段分:TTFT 主要看 prefill 侧,TPOT 和 ITL 主要看 decode 侧(公众号第一篇)
多卡并行
单卡放不下模型,或者单卡吞吐不够时,就要用多张卡,切法不同名字就不同(vLLM 文档 Parallelism and Scaling、Optimization and Tuning 两页)
TP(Tensor Parallelism,张量并行):把每一层的权重矩阵切开分到多张卡上,每张卡算一部分,每层算完做一次 all-reduce 把结果合起来(上一篇 All-Reduce 一节),vLLM 用的是 Megatron-LM 的切法
- 用在:单卡放不下模型;或者需要减少每张卡上的权重,腾出显存给 KV Cache
- 代价:每层都要通信,文档提醒 TP 加大可能带来过多的同步开销,所以一般放在同一台机器内、卡间互联快的地方
- 参数:–tensor-parallel-size / -tp
PP(Pipeline Parallelism,流水线并行):按层切,前几层放卡 0,后几层放卡 1,数据按顺序流过去
- 用在:TP 用满了还放不下,或者要跨机器;支持不均匀切分
- 文档还有一条:节点内的卡之间没有 NVLink 时(文档举的例子是 L40S),用 PP 替代 TP 吞吐更高、通信开销更小。自己的卡有没有 NVLink,用上一篇的 nvidia-smi topo -m 看
- 代价:文档的说法是可能带来延迟上的损失
- 参数:–pipeline-parallel-size / -pp。常见做法是 TP 设成每台机器的卡数,PP 设成机器数,比如 2 台各 8 卡就是 TP=8、PP=2
DP(Data Parallelism,数据并行):把整个模型完整复制好几份,每份处理不同的请求
- 用在:卡够多,要的是更多吞吐而不是放下更大的模型
- 在 vLLM 里每个 DP rank 是一个独立的 engine core 进程,各自有调度器和 KV Cache,DP 可以和 TP 叠加,比如 –data-parallel-size 4 –tensor-parallel-size 2 要 8 张卡
- 注意 –max-num-seqs 是每个 DP rank 各算各的,不是整个服务的总数
- 文档的原话是每个 DP engine 的 KV Cache 相互独立,请求路由得好,前缀缓存的收益才最大,第四部分的缓存感知路由讲的就是这件事
- 参数:–data-parallel-size / -dp
EP(Expert Parallelism,专家并行):只用于 MoE(混合专家)模型,比如 DeepSeek-V3、Qwen3-MoE,把不同的专家网络放到不同的卡上
- vLLM 里打开 –enable-expert-parallel 后,MoE 层改用 EP 代替 TP,并行度沿用 TP 的设置
- 和 DP 组合时(attention 层用 DP,专家层用 EP 或 TP),各 DP rank 就不完全独立了,每次前向都要在专家层同步,没有请求的 rank 也要执行空的前向计算(dummy forward)
PD与DP:该缩写相近,很容易混淆,但是其含义完全不同
| DP(数据并行) | PD 分离(Prefill/Decode Disaggregation) | |
|---|---|---|
| 切的是什么 | 请求:不同的请求分给不同的模型副本 | 阶段:同一条请求的 prefill 和 decode 放到不同的实例上 |
| 每个实例做什么 | 完整地处理一条请求,从 prefill 到 decode | P 实例只做 prefill,D 实例只做 decode |
| 实例之间要传什么 | 不传,各算各的 | 每条请求的 KV Cache 都要从 P 传到 D |
| 解决什么问题 | 吞吐不够 | prefill 和 decode 互相干扰;两个阶段需要不同的硬件或并行策略 |
| vLLM 里怎么开 | –data-parallel-size,或者直接起多个副本 | 起两组实例,用 –kv-transfer-config 配 KV Connector,前面再加一个 Proxy 编排 |
两者可以叠加:PD 分离之后,P 组和 D 组内部各自还可以有多个副本(这就是 DP),每个副本内部还可以开 TP
DistServe 博客提到了两者的联系:两个阶段部署在一起时只能使用同一套并行策略;TTFT 要求严、TPOT 要求松的场景下,prefill 适合用 TP 降低延迟,decode 更适合用 DP 或 PP 提高吞吐,拆开以后两边可以分别配置
术语索引
| 名词 | 一句话 | 展开位置 |
|---|---|---|
| KV Cache | 存历史 token 的 K、V,decode 时不用重算 | 第二部分 |
| Continuous Batching | 每一步重新选择参与计算的请求,请求可随时加入和退出 | 第三部分 |
| Chunked Prefill | 长 prompt 切成小块,和 decode 拼进同一步 | 第三部分 |
| PagedAttention | KV Cache 按固定大小的块分页管理 | 第三部分 |
| 抢占(Preemption) | KV 不足时释放某条请求的 KV 并放回等待队列,之后重算 | 第三部分 |
| Prefix Caching(APC) | 相同前缀的 KV 直接复用,少做 prefill | 第三部分 |
| 缓存感知路由 | 多副本时把请求发给已经有缓存的那个 | 第四部分 |
| PD 分离 | prefill 和 decode 放到不同的实例 | 第四部分 |
二、单条请求的执行过程
本部分只考虑一条请求,不涉及多请求间的资源竞争
请求时间线
1 | |
vLLM 在引擎里给每条请求记了几个时间点(vLLM 文档 design/metrics):
- QUEUED:请求进入调度器的等待队列
- SCHEDULED:第一次被调度执行
- NEW_TOKENS:某一步生成了新 token
- PREEMPTED:被抢占,放回等待队列,之后会重新开始 prefill
用这几个时间点算出来的区间,直接对应 Prometheus 指标:
| 区间 | 从哪到哪 | 指标 |
|---|---|---|
| 排队 | QUEUED → 最近一次 SCHEDULED | vllm:request_queue_time_seconds |
| prefill | 最近一次 SCHEDULED → 第一次 NEW_TOKENS | vllm:request_prefill_time_seconds |
| decode | 第一次 NEW_TOKENS → 最后一次 NEW_TOKENS | vllm:request_decode_time_seconds |
| token 间隔 | 相邻两次 NEW_TOKENS | vllm:inter_token_latency_seconds |
| TTFT | 前端收到请求(从分词开始算)→ 第一个 token | vllm:time_to_first_token_seconds |
| 端到端 | 前端收到请求 → 前端收到最后一个 token | vllm:e2e_request_latency_seconds |
TTFT 升高时,先拆成排队和 prefill 两段,确认是哪一段升高,第五部分的排查也从这一步开始
注意 TTFT 是从前端开始算的,比”排队 + prefill”多出分词等输入处理的时间,客户端测到的 TTFT 还要再加上网络和 HTTP 的开销
Prefill
prefill 要为 prompt 里的每个 token 都算出 K、V,所以 prompt 越长,计算量越大,prefill 越慢,TTFT 越高
公众号第三篇有一组能直接看出 prefill 有多贵的数据(Qwen2.5-0.5B,FP16,eager 模式,vLLM v0.31.0,并发 1,前缀约 5200 token):同一段前缀,落到已经有缓存的副本上 TTFT 中位数 132.2 ms,落到没有缓存、要重新做 prefill 的副本上是 2005.3 ms
prefill 结束时会顺带产出第一个输出 token,这也是为什么 TTFT 基本由”排队 + prefill”决定
Decode
decode 每一步,每条请求只生成一个 token,但这一步里:
- 模型权重要从显存完整读一遍(第一部分”计算密集与访存密集”)
- 这条请求在 KV Cache 里的所有历史 K、V 也要读一遍,用来和新 token 的 Q 做注意力
所以上下文越长(prompt 加上已经生成的部分),每步要读的 KV 越多(公众号第一篇),同一条请求越往后生成,每一步的工作量越大
一条请求的 decode 阶段一共要走 n−1 步,所以输出越长,端到端时延越长,这部分时间 TTFT 里看不到,要看 TPOT 和 decode 时长
KV Cache
KV Cache 的大小公式在上一篇”KV Cache:运维时的显存计算核心”一节,这里只强调两点:
- 它跟着 token 数涨:每条请求的每个 token 都要占一份,prompt 越长、生成越多、同时在跑的请求越多,占得越多
- 它在 prefill 时写入,在 decode 的每一步被读取并追加,是 prefill 交给 decode 的全部状态,后面 PD 分离要把两个阶段拆到不同机器上,传的也就是这份东西
vLLM 启动时会把能给 KV Cache 用的显存换算成 token 数打在日志里,官方文档里的例子:
1 | |
- GPU KV cache size:整张(或整组)卡的 KV Cache 同时最多能放多少个 token
- Maximum concurrency:假设每条请求都用满 max_model_len(例子里是 40,960),最多能同时跑几条
公众号第三篇把每个实例的 KV Cache 预算用 –kv-cache-memory-bytes 固定成 256 MiB,启动日志显示 Qwen2.5-0.5B 在这个预算下能放 21840 个 token,文章也提醒了:KV Cache 预算不等于进程总显存,权重等还要额外占
KV Cache 预算默认由 –gpu-memory-utilization 决定(文档里的默认值是 0.92),这个比例是按单个 vLLM 实例算的,同一张卡上跑两个实例,要各自设成比如 0.5
三、单卡多请求
Continuous Batching
Continuous Batching(连续批处理),也叫 iteration-level scheduling(迭代级调度):引擎每跑一步(一次前向计算)之前,都重新决定这一步带上哪些请求,所有被带上的请求在这一步里各生成一个 token(DistServe 博客的说法)。已结束的请求立即移出批次,新请求在下一步即可加入,不用等整批完成
这样 decode 阶段一步里有很多条请求,一次读进来的权重被它们共用,这正是第一部分说的 decode 需要合批
两个上限参数:
- –max-num-seqs:一步里最多带多少条请求
- –max-num-batched-tokens:一步里最多处理多少个 token,prefill 一条请求可能占几千个 token,decode 一条请求只占 1 个
对应的指标:
- vllm:num_requests_running:正在批里跑的请求数
- vllm:num_requests_waiting:在等待队列里的请求数
- vllm:iteration_tokens_total:每一步处理了多少个 token 的直方图
batch 加大的效果,公众号第一篇引用了 DistServe 论文图 3:prefill 的吞吐随 batch 增大较早趋于平缓,decode 的吞吐可以随 batch 增大继续提升,但 decode 加大 batch 仍然受 TPOT、ITL 和 KV Cache 容量约束
Prefill 对 Decode 的干扰
continuous batching 把 prefill 和 decode 放进同一个批里跑,DistServe 博客描述了它的副作用:两个请求一起跑时,decode 那条的延迟被明显拉长,prefill 那条也稍微变慢;请求持续到达时,每有一个新的 prefill 进入,正在 decode 的请求就会被延迟一次
结果就是 ITL 上出现毛刺:平均 TPOT 变化不大,但流式输出会出现间歇性的停顿
Chunked Prefill
vLLM 文档(Optimization and Tuning)对 V1 的说明:
- chunked prefill 在 V1 里能开的情况下默认开启
- 调度时 decode 优先:先把所有待跑的 decode 请求排进这一步,max_num_batched_tokens 的预算还有剩,再排 prefill;一个 prefill 放不进剩下的预算,就自动切块,剩下的部分留到后面几步
所以 –max-num-batched-tokens 就成了 TTFT 和 ITL 之间的一个旋钮(同一页文档):
- 调小(文档举例 2048):每步混入的 prefill token 少,对 decode 的影响小,ITL 更好
- 调大:每步能处理更多 prefill token,TTFT 更好
- 追求吞吐时,文档建议大于 8192,小模型跑在大卡上尤其如此
- 设成和 max_model_len 一样,基本等于 V0 默认的调度方式(但仍然 decode 优先)
不过这个值不好找,vLLM 的 PD 分离文档原话是:合适的 chunk size 也能达到控制尾部 ITL 的目的,但实际中很难找到正确的值,所以 PD 分离是控制尾部 ITL 更可靠的办法
还有一个副作用要记住:decode 优先意味着在跑的请求很多时,留给 prefill 的预算变少,一条长 prompt 要切成更多块、跨更多步才能做完,这部分时间算在 prefill 区间里
PagedAttention
vLLM 2023 年的发布博客(vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention)把问题讲得很直接:每条请求的 KV Cache 大小取决于序列长度,而序列长度变化大、事先也不知道,当时已有的系统因为碎片和过度预留,浪费了 60%–80% 的显存
PagedAttention 的做法参考了操作系统的虚拟内存分页:
- 每条请求的 KV Cache 切成固定大小的块(block),每块放固定数量 token 的 K、V
- 块在显存里不需要连续,用到哪分配到哪
- 博客里的对应关系:块对应页,token 对应字节,请求对应进程
这样浪费只发生在每条请求的最后一个块里,博客给的数字是浪费不到 4%。同一个 prompt 的多个采样输出还能共享块,并行采样、beam search 这类场景显存最多省 55%,吞吐最多提升 2.2 倍
同一篇博客的吞吐对比(LLaMA-7B 跑在 A10G、LLaMA-13B 跑在 A100 40GB,输入输出长度从 ShareGPT 数据集采样):每条请求一个输出时,vLLM 比 HuggingFace Transformers 高 14–24 倍,比 TGI 高 2.2–2.5 倍
压测里怎么体现:PagedAttention 让同样的显存能装下更多条请求的 KV,单条请求并不会因此变快,它抬高的是同时能跑的请求数,以及吞吐随并发上涨能涨到多高。现在它是 vLLM 管理 KV Cache 的基础,Engine Arguments 文档里没有关掉它的参数,所以没法开关对比,压测时能看到的是它的结果:
- 启动日志里的 GPU KV cache size 和 Maximum concurrency
- 压测中 vllm:kv_cache_usage_perc(1 表示 100%)涨到多少
- 块大小用 –block-size 设置,当前值可以在 vllm:cache_config_info 的 block_size 标签里看到
抢占(Preemption)
vLLM 文档(Optimization and Tuning):自回归生成的请求会不断变长,总有 KV Cache 装不下当前所有在跑请求的时候,这时 vLLM 会抢占(preempt)一部分请求,释放它们的 KV Cache 给其他请求用,被抢占的请求等显存够了再重新计算
- V1 默认的抢占方式是 RECOMPUTE(重算),不是 SWAP(换出到 CPU 内存),文档的理由是在 V1 架构下重算的开销更低
- 被抢占的请求回到等待队列,之后会重新开始它的 prefill 阶段(vLLM 文档 design/metrics 对 PREEMPTED 事件的描述)
- 抢占发生时日志里会打类似这样的警告(文档原文):
1 | |
代价有两部分:被抢占的请求要重新执行 prefill;重新执行的 prefill 还会占用同一步里其他请求可用的 token 预算
在指标上:
- vllm:num_preemptions:累计抢占次数(Counter,看增量)
- vllm:request_num_preemptions:每条请求被抢占的次数
- 抢占发生在 decode 阶段时,vLLM 把它算进 ITL、decode 时长和推理时长;发生在 prefill 阶段时算进 TTFT 和 prefill 时长(design/metrics)。所以 decode 阶段被抢占的请求,ITL 上会出现一次很大的间隔
文档给的应对办法:
- 调大 –gpu-memory-utilization,给 KV Cache 更多显存
- 调小 –max-num-seqs 或 –max-num-batched-tokens,一步里的请求少了,需要的 KV 就少
- 调大 TP:权重切到更多卡上,每张卡能腾出更多显存给 KV,但可能带来过多的同步开销
- 调大 PP:权重按层分到更多卡上,间接腾出 KV 的空间,但可能带来延迟损失
Prefix Caching
APC(Automatic Prefix Caching,自动前缀缓存):新请求的前缀和之前某条请求相同,就直接复用那部分 KV Cache,跳过这一段的 prefill(vLLM 文档 Automatic Prefix Caching)
文档举的两个典型场景:
- 长文档问答:同一份长文档(软件手册、年报)被反复用不同的问题来问,文档只需要 prefill 一次
- 多轮对话:每一轮都要带上之前的聊天历史,历史部分可以一直复用
文档也写明了限制:APC 只减少处理输入(prefill)的时间,不减少生成新 token(decode)的时间。所以输出很长、时间主要花在 decode 上的业务,开了 APC 也看不出效果
是否开启用 –enable-prefix-caching / –disable-prefix-caching 控制,部署里实际开没开,看 vllm:cache_config_info 的 enable_prefix_caching 标签
块哈希
vLLM 按块做哈希(vLLM 文档 design/prefix_caching):每个块的哈希值由三样东西算出来:上一个块的哈希、本块里的 token、额外信息(LoRA ID、多模态输入的哈希、cache_salt 等)
因为带上了上一个块的哈希,一个块能命中,说明从开头到这个块为止的所有 token 都相同,只要中间有一个 token 不一样,从那个块往后就全部不命中。所以:
- 系统提示词、长文档这类公共部分要放在 prompt 的最前面,变化的部分(用户问题、时间戳)放后面
- 复用是按块算的,一个块没填满就不会被缓存
多租户场景可以在请求里带 cache_salt,只有 salt 相同的请求之间才会共享缓存,防止有人通过响应时间推测出别人缓存过什么内容
驱逐(LRU)
这一点是理解”并发上去以后命中率掉”的前提,同样来自 design/prefix_caching:
- 所有 KV 块在启动时就分配好,组成一个块池,每个块记录有几条请求正在用它(ref_cnt)
- 一条请求结束后,没人用的块被放回空闲队列的尾部,块里的内容和哈希还留着,这时它还能被命中
- 新请求或在跑的请求要分配新块时,从空闲队列的头部取,如果取到的是一个还留着缓存的块,就把它驱逐(evict)掉,从此不能再命中
- 所以空闲队列头部是最久没被用过的块,驱逐顺序是 LRU(最近最少使用)
所以前缀缓存用的就是空闲块:在跑的请求越多、每条越长,占掉的块越多,空闲块越少,被驱逐得越快,高并发时命中率下降,靠这个机制就能解释
相关指标:
- vllm:prefix_cache_queries / vllm:prefix_cache_hits:查询了多少 token、命中了多少 token(都是 Counter),命中率 = Δhits ÷ Δqueries
- vllm:request_prefill_kv_computed_tokens:每条请求 prefill 时实际新算了多少 token,不含命中缓存的部分
- vllm:kv_block_lifetime_seconds:块从分配到被驱逐的时长
- vllm:kv_block_idle_before_evict_seconds:块被驱逐前空闲了多久
- vllm:kv_block_reuse_gap_seconds:同一个块两次被访问之间隔了多久
- 后三个是采样指标,由 –kv-cache-metrics-sample 控制
把 reuse_gap 和 idle_before_evict 放在一起看:同一个前缀两次被访问的间隔,比块被驱逐前能空闲的时间还长,这个前缀的块在下一次访问之前大多已经被驱逐
命中率与 TTFT
公众号第三篇的实验正好把这条链路量出来了(条件同上:Qwen2.5-0.5B,两个副本,并发 1,前缀约 5200 token),表格放在第四部分。单看一个实例内部的话,链路是这样的:
命中率高 → vllm:request_prefill_kv_computed_tokens 小 → prefill 时间短 → TTFT 低
反过来,看到 TTFT 涨,同时命中率下降、实际 prefill token 数上升,就该往”缓存被驱逐”或者”请求没有落到有缓存的实例上”这两个方向查
四、多实例部署
实例的运行时状态
公众号第一篇的说法:只要客户端每次都带上完整的对话上下文,请求发到哪个实例都不影响结果的正确性,从这个角度看推理服务是无状态的;但每个实例都有自己的请求队列、Prefix Cache 和 KV Cache,请求落到没有缓存的实例上,就要重新做一遍 prefill,表现为更高的 TTFT、更低的吞吐、更多的 GPU 开销
普通负载均衡只掌握实例是否 Ready,没有队列长度、KV Cache 余量和前缀缓存位置这些信息
缓存感知路由
常见的路由方式(公众号第三篇):
- 轮询或随机:不看请求内容,后续请求可能分到没有缓存的副本
- 会话粘滞:同一会话尽量回到原副本,但不同会话之间也可能共享系统提示词,而且原副本上的缓存也可能已经被驱逐了
- 前缀感知:按请求前缀的匹配情况,优先选可能已经有缓存的副本
llm-d Router 获取缓存位置有三种做法:
- Approximate:把请求前缀切成块,记录每次把哪些前缀块发给了哪个实例,后续相同前缀的请求按记录选实例。部署简单,不依赖引擎上报,但记的是路由历史,不是引擎的真实缓存清单,缓存被驱逐或实例重启后就对不上了
- Precise:订阅引擎上报的 KV Events(块保存、驱逐事件)维护索引,按真实 token 序列查询,要处理事件延迟、丢失和索引重建
- Burst Prefix:一批共享前缀的请求同时到达、所有实例都还没缓存时,在一个短时间窗口内把它们收集起来,尽量分到同一个实例。代价是窗口本身会增加等待时间
随机路由与前缀感知路由对比
实验条件:Kubernetes v1.36.1 单节点,Qwen2.5-0.5B-Instruct,vLLM v0.31.0(FP16、eager、APC 开启),llm-d-router v1.0.0,两个 vLLM 副本,每个副本 KV Cache 预算 256 MiB,前缀约 5200 token,输出上限 32 token,全局并发 1
做法:每组构造一段新前缀,先发”前缀 + 问题 1”,等响应完再发”同一前缀 + 问题 2”,看第二次请求的表现。每种策略 3 轮,每轮 10 组,下表是每种策略 30 条第二次请求的汇总:
| 指标 | 随机路由 | 前缀感知路由 |
|---|---|---|
| 回到首次处理该前缀的副本 | 17/30(56.7%) | 30/30(100%) |
| 前缀 token 命中率 | 56.56% | 99.60% |
| TTFT P50 | 155.1 ms | 127.3 ms |
| TTFT P95 | 2018.0 ms | 159.2 ms |
| 实际 Prefill token 均值 | 2261.5 | 21.0 |
看这张表要注意的地方:
- P50 只差约 28 ms,因为随机路由下也有一半多的请求回到了有缓存的副本,收益集中在 P95,也就是那些落到冷副本、要重做 5200 token prefill 的请求
- 命中率用的是 vllm:prefix_cache_hits ÷ vllm:prefix_cache_queries 的增量,实际 prefill token 用的是 vllm:request_prefill_kv_computed_tokens 的 _sum 增量,路由器的打分只是选点依据,缓存是否实际复用要看 vLLM 的指标
- “回到原副本”和”命中缓存”是两个指标:回到原副本不保证缓存还在,换了副本也可能命中公共模板
- 这是 0.5B 小模型、并发 1 的结果,用来看现象,不能当容量参考
文章最后提了一个实验没覆盖的情况:实例 A 有缓存但队列很长,实例 B 没缓存但能马上开始算,这时要比较复用缓存省下的 prefill 时间能不能抵消在 A 上多排的队,所以实际路由还要把负载算进来
PD 分离
拆分原因
前面的手段都是在同一个实例内减少 prefill 和 decode 的相互干扰,PD 分离则把两个阶段放到不同的实例(DistServe 博客、公众号第一篇):
- 互相干扰:长 prompt 的 prefill 会拉高同一实例里其他请求的 ITL
- 资源需求不同:prefill 偏算力,decode 偏显存带宽和容量,放在一起,同一套硬件要同时满足两种需求
- 并行策略被绑在一起:放在一起只能用同一套 TP/PP/DP,拆开以后可以各选各的
vLLM 文档(Disaggregated Prefilling,标注为实验性功能)给的两个理由:可以分别调 TTFT 和 ITL,互不影响;可以控制尾部 ITL。紧接着有一句加粗的提示:”Disaggregated prefill DOES NOT improve throughput.”(PD 分离不提升吞吐)
DistServe 博客的实验结果是用 goodput(满足 SLO 的请求数)来比的,在聊天、代码补全、摘要三类负载上和 vLLM 对比:聊天 goodput 高 2.0–3.41 倍,代码补全高 3.2 倍,摘要高 4.48 倍。这里比的是 goodput 不是吞吐,和 vLLM 文档那句”不提升吞吐”并不矛盾
请求流程
公众号第二篇在单卡上用 vLLM v0.27.1、NixlConnector 和 toy Proxy 跑通了最小的 PD Demo,流程是:
1、客户端发一次普通的 OpenAI 兼容请求给 Proxy
2、Proxy 把请求改成 max_tokens=1,带上 “do_remote_decode”: true 发给 Prefill 实例
3、Prefill 算完整个 prompt,生成 KV Cache,返回一个临时 token 和 kv_transfer_params(里面有 Prefill 侧的 engine ID、请求 ID、KV block ID、NIXL side channel 地址和端口)
4、Proxy 丢掉临时 token,把 kv_transfer_params 放进原始请求,恢复原来的 max_tokens,发给 Decode 实例
5、Decode 按元数据通过 NIXL 直接从 Prefill 拉 KV Cache,这份数据不经过 Proxy
6、Decode 接着往下生成,结果经 Proxy 返回客户端
所以有两条链路:控制请求走 Proxy 的 HTTP,KV 数据在 P、D 之间直接传
验证是否真的传了 KV,看 Decode 的日志:NIXL compatibility check passed 只说明两端配置兼容,KV Transfer metrics: Num successful transfers=1 才说明这次请求确实拉了 KV。文章里的那次运行,15 个 prompt token 的请求传了约 0.188 MB,平均耗时 1.51 ms(单容器、单卡、UCX 走 tcp,cuda_copy,cuda_ipc)
KV 传输与 Prefix Cache 命中:前缀缓存减少的是 prefill 的计算量;KV 传输是把本次 prefill 的结果交给 decode,即使完全没有命中缓存,P 也会完整计算后再传给 D(公众号第一篇)
PD 分离不会让多个 P 实例自动共享前缀缓存,P 实例之间仍然需要上一节的缓存感知路由(公众号第一篇)
KV Connector
P 和 D 之间怎么传 KV 由 KV Connector 决定,用 –kv-transfer-config 配置,vLLM 文档列了这几类:
- P2P 传输:NixlConnector,实例之间直接传,支持全异步收发
- 共享存储或缓存服务:ExampleConnector(本地共享目录,用来验证接口)、LMCacheConnectorV1、MooncakeConnector、FlexKVConnectorV1
- 卸载和多级缓存:OffloadingConnector 把 KV 放到 CPU 内存或文件系统,MultiConnector 按顺序组合多个 Connector
- MoRIIOConnector:只用于 ROCm
Connector 只管 KV 怎么生产、保存、消费,不管请求该发给哪个 P、哪个 D,后者是路由和编排的事(公众号第一篇)
代价与适用场景
代价(公众号第一篇):
- 权重要在 P、D 两组 GPU 上各加载一份
- 每条请求多了一次实例选择和阶段编排
- KV 要跨实例传,还要考虑 NIXL、RDMA、故障恢复、两组实例各自扩缩容
- 模型很小、prompt 很短、网络只能走普通 TCP 时,这些开销可能比隔离带来的收益还大
P 和 D 的数量比例(常写成 xPyD)看业务:文档问答输入长、输出短,可能需要更多 P;聊天、推理类输出长,可能需要更多 D,具体比例要用真实的输入长度、输出长度、并发和延迟目标压出来
文章的建议顺序是:先用 chunked prefill 这类同一实例内的优化,TTFT、ITL 或资源配比已经成为明确的瓶颈时,再上 PD 分离
五、按指标排查
压测或线上看到某个指标变差时,按”可能是哪个机制 → 用哪个指标区分”来查。这里只给判断方法,不给”多少算高”的阈值,阈值要用自己环境的基线来定
指标大多是 Counter 和 Histogram,都是累计值,要取压测前后的增量(公众号第三篇也是这么算的);Gauge(running、waiting、kv_cache_usage_perc)是瞬时值,要在压测进行中去抓
TTFT 升高
先把 TTFT 拆成 vllm:request_queue_time_seconds(排队)和 vllm:request_prefill_time_seconds(prefill),看是哪一段涨的
排队变长:
| 同时看到 | 可能的机制 |
|---|---|
| vllm:kv_cache_usage_perc 接近 1,vllm:num_preemptions 在涨 | KV 不够用,新请求排不进去,在跑的请求也被抢占后重排 |
| KV 使用率不高,vllm:num_requests_running 顶在 –max-num-seqs | 卡在一步最多带多少条请求上 |
| vllm:num_requests_waiting_by_reason 里 deferred 多 | 被临时条件挡住,文档列的是 LoRA 预算、KV 传输、阻塞状态,PD 分离时等 KV 传输就算在这里 |
| waiting_by_reason 里 capacity 多 | 在等调度容量 |
prefill 变长:
| 同时看到 | 可能的机制 |
|---|---|
| 前缀命中率(Δhits ÷ Δqueries)下降,vllm:request_prefill_kv_computed_tokens 上升 | 缓存被驱逐,或者多副本时请求没落到有缓存的实例上 |
| 命中率没变,vllm:num_requests_running 高 | decode 优先,留给 prefill 的 token 预算少了,长 prompt 被切成更多块、跨更多步 |
| vllm:request_prompt_tokens 分布变了 | 输入本身变长了 |
“并发上去后 TTFT 涨,是 prefill 排队还是 KV Cache 驱逐”这个问题,看的就是上面两张表:排队时间涨而 KV 使用率不高,是调度容量的问题;KV 使用率顶满、抢占在涨,是显存的问题;prefill 时间涨同时命中率下降,是缓存被驱逐了
TPOT / ITL 升高
| 现象 | 可能的机制 | 怎么区分 |
|---|---|---|
| TPOT 随并发缓慢上升 | 一步里请求多了,每步的工作量变大,这是合批的正常代价 | vllm:iteration_tokens_total 和 running 一起涨 |
| TPOT 随上下文变长上升 | 每步要读的历史 KV 变多 | 同样的并发,换长上下文的数据集压一次对比 |
| TPOT 平均值变化不大,ITL 的 P99 出现尖刺 | prefill 插进了 decode 的步里 | 和 –max-num-batched-tokens 的取值、长 prompt 请求的到达时间对一下 |
| 个别请求的 ITL 有特别长的间隔 | 被抢占后重算 | vllm:request_num_preemptions 大于 0 的请求 |
吞吐瓶颈
先看哪个先满:
- vllm:kv_cache_usage_perc 先到 1,抢占开始出现:KV 容量是上限,思路是给 KV 腾显存(–gpu-memory-utilization、TP、–kv-cache-dtype 用更低精度的 KV)或者减少每条请求占的 KV
- running 顶在 –max-num-seqs,KV 还有空:上限是参数设的,可以往上调,但要盯着 TPOT 和 ITL 是否还满足 SLO
- 两个都没满,吞吐也不涨:瓶颈可能在 CPU 侧(分词、模板渲染),vLLM 文档提醒过 engine core 进程跑的是忙循环,对 CPU 不足特别敏感
吞吐高不等于服务好,最终要看的是满足 SLO 的那部分(goodput)
PagedAttention 的压测表现
单条请求的 TTFT、TPOT 看不出它,它体现在容量上:
- 启动日志的 GPU KV cache size、Maximum concurrency
- 并发往上加的时候,kv_cache_usage_perc 什么时候到顶、到顶之后抢占开始出现,那个并发数就是这套配置在这个数据集上的实际上限
客户端与服务端口径差异
- 客户端测的 TTFT 比 vllm:time_to_first_token_seconds 多出网络和 HTTP 的时间,服务端的 TTFT 从分词开始算,比排队加 prefill 多出输入处理的时间
- 公众号第三篇对 TTFT 的定义是”收到第一个非空 delta.content”,流式响应开头的 role 或空事件不算首字,自己写压测脚本时这一点要对齐
- 流式响应里一个 SSE 数据块不一定只有一个 token,统计输出 token 数时用 usage 里的 completion_tokens,请求里带上 “stream_options”: {“include_usage”: true} 才会返回(公众号第三篇的请求就是这么带的)
附录:参数速查
| 参数 | 管什么 | 相关章节 |
|---|---|---|
| –tensor-parallel-size / -tp | TP 并行度 | 一 |
| –pipeline-parallel-size / -pp | PP 并行度 | 一 |
| –data-parallel-size / -dp | DP 副本数 | 一 |
| –enable-expert-parallel | MoE 层用 EP 代替 TP | 一 |
| –max-model-len | 单条请求的最大上下文(输入加输出) | 二 |
| –gpu-memory-utilization | 当前实例能用的显存比例,文档默认 0.92,决定 KV Cache 预算 | 二、三 |
| –kv-cache-memory-bytes | 直接指定每张卡的 KV Cache 字节数 | 二 |
| –kv-cache-dtype | KV Cache 的精度 | 五 |
| –max-num-seqs | 一步最多带多少条请求,DP 时按每个 rank 算 | 三 |
| –max-num-batched-tokens | 一步最多处理多少 token,TTFT 和 ITL 之间的旋钮 | 三 |
| –enable-chunked-prefill | 长 prefill 切块 | 三 |
| –block-size | KV 块大小(token 数) | 三 |
| –enable-prefix-caching | 前缀缓存开关 | 三 |
| –kv-transfer-config | PD 分离的 KV Connector 配置 | 四 |
附录:参考
- 为什么需要 PD 分离:Prefill 与 Decode 的瓶颈、拆分与代价
- vLLM PD 分离实战:从请求流程理解 Prefill、Decode 与 KV Cache 传输
- vLLM 多副本如何复用 KV Cache?llm-d 缓存感知路由实战
- vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention
- Throughput is Not All You Need: Maximizing Goodput in LLM Serving using Prefill-Decode Disaggregation
- DistServe 论文
- vLLM 文档:Optimization and Tuning、Parallelism and Scaling、Data Parallel Deployment、Automatic Prefix Caching、Prefix Caching 设计、Disaggregated Prefilling、Metrics 设计、Production Metrics、Engine Arguments