TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

LLM 推理基础与 vLLM 内部机制:KV 缓存、PagedAttention、调度器

状态:草稿(2026-10-05),已反映部分实测数据。 本文基于官方文档核实的内容撰写,笔者在实验环境中亲自测量的数据收录在“实测”一节(第 9 节),其余内容均以文档为准。 请先阅读文章末尾的“已核实版本与日期”和“未核实的内容”。 编写规则:以实测数据为基础,未能测量的内容标注为“未实测”。

目录

LLM 服务的吞吐量不取决于运算速度,而取决于两点:GPU 内存能容纳多少 KV 缓存,以及生成一个 token 时读取的权重由多少个请求共同分担。本文通过算术强度解释其中的原因,并根据 vLLM v0.30.0 的源码和官方文档,梳理了 PagedAttention 的块管理、前缀缓存、分块预填充、抢占机制、运行参数以及指标。笔者没有运营大规模生产服务的经验,仅有在消费级 GPU 上进行测试的经历。

读完本文后,你将在面试中能够解释以下内容:

  1. 用算术强度说明为什么 prefill 是计算受限、decode 是内存带宽受限,从而解释为什么批处理和 KV 容量决定了吞吐量。
  2. 通过公式计算 KV 缓存大小,并解释 GQA、块(PagedAttention)、前缀缓存和抢占机制是如何消耗这些容量的。
  3. 将六个运行参数与 /metrics 关联起来,阐述诊断瓶颈的步骤。

1. 两个阶段与算术强度

推理分为一次性处理整个提示词(Prompt)以填充 KV 缓存的 prefill 阶段,以及逐个生成 token 的 decode 阶段。算术强度是指从内存中读取 1 字节时执行的运算次数(FLOP/byte)。当参数量为 P,权重占 2 字节时,一次性处理 n 个 token 的线性层的计算量约为 2·P·n FLOP,而权重仅需读取一次,因此读取量约为 2·P 字节。所以算术强度约为 n FLOP/byte(此为去除了注意力机制和 KV 读取的近似值)。

由于 prefill 的 n 是提示词长度,因此它是计算受限的。而在 decode 阶段,单个请求的 n=1,因此同时处理的请求数 B 即为 n。若 B 小于 GPU 的运算性能除以内存带宽所得的值(转折点,ridge point),则属于内存带宽受限。作为说明示例,使用 NVIDIA 在 H100 SXM 上标注的 3.35 TB/s 以及 FP16 张量核心 1,979 TFLOPS(包含稀疏性的标注),并假设密集算力为标注的一半,则转折点约为 295 FLOP/byte。除非并发请求达到数百个,否则 decode 时间主要由读取权重的时间决定。

根据笔者的实验环境记录,在内存带宽为 896 GB/s(NVIDIA 标注)的笔记本 GPU 上,Qwen3.8-27B NVFP4 在加载大小为 16.2GB 时,单请求为 39 tok/s,4 个并发请求合计为 138 tok/s。39 × 16.2GB 约为 632 GB/s,约为标称带宽的 70%;合计约为 3.5 倍,这可以自然地理解为四个请求分摊了每个步骤中读取的权重。这只是将加载大小视为每步读取量的粗略估计,由于缺乏测量时的提示词和输出长度记录,因此无法验证。

2. KV 缓存与大小公式

由于注意力机制需要重新查看前面所有 token 的 key 和 value,因此会将其保存在每一层中。单个 token 的大小计算如下:

토큰당 바이트 = 2(K와 V) × 층 수 L × KV 헤드 수 H_kv × head 차원 d × 원소 바이트 b

这些数值在 Hugging Face 的 config.json 中进行了核实(num_hidden_layers, num_key_value_heads)。由于全部为 BF16,因此 b=2;head 维度 128 是因为配置中没有 head_dim 因而通过 hidden_size ÷ num_attention_heads 计算得出的值。

模型 L H_kv 每个 token 的大小 1 个 32,768 token 的请求
Qwen2.5-1.5B-Instruct 28 2 28 KiB 0.875 GiB
Qwen2.5-7B-Instruct 28 4 56 KiB 1.75 GiB
Qwen2.5-72B-Instruct 80 8 320 KiB 10 GiB
(说明假设)若 7B 使用 MHA 28 28 392 KiB 12.25 GiB

MQA(Multi-Query Attention,Shazeer 2019)是指所有查询头共享单个 K 和 V(H_kv=1),而 GQA(Grouped-Query Attention,Ainslie 等人 2023)是一种折中方案,将查询头分组,每组共享一个。GQA 论文主张,如果以原本预训练计算量的 5% 额外训练 MHA 检查点,就能获得接近 MQA 的速度和接近 MHA 的质量。减少 H_kv 可以增加可容纳的请求数量,并减少 decode 时读取的字节数;如果将 KV 保存为 FP8(--kv-cache-dtype fp8),则 b=1,大小再次减半。

该公式是通过笔者的实验日志进行匹配验证的。Qwen2.5-1.5B 每个 token 为 28,672 字节,而 LMCache 日志中的“4,864 tokens, size 0.1299 gb”与 4,864 × 28,672 ÷ 2^30 = 0.12988 相符(当把 gb 视为 GiB 时)。按同样的公式,GPU 上 21,520 个 token 约为 0.575 GiB,但由于未保存服务器的可用 KV 内存日志行,因此未能进行直接对比。

3. 从静态批处理到连续批处理

静态批处理将一批请求同时开始并同时结束,因此较短请求结束后的空位会一直闲置,直到最长的请求结束。Orca(OSDI 2022)将调度单位从“请求”更改为“迭代”(生成一个 token),在每次迭代中移除已完成的请求并加入新请求。这就是连续批处理(continuous batching),该论文声称在 GPT-3 175B 上,相比于 FasterTransformer,在相同延迟下吞吐量提升了 36.9 倍(论文条件下的数值)。vLLM V1 调度器源码的注释将其进行了更广泛的泛化:既没有 decoding 阶段也没有 prefill 阶段,只是为每个请求分配 token,使得 num_computed_tokens 能够赶上 num_tokens_with_spec ,并指出分块预填充、前缀缓存和推测解码都可以用这种表达来处理。

4. PagedAttention 与块管理

传统的服务器会为每个请求预先分配最大长度的连续内存,根据论文图 2(6.2 节实验中的平均内存浪费比例),在作者亲自实现的 Orca 三种变体(Max、Pow2、Oracle)中,KV 内存中实际用于 token 状态的比例为 20.4%~38.2%,而 vLLM 读起来约为 96%。由于 Orca 没有公开实现,这些变体是作者自己的实现,该比例不能泛化为所有传统服务器的值。同一篇论文中的吞吐量提升(相比 Orca Oracle 提升 1.7~2.7 倍,相比 Orca Max 提升 2.7~8 倍)也是基于此比较对象的标准。PagedAttention(SOSP 2023)借鉴操作系统的虚拟内存,将 KV 划分为固定大小的块(默认 16 个 token),并在每个请求中维护一个从逻辑块到物理块的映射表(block table),在需要时按需分配物理块。浪费减少到了每个请求最后一个块的空白部分,并且相同的前缀可以共享同一个物理块(引用计数和写时复制)。

요청 A 논리 블록        block table(A)   물리 블록 풀(GPU)
B0 [토큰  0-15]    ->   물리 7     ---+
B1 [토큰 16-31]    ->   물리 1        |  물리 7 : ref_cnt=2 (A, B 가 공유)
B2 [토큰 32-37]    ->   물리 3        |  물리 3 : 6/16 만 참  <- 낭비는 여기뿐
요청 B: B0 -> 물리 7 -----------------+  (같은 접두사)

论文中的性能表现作为主张区分。论文指出,在 A100 上使用 ShareGPT 和 Alpaca 轨迹测试 OPT 13B/66B/175B 和 LLaMA 13B 的结果显示,在相同延迟下,其吞吐量是 FasterTransformer 和 Orca 的 2~4 倍,且由于需要经过块表,注意力机制内核比 FasterTransformer 慢 20%~26%。vLLM 设计文档的 paged_attention 部分提到它本身是基于原论文的历史文档,并不描述当前的代码,因此概念依然有效,但内核实现已经发生了变化。块池(源码 v0.30.0)使用双向链表队列管理空闲块,并将 0 号块保留为空块(null block)。若将块大小视为默认值 16,实验日志中的 21,520 个 token 即为 1,345 个块,其中可用的是 1,344 个块。

5. 前缀缓存

块的哈希由父块的哈希、当前块的 token 以及附加键(LoRA ID、多模态输入哈希、cache_salt)组成,从而构成一条链。如果前缀哪怕只有一个 token 不同,后面的块也会全部不同,因此只有前缀能够命中。只有装满的块才会被缓存,因此命中必然是 16 token 的倍数;即便全部命中,为了获取 logits,最后一个 token 也会被重新计算(源码注释)。请求结束后,块会被归还到空闲队列,但会保留哈希,并从队列前端开始按 LRU 机制重复使用,同时从尾部开始归还以便尾部块优先被释放(源码注释)。越靠前的块越有可能被重复使用——这个原因并非由源码说明,而是笔者自己的解释。

在 v0.30.0 中, enable_prefix_caching 如果模型支持,默认会开启。根据 V1 博客,V0 在命中率较低时 CPU 负担较大,因此默认关闭;而 V1 声称即使在 0% 命中率下性能下降也微乎其微。前缀缓存只能缩短 prefill 时间,无法缩短 decode 时间(官方文档)。在多租户环境中, cache_salt 用于划分信任组。

6. 分块预填充、调度器与抢占

调度器在每个步骤通过 token 预算(max_num_batched_tokens)。源码顺序如下:首先为运行中的请求(decode 和正在进行的 prefill 片段)分配 token,用剩余的预算从队列头部接收新请求,并将长于预算的 prompt 截断只放入一部分(chunked prefill,在官方文档可能的所有情况下默认开启)。其目的是防止一个长的 prefill 阻塞其他请求的 decode 多个步骤,从而稳定 token 之间的延迟。Sarathi-Serve 论文(2024)主张,基于这个想法,在当时的 vLLM 相对延迟 SLO 下,单张 Mistral-7B/A100 的服务吞吐量是其 2.6 倍。

flowchart TD
  A[스텝 시작: 토큰 예산 = max_num_batched_tokens] --> B[실행 중 요청에 토큰 배정]
  B --> C{새 블록을 받을 수 있는가}
  C -- 예 --> D[남은 예산으로 대기열 앞에서 새 요청 받기]
  C -- 아니오 --> E[가장 늦게 들어온 실행 요청 선점: 블록 반납, 대기열 맨 앞으로]
  E --> C
  E -.-> F[선점이 있던 스텝은 새 요청을 받지 않음]
  D --> G[긴 프롬프트는 예산만큼 잘라 넣고 GPU 한 스텝 실행]

如果 KV 块不足,就会发生抢占。源码(FCFS 策略)挑选执行列表最后面的、也就是最晚进入的请求,归还其所有块,并把 num_computed_tokens 归零后放回队列头部。这就是重新计算(recompute)。官方文档也解释说,V1 默认采用 recompute 而不是 swap,并且开销更低。由于抢占重复做了同一件事,因此会同时损害 TTFT 和吞吐量。文档建议的缓解措施是提高 gpu_memory_utilization ,减少 max_num_seqs 或 max_num_batched_tokens ,并增加 tensor_parallel_size 。

7. 六个运维参数

默认值基于 v0.30.0 源码,可能因版本而异。 --performance-mode throughput 如果给出

选项 更改项 默认值 方向
--gpu-memory-utilization 此实例将使用的 GPU 内存比例。扣除权重和激活值之后的部分即为 KV 池 0.92 调大会增加 KV,存在 OOM 风险。在作者的 WSL2 环境中,WSL 先占用了 1.3GB,导致 0.95 启动失败
--max-model-len 单个请求的最大长度 从模型配置中推导 调小可以用相同的 KV 接收更多请求。在作者的实验中,将 262K 缩减到了 16K
--max-num-seqs 并发运行请求数的上限 256(70GiB 及以上的非 A100 为 1024) 大则吞吐量增加,KV 压力和 token 间延迟增加
--max-num-batched-tokens 每步的 token 预算 2048(70GiB 及以上的非 A100 为 8192,160GiB 及以上为 16384) 大则 TTFT 减少,同时运行的 decode 延迟增加
--tensor-parallel-size 将层内的权重分割到多张 GPU 上 1 KV 空间增加,权重读取时间减少,产生通信成本
--enable-prefix-caching 前缀缓存 开启(如果模型支持) 输出较长的工作负载中收益较小

作者的实验部署设置是 --gpu-memory-utilization=0.93 --max-model-len=16384 --max-num-seqs=4 --max-num-batched-tokens=2048 --kv-cache-dtype=fp8 ,根据设置中记录的实测数据,KV 缓存为 2.9GB(约 57,000 个 token)。这个大小大约能容纳 3.5 个 16,384 token 的请求,因此如果同时有 4 个请求都很长,可能会发生抢占,这是算术推算而非观察结果。

8. 指标与 /metrics

TTFT 是发送请求后收到第一个输出的时间,TPOT 是(总延迟 - TTFT)÷(输出 token 数 - 1),ITL 是流式输出的 token 之间的间隔。在推测解码中,一个输出包含多个 token,导致 ITL 和 TPOT 不同(vLLM 基准测试文档)。goodput 是指仅统计满足 TTFT、TPOT 等 SLO 的请求的吞吐量(DistServe 论文,OSDI 2024)。我们看 p50、p95、p99,而不是平均值。

这是在 v0.30.0 源码中确认的指标(Prometheus 计数器附加了 _total )。

如果等待增加、 kv_cache_usage_perc 接近 1 且抢占增加,则说明 KV 不足。如果等待增加但使用率低,则是 max_num_seqs 或 token 预算成为瓶颈。由于直方图按桶边界划分(TTFT 为 0.5、0.75、1.0 秒等), histogram_quantile 的 p99 粗略程度相当于桶的宽度。

9. 实测:循环访问中 LRU 失效了

这是作者测得的实测数据。单张 8GB 级笔记本 GPU,镜像 lmcache/vllm-openai:latest(vLLM 0.29.0,LMCache 0.5.5),模型为 Qwen2.5-1.5B-Instruct, --gpu-memory-utilization 0.60 --max-model-len 8192 --max-num-seqs 4 --enable-prefix-caching 。服务器日志中的 GPU KV 缓存为 21,520 个 token。用随机数单词生成了 10 个彼此前缀不重叠的文档(每个约 5,011 个 token,合计约 50,000 个 token),按顺序、按相同顺序再次、以及按相反顺序进行读取,请求是一个接一个发送的。

设置 轮次 TTFT p50 (ms) 前缀缓存命中 (token)
baseline 1 首次 575.5 0
baseline 2 按相同顺序再次 576.2 0
baseline 3 倒序 505.4 21,488
LMCache(CPU 1.5GB) 2 按相同顺序再次 48.6 GPU 0,外部 48,640

由于文档总计是 GPU 容量的 2.3 倍,如果在 LRU 中按相同顺序再次读取,每次需要时该文档刚好刚被驱逐,因此命中率为 0。倒序读取的第 3 轮命中 21,488 个 token,即可用 1,344 个块中的 1,343 个块(×16)。我们通过第 10 节的块 LRU 模型验证了这一解释,该模型也得出了第 1 轮 0、第 2 轮 0、第 3 轮 21,488 的结果。模型与实测相符只能证明解释是自洽的,不能作为最终证明。

当 LMCache 将 KV 卸载到 CPU 内存时,第 2 轮的 TTFT 为 48.6ms。实验记录显示,根据日志时间戳,从 CPU 加载每个文档 4,864 个 token 大约花费了 8~9ms,而重新计算相同的 token 大约花费了 560ms。首次读取时,由于存储开销,TTFT p50 从 575.5 增加了约 4% 至 597.8ms。

在此坦诚局限性。 由于并发量为 1,因此未测量请求重叠的情况。由于模型是 1.5B,GPU 只有一张(8GB),且文档是合成的,实验记录中预测大模型收益更大的推测并未在此次测量中得到体现。这是一个单次测量值,没有标准差,且每轮只有 10 个请求,因此 p95 实际上就是最大值。收益是由 CPU 卸载而非 PagedAttention 带来的,且属于前缀长距离重复的最优场景。实验结束后,虽然在 vLLM 文档中确认了其自身的 OffloadingConnector ,但并未在相同条件下重新测量。

10. 动手尝试

第 1、2 项为本地计算或只读。

# 1) 클러스터 없이: KV 크기 계산
curl -sL https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/resolve/main/config.json | python3 -c '
import json,sys; c=json.load(sys.stdin)
d=c["hidden_size"]//c["num_attention_heads"]
b=2*c["num_hidden_layers"]*c["num_key_value_heads"]*d*2
print(b, "바이트/토큰", b*32768/2**30, "GiB/32K 토큰")'

# 2) 읽기 전용 확인(vLLM 서버가 떠 있는 클러스터에서. 서버를 새로 켜는 일은 변경이라 하지 않는다)
kubectl logs <vLLM 파드 이름> | grep -E "GPU KV cache size|Maximum concurrency"
kubectl exec <vLLM 파드 이름> -- python3 -c "import urllib.request as u;print(u.urlopen('http://127.0.0.1:8000/metrics').read().decode())" | grep -E "^vllm:(num_requests|kv_cache_usage|num_preemptions|prefix_cache)"

第 3 项是块 LRU 模型。这不是实测,而是我简化后的模型(块大小 16 个 token,优先驱逐尾部,部分块无哈希)。

from collections import OrderedDict
BS, TOTAL, DOCS, N = 16, 1344, 10, 5033   # 쓸 수 있는 블록 = 전체 1345 - null block
free, fresh = OrderedDict(), TOTAL        # free: 해시가 붙은 빈 블록, 앞쪽이 먼저 방출됨

def serve(d):
    global fresh
    hs = [(d, i) for i in range(N // BS)]            # 가득 찬 블록의 해시
    hit = 0
    while hit < len(hs) and hs[hit] in free and (hit + 1) * BS <= N - 1:
        hit += 1
    for h in hs[:hit]:
        del free[h]                                  # 적중한 블록은 사용 중
    for _ in range(-(-N // BS) - hit):               # 모자란 블록을 새로 받는다
        if fresh: fresh -= 1
        else: free.popitem(last=False)               # 가장 오래된 블록을 내보냄
    for h in reversed(hs):
        free[h] = 1                                  # 끝나면 꼬리부터 큐에 넣는다
    fresh += N % BS > 0                              # 부분 블록은 바로 재사용
    return hit * BS

for name, order in (("1차", range(DOCS)), ("2차", range(DOCS)), ("3차 거꾸로", reversed(range(DOCS)))):
    print(name, sum(serve(d) for d in order))

第 4 项是临时 Pod 复现。最初的实验是在使用 1 张 GPU 和 6Gi 内存的临时 Pod 中运行的。风险在于与同一节点上其他工作负载的内存竞争以及 GPU 占用。即使保留 Pod,也要让其在 1 小时后自行终止,对象删除则由人工直接执行。本文未重新运行此复现。

11. 面试可能问到的问题

  1. 为什么 decode 受内存带宽限制? 当一个请求生成一个 token 时,它会读取所有的权重,但每个权重的乘法运算大约只有一次,因此算术强度与并发请求数 B 类似。它小于 ridge point(H100 标准下为数百),导致计算单元处于空闲状态,而将请求聚合以分摊权重读取的批处理则能提高吞吐量。
  2. 请计算 KV 缓存大小并说明 GQA 的效果。 每个 token 的大小为 2 × 层数 × KV 头数 × head 维度 × 字节。Qwen2.5-7B 是 28 × 4 × 128,即 56KiB;如果它是拥有全部 28 个查询头且都具备 KV 的 MHA,则为 7 倍的 392KiB,因此 GQA 相应地增加了可同时容纳的请求数。
  3. PagedAttention 解决了什么问题,块大小是如何确定的? 它用块级动态分配消除了以往按最大长度预分配连续内存所带来的碎片化和浪费。块大则内核效率高,但最后一个块的浪费大;块小则反之。论文通过权衡选择了默认的 16。
  4. 如果 KV 缓存满了,vLLM 会怎么做,如何察觉? 它会抢占最后进入的运行请求以收回块,并在稍后从头重新计算。 kv_cache_usage_perc 接近 1, num_preemptions_total 增加且队列变长,应对措施包括提高使用率、缩小 max_num_seqs 、使用 KV FP8 以及增设张量并行。
  5. 开启了前缀缓存但没有命中,应该查看什么? prefix_cache_hits_total ÷ queries_total 通过查看实际命中率,并检查 prompt 前部是否存在时间戳或请求 ID 等可变 token。由于采用哈希链,前面不同,后面就会全部错开。也可能是重用间隔期间读取的总量超过了 KV 容量导致 LRU 失效,我的实验就是这种情况。

12. 常见的误解

服务质量由 p99 决定,平均值会掩盖因抢占而被拉长的尾部。

参考资料

已确认的版本与日期、未确认的事项

登录后即可点赞

评论

还没有评论。

登录后即可发表评论