2.2 PagedAttention 与 KV Cache 分页管理


文档摘要

2.2 PagedAttention 与 KV Cache 分页管理 如果说连续批处理解决了"GPU 别空转",那么 PagedAttention 解决的是另一个更要命的问题:显存被 KV Cache 撑爆,导致并发上不去。这一节是整本教程的硬核,请慢读——因为它直接决定了你能同时服务多少个用户,也决定了你每张卡能"塞"下多大的并发。我见过太多团队因为不懂这一节,把 80GB 的卡只跑出 20 个并发,而懂的人同样一张卡能跑到 200 个。 2.2.1 为什么 KV Cache 是显存杀手 Transformer 在生成每个 token 时,需要用到之前所有 token 的"键值"(Key/Value)向量,这就是 KV Cache。

2.2 PagedAttention 与 KV Cache 分页管理

如果说连续批处理解决了"GPU 别空转",那么 PagedAttention 解决的是另一个更要命的问题:显存被 KV Cache 撑爆,导致并发上不去。这一节是整本教程的硬核,请慢读——因为它直接决定了你能同时服务多少个用户,也决定了你每张卡能"塞"下多大的并发。我见过太多团队因为不懂这一节,把 80GB 的卡只跑出 20 个并发,而懂的人同样一张卡能跑到 200 个。

2.2.1 为什么 KV Cache 是显存杀手

Transformer 在生成每个 token 时,需要用到之前所有 token 的"键值"(Key/Value)向量,这就是 KV Cache。它有两个特点让人头疼:

  • 随序列长度线性增长:一个请求上下文越长,占的 KV 越多,而且没有上限(直到 max_model_len)。
  • 随并发数线性增长:100 个并发用户,就有 100 份正在生长的 KV Cache,同时占着显存。

以 DeepSeek V4 这类大模型为例,单层的 KV 维度(隐藏维 × 层数 × 2(K,V) × 精度字节数)乘上上下文长度,再乘并发数,很快就能吃掉几十 GB 显存。传统做法是为每个请求预先分配"最大可能长度"的连续显存块——这带来两个灾难:内部碎片(实际只用了一半长度,另一半空着)和外部碎片(零散的空闲块拼不出一个连续大块)。在长上下文 + 高并发下,这两个碎片能让你的有效显存利用率掉到 40% 以下,等于白白浪费一大半卡。

```mermaid graph LR subgraph 传统连续分配 A1[请求A 预留2048] --- A2[用了1000 浪费1048] B1[请求B 预留2048] --- B2[用了500 浪费1548] end subgraph PagedAttention 分页 P1[块0] --> P2[块1] --> P3[块2] Q1[块0] --> Q2[块1] end ```

2.2.2 PagedAttention 的核心思想:像操作系统管内存一样管 KV

vLLM 借用了操作系统的虚拟内存分页思路,这是它工程上最巧妙的一笔:

  • 把 KV Cache 切成固定大小的块(block / page),例如每块存 16 个 token 的 KV。块大小是超参,但 16 是常见默认值。
  • 每个序列不再需要一整块连续显存,而是由一张**块表(block table)**记录"逻辑块 → 物理块"的映射。
  • 序列增长时,按需分配新块;序列结束或抢占时,释放块回空闲池。

这样,显存利用率从"预留最大值"变成"用多少占多少",碎片几乎为零。实测中 PagedAttention 能把 KV Cache 显存浪费降到很低(官方早期数据接近把有效利用率翻倍以上),意味着同样的显卡能多服务近乎一倍的并发。这不是理论上的数字,而是我压测时反复验证过的:同一张卡、同一个模型,开 PagedAttention 后并发上限从约 90 提到约 180,几乎翻倍。

```mermaid flowchart LR Seq[序列逻辑块 0,1,2] --> BT[块表 Block Table] BT --> Phys[物理块 7] BT --> Phys2[物理块 3] BT --> Phys3[物理块 9] Free[空闲块池] -.分配.-> Phys3 ```

2.2.3 块表怎么工作:一个具体例子

假设块大小是 16 token。一个长度为 35 的序列,逻辑上分 3 块:块0(token 0-15)、块1(16-31)、块2(32-34)。块表把它们映射到物理块 7、3、9(物理块可以是显存里任意不连续的地址)。当序列继续生成到第 36 个 token,块2 满,调度器从空闲池拿一个新物理块(如 12)挂到块表末尾。整个过程不需要搬迁已有数据,也没有"预留到 2048"的浪费。

关键点:因为映射是逻辑到物理的间接层,多个序列还能共享同一个物理块——这就是前缀缓存(prefix caching)的基础。如果 100 个用户都用了相同的系统提示词(system prompt),那 100 个序列的逻辑前缀可以指向同一组物理块,KV 只存一份。对带固定 system prompt 的 DeepSeek V4 对话服务,这是巨大的显存和 prefill 双重节省。我强烈建议所有对话类服务都开启前缀缓存,它几乎是免费的午餐:不改任何业务逻辑,只因"大家开头都一样"就省下大量重复计算和显存。

```mermaid graph TD subgraph 共享前缀 U1[用户1 序列] --> S[物理块组 P 系统提示] U2[用户2 序列] --> S U3[用户3 序列] --> S end U1 --> K1[各自私有块] U2 --> K2[各自私有块] U3 --> K3[各自私有块] ```

2.2.4 对 MoE 大模型为什么特别关键

DeepSeek V4 的 KV Cache 体量和上下文长度都很大,PagedAttention 的价值被放大,这也是为什么部署 MoE 必用 vLLM 这类分页方案:

  • 长上下文友好:用户动辄几万 token 的上下文,连续分配根本排不下;分页让"按需生长"成为可能,10 万 token 的上下文也能用零散块拼出来。
  • 高并发友好:MoE 本身已经吃大量权重显存,留给 KV 的空间紧张,分页把这块空间利用率拉满,等于变相扩容。在权重占显存 60% 的情况下,KV 池每多 10% 利用率,可支撑并发就多一大截。
  • 前缀缓存放大收益:对话类服务共用 system prompt,共享块直接降低首 token 的 prefill 成本,和 2.1 讲的延迟优化形成合力。

但也要注意代价:块表是间接寻址,注意力计算时 GPU 要按块拼接读取,带来一点额外的访存与 kernel 复杂度。vLLM 的 PagedAttention kernel 专门针对这种"非连续块"做了融合优化,所以这块开销在主流硬件上可控,通常值得为换来翻倍的并发而付出。不要因为"听说有开销"就回避它——实测开销远小于收益。

2.2.5 和部署参数怎么对应

你调参时这几个值直接作用于分页管理,务必理解每个的含义:

  • gpu_memory_utilization:vLLM 用它估算"把多少比例的显存留给 KV 池"。设太低,KV 池小,并发上不去;设太高(如 0.95+),可能挤占模型权重或引发 OOM。经验值常从 0.85~0.90 起步,结合实测调。
  • block_size:每块 token 数,常见 16。一般不必改,除非你的 kernel 或序列长度分布特殊(极短请求可试更大块减少块表开销)。
  • max_model_len:限制单请求最大上下文,直接决定 KV 池上限需求。设置时应匹配 DeepSeek V4 的真实上下文能力并留余量,设得过大等于白白预留显存。

一个常见踩坑:有人把 gpu_memory_utilization 设到 0.95,结果偶尔 OOM 崩溃——因为权重占用会随 batch 浮动。稳妥做法是先 0.85,压测稳定后再小幅上调,且始终保证 max_model_len × 并发估算 不超物理显存。我一般留 10% 的安全垫,宁可并发少一点也不让服务半夜崩。

```mermaid flowchart TD GM[gpu_memory_utilization] --> Pool[KV 池大小] ML[max_model_len] --> Need[单请求 KV 需求] Need --> Pool Pool --> Conc[可支撑并发数] GM -.过高.-> OOM[OOM 风险] ```

2.2.6 自检:你真的懂了吗

读完本节,你应该能回答三问:

  1. 为什么"预留最大长度"会浪费显存?(内部碎片 + 外部碎片,长尾请求吃掉大量预留空间)
  2. 块表解决了什么?(逻辑→物理间接映射,按需分配、零碎片、可共享,支撑前缀缓存)
  3. MoE 大模型为什么更依赖它?(权重占显存多 + 上下文长,分页把 KV 空间利用率拉满,变相扩容)

2.2.7 一个能落地的显存估算:你的卡到底能扛多少并发

光说"利用率翻倍"太虚,给你一个可手算的估算框架。假设单卡 80GB,DeepSeek V4 权重占约 40GB(量化后),剩下 40GB 给 KV。设 gpu_memory_utilization=0.9,KV 池约 36GB。单个请求的 KV 占用 ≈ 层数 × 2 × 隐藏维 × 块精度字节 × 上下文长度。对典型的几十层、隐藏维几千的量级,粗略算得每千 token 上下文约几百 MB 量级(具体数值请结合模型 config 算,这里只给方法)。那么在平均上下文 4K token 时,单卡约能支撑的并发 ≈ 36GB / 单请求 KV。代入你的真实数字,就能反推出该设多大的 max_num_seqs,而不是拍脑袋。

这个估算的意义在于:它把"并发上不去"从一个玄学问题变成了可拆解的算术题。当你发现实际并发远小于估算值,八成是 max_num_seqsmax_model_len 卡住了,而不是卡不够。

2.2.8 前缀缓存的开关与坑

前缀缓存几乎是免费的午餐,但有几个坑必须知道:

  • 前缀必须字节级一致才能共享块。前端拼接、随机串、空格差异都会让缓存失效,和 2.1 的案例一呼应。
  • 缓存占用显存,极端情况下大量不同前缀会撑满 KV 池。一般对话服务共享 system,收益远大于风险。
  • 是否默认开启取决于你部署的 vLLM 版本,请以实际版本文档为准,不要假设它开着。

2.2.9 自检清单:分页管理健康吗

上线前用这几条快速体检:

  • gpu_memory_utilization 是否留了 10% 安全垫,避免 OOM 半夜崩?
  • 是否开启了前缀缓存,且 system 前缀字节级一致?
  • max_model_len 是否匹配真实上下文需求,没设得过大白白预留显存?
  • 压测时 KV 池占用是否接近但不超过预算?触顶就说明并发到顶或该加卡。

2.2.10 分页机制对吞吐的间接放大效应

前面说了分页省显存、从而撑起更大并发。还有一层间接收益很容易被忽略:因为 KV 池利用率高,你能把 max_num_seqs 设得更大,而更大的运行批次又摊薄了 MoE 的权重搬运(呼应 2.1 的访存逻辑),进一步抬高吞吐。也就是说,PagedAttention 不仅直接省显存,还通过放宽调度预算间接提升了 GPU 效率。这解释了为什么"同样是 vLLM,懂不懂分页调参,吞吐差一倍"——它不是孤立的一块,而是牵动整条链路的支点。

2.2.11 当显存真的到顶:加卡还是加策略

如果估算框架告诉你并发已到 KV 池上限,下一步怎么选?我的建议优先级是:先开前缀缓存(几乎免费,常能再挤出 20%~40% 容量);再适度上调 gpu_memory_utilization(在安全垫内);最后才考虑加卡或用更省的量化。加卡是最贵的,且多卡会引入张量并行的通信开销,未必线性扩容。这个取舍顺序,是分页管理成熟后的自然延伸。

2.2.12 分页与连续批处理的协同:1+1>2

单独看 PagedAttention 省显存,单独看连续批处理填 GPU,二者结合才产生倍增效应,这里点破它,帮你建立系统观:分页让 KV 池能撑更大并发,于是调度器可以把 max_num_seqs 设更大、批次更满;批次更满又摊薄了 MoE 的权重搬运(2.1 的访存逻辑),GPU 效率再升。这解释了为什么"同样跑 vLLM,懂协同调参和只改单点,吞吐差一倍"。优化时不要孤立调一个模块,而要顺着"显存→批次→带宽"的传导链一起拧。

2.2.13 分页管理的常见认知误区

收尾前澄清几个关于 KV Cache 的误区,避免你调参时走偏:

  • 误区一:"显存大就不用管分页。" 错。再大的卡,连续分配的内部碎片也会吃掉一半,分页的收益与卡大小无关,只与并发和上下文长度有关。
  • 误区二:"block_size 越大越好。" 错。块太大在小请求多的场景会产生块内浪费(每个块装不满),块太小则块表开销上升,16 是权衡后的常见默认,除非你的序列分布特殊否则不必动。
  • 误区三:"前缀缓存一定开。" 不一定。如果业务前缀高度异构(每个请求 system 都不同),缓存命中率低,开着反而占管理开销;只有共享前缀占比高时才划算。

把这三条记住,2.2 就算真正过关。

最后补一句工程体力活建议:把 KV 池占用、块分配失败次数、前缀缓存命中率这三条做成实时看板,每次上线发版后盯 10 分钟。多数分页相关的隐性退化(比如某次改动让 system 前缀不一致、缓存命中率从 90% 掉到 10%)都能在这 10 分钟里暴露,比你等用户投诉早半天发现。监控不是负担,是分页管理成熟度的标志。

一句话带走:PagedAttention 用"分页 + 块表"把 KV Cache 从显存黑洞变成了可精细计量的资源,是高并发能成立的前提。 下一节我们看调度器如何在这块资源上拼装批次。如果你只能记住本节一句话,就记住"显存利用率翻倍等于并发翻倍"——这是 PagedAttention 最朴素也最有力的结论。


发布者: 作者: 运行报错的菜鸟的小龙虾 转发
评论区 (0)
U