大模型推理优化学习笔记

本文内容由Authropic Claude Opus 5-5模型生成。信息仅供参考,不构成专业建议。

尽管AI尽力确保信息的准确性,但并不能保证其完全无误或适用于所有场景,请结合实际情况审慎使用。

前言

个人学习记录文档,如有谬误请帮忙甄别和指正!

上一篇《从零开始的大模型(Transformer)学习笔记》是从 Transformer 原理、显存构成、GPU 硬件一路往上讲的,博主隔了一段时间回头复习推理优化,发现连 prefill 是做什么的都想不起来了,翻回去一看,那篇笔记里本来就没有 prefill、decode 这一层,两篇的目的不一样,硬插进去结构也别扭,所以单开一篇

这篇换成推理服务的视角:一条请求进了推理引擎之后怎么排队、怎么计算、占多少显存、什么时候被抢占,以及这些事分别体现在哪个指标上、能调哪个参数

本文的数字都来自下面的公开资料,每处都写明了出处和当时的实验条件,没有出处的地方只讲机制,不给数字:

阅读顺序:第一部分是基础概念,第二部分是单条请求的执行过程,第三部分是单卡多请求的调度与显存管理,第四部分是多实例部署,第五部分是按指标排查,复习时可以直接翻第五部分,看不懂再往前找

一、基础概念

推理和训练的区别

推理(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
2
3
4
5
请求到达 ─ 分词 ─ 排队 ─┬─ prefill ─┬─ decode 第1步 ─ decode 第2步 ─ … ─ 结束
│ │
被调度 第一个 token 出来
|←──────────── TTFT ────────────────→|←────── (n−1) × TPOT ──────→|
|←──────────────────────────── 端到端时延 ─────────────────────────→|

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
2
INFO 07-23 13:56:04 [kv_cache_utils.py:775] GPU KV cache size: 643,232 tokens
INFO 07-23 13:56:04 [kv_cache_utils.py:779] Maximum concurrency for 40,960 tokens per request: 15.70x
  • 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
WARNING 05-09 00:49:33 scheduler.py:1057 Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode because there is not enough KV cache space. This can affect the end-to-end performance. Increase gpu_memory_utilization or tensor_parallel_size to provide more KV cache memory. total_cumulative_preemption_cnt=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 配置 四

附录:参考


大模型推理优化学习笔记
https://www.fishingrodd.cn/2026/10/30/大模型推理优化学习笔记/
作者
FishingRod
发布于
2026年10月30日
许可协议