PagedAttention原理,KV缓存也能分页

摘要

大模型推理的瓶颈常常不是算力,而是装不下更多请求的 KV 缓存。vLLM 的 PagedAttention 借用操作系统的虚拟内存思路,把 KV 缓存切成固定大小的块,按需分配、用块表映射,几乎消除了显存碎片,还能在多个请求之间共享块。原论文(SOSP 2023)的实验里,相同延迟下吞吐比 FasterTransformer 和 Orca 高 2 到 4 倍。我还写了一个小模拟器,把这套机制跑了一遍。

背景与问题

自回归生成时,每个已经处理过的 token 都要保存它在每一层的 Key 和 Value,这就是 KV 缓存。它很大:论文里 13B 的 OPT 模型,单个 token 就要 800KB(2 个向量 × 5120 隐层维度 × 40 层 × 2 字节),一个 2048 token 的请求最多占 1.6GB。在 40GB 的 A100 上,约 65% 的显存给了权重,接近 30% 用来存 KV 缓存(其余是激活等),能同时服务多少请求,几乎由这 30% 决定。

早期系统把一个请求的 KV 缓存放在一段连续显存里,并且按最大长度(比如 2048)提前预留。论文把浪费分成三类:

  • 预留浪费:为将来要生成的 token 先占着位置,但这些位置在整个请求期间别人用不了
  • 内部碎片:预留的最大长度远大于实际长度,用不上的部分白白浪费
  • 外部碎片:不同请求预留的大小不同,分配器留下一堆零散缝隙

论文在实验里测到,现有系统里只有 20.4% 到 38.2% 的 KV 缓存显存真正存着 token 状态。另外,连续存放还让共享变得很难:同一个请求的多个采样结果、多个请求共用的系统提示词,它们的 KV 本来可以共用,却各存一份。

核心思路与优势

像操作系统一样分页

PagedAttention 的类比很直接:块是页,token 是字节,请求是进程。

  • 把 KV 缓存切成固定大小的块,vLLM 论文默认每块 16 个 token
  • 每个请求有一张块表 ,把它的逻辑块 (按顺序排的)映射到物理块(显存里的实际位置)
  • 物理块不必连续,用到哪里分配到哪里,新 token 写满一个块才再要一个新块

这样三类浪费就都被压住了:不再提前预留最大长度;浪费只可能出现在每个请求最后一个没写满的块里;所有块一样大,也就没有外部碎片。论文的说法是 KV 缓存显存接近零浪费。

代价是注意力内核要先查块表再取数据。论文测得注意力内核延迟比高度优化的 FasterTransformer 高 20% 到 26%,但这只影响注意力算子,不影响线性层,端到端仍然大幅领先:论文 2023 年的实验里,相同延迟下吞吐比 FasterTransformer 和 Orca 高 2 到 4 倍,原因是显存省下来之后,一次能塞进更多请求。

块级共享和写时复制

块表还带来第二个好处:共享。物理块上有引用计数,多个序列的块表可以指向同一个物理块。

  • 并行采样:同一个提示词生成多个候选,提示词部分的块全部共用
  • 束搜索:不同候选的前缀大部分相同,共享比例更高
  • 共享的块如果某个序列要往里写新内容,就用写时复制:先复制一份给它,再改,其他序列不受影响

论文给出的显存节省:并行采样 6.1% 到 9.8%,束搜索 37.6% 到 55.2%(Alpaca 数据);换成对话更长的 ShareGPT 数据,分别是 16.2% 到 30.5% 和 44.3% 到 66.3%。

显存不够时的调度

块是按需增长的,所以显存可能中途耗尽。vLLM 的做法是:先来先服务,显存不够就抢占,而且整个序列的块要么全部驱逐,要么都不驱逐,同一个请求里的多个序列(比如束搜索的候选)一起抢占、一起恢复。被驱逐的块有两种恢复方式:

  • 交换:搬到 CPU 内存,需要时再搬回来
  • 重算:丢掉,之后重新计算这些 token 的 KV

论文发现:块太小时交换要做大量零碎的 CPU-GPU 小传输,开销很大;重算不读 KV 块,开销与块大小无关。所以小块时重算更划算,大块时交换更划算,但即便在交换占优的情况下,重算也最多比交换慢 20% 左右;块大小在 16 到 64 之间时,两者的端到端性能相当。

块大小怎么选

块太小,GPU 读 KV 缓存的效率下降;块太大,内部碎片变多,共享的机会也变少。论文在 ShareGPT 数据上测到 16 到 128 都不错,在较短序列的 Alpaca 数据上 16 和 32 好、更大的块性能明显变差,最终默认 16。

后来的演进:自动前缀缓存

分块之后,还能做一件事:把已经算好的块缓存下来,新请求只要前缀相同就直接复用。vLLM 当前的实现是基于哈希的自动前缀缓存:每个块的哈希由前一个块的哈希、本块的 token,以及 LoRA 编号、多模态输入哈希、缓存盐值等附加信息共同决定,所以哈希能唯一标识"这个块 + 它前面的全部内容"。几个要点:

  • 只缓存写满的块
  • 从 v0.11 起默认哈希算法是 sha256,降低碰撞风险;也可以通过 --prefix-caching-hash-algo 换成可跨环境复现的 sha256_cbor,或更快但非加密、碰撞风险理论上更高的 xxhash / xxhash_cbor
  • 多租户场景可以给请求加 cache_salt,只有盐值相同的请求才能复用缓存,避免通过延迟差异推测别人缓存了什么内容

另外要注意:vLLM 官方仓库里 paged_attention.md 那篇讲内核实现的设计文档,开头就标明是基于原论文的历史文档,已不再描述今天 vLLM 的代码。想读当前实现,看注意力后端和 KV 缓存管理器的文档更靠谱。

面向人群

  • 想弄清 vLLM 为什么比朴素推理吞吐高的工程师
  • 需要给线上服务估算显存、调 max-model-len 之类参数的人
  • 学过操作系统,想看虚拟内存思想怎么用到大模型上的人
  • 面试或做技术分享,需要讲清 PagedAttention 的人

实践步骤

第一步:先算清楚 KV 缓存多大

每个 token 的 KV 大小 = 2(K 和 V)× 层数 × KV 头数 × 头维度 × 每个数的字节数。

论文里 OPT-13B 用的是传统多头注意力,KV 头数等于注意力头数,所以大。现在的新模型多用分组查询注意力(GQA),KV 头数少得多。我用本机的 Qwen2.5-1.5B-Instruct 配置算了一下:28 层、2 个 KV 头、头维度 128(1536 隐层 ÷ 12 个注意力头)、BF16 每个数 2 字节,每个 token 只要 28KiB,2048 个 token 才 56MiB,比 OPT-13B 单个 token 800KB 小了约 28 倍。所以你的模型到底有多吃 KV 缓存,要看它的配置,不能套用论文里的数字。

第二步:用一个小模拟器看清分页的效果

下面的数字是我写的一个玩具模拟器算出来的,不是 vLLM 的实测,目的是把机制跑一遍。设定:沿用论文的 OPT-13B 数字(每 token 800KB),给 KV 缓存 12GB(约 15000 个 token 位置),最大长度 2048,块大小 16,5000 个合成请求(提示词和输出长度服从对数正态分布,平均总长约 480 个 token),每个请求取生命周期中随机一刻的快照。

方案 真正存着 token 的显存占比 同时能容纳的请求数
连续预留 2048 个位置 17.1% 7
分页(块大小 16) 97.9% 40

分页方案里平均每个请求浪费 7.4 个位置,就是最后一块没写满的部分。要说明的是,这里"能容纳 40 个"是按当前长度算的快照,没有模拟后续增长、抢占和调度,真实系统不可能一直满载。但量级的差异能说明问题:连续预留把绝大部分显存都耗在了用不上的位置上。

核心的块管理只需要引用计数和写时复制:

python 复制代码
BLOCK = 16

class BlockManager:
    def __init__(self):
        self.ref = {}        # 物理块编号 -> 引用计数
        self.next_id = 0

    def new_block(self):
        b = self.next_id
        self.next_id += 1
        self.ref[b] = 1
        return b

    def fork(self, table):                       # 复制块表,只增加引用计数
        for b in table:
            self.ref[b] += 1
        return list(table)

    def append_token(self, table, n_tokens):     # 给序列再放一个 token
        if n_tokens % BLOCK == 0:                # 最后一块写满了 -> 新开一块
            table.append(self.new_block())
        elif self.ref[table[-1]] > 1:            # 要写的块被共享 -> 写时复制
            self.ref[table[-1]] -= 1
            table[-1] = self.new_block()

用它模拟"提示词 300 个 token,每个采样再生成 100 个 token"的并行采样:

采样数 不共享(块) 共享(块) 节省
2 50 32 36.0%
4 100 46 54.0%
6 150 60 60.0%

采样数越多省得越多(提示词在总长里占比越高,同理也越省),趋势和论文一致;具体数字和论文不同,因为这只是固定长度的玩具设定,工作负载完全不同。

第三步:在 vLLM 里和分页相关的参数

下面的参数和默认值取自 2026 年 10 月初 vLLM 主分支的缓存配置,你装的版本可能不同,以 vllm serve --help 为准:

  • --gpu-memory-utilization:这个 vLLM 实例能用的显存比例,默认 0.92。启动时在这个额度内扣掉权重、剖析得到的峰值激活等开销,剩下的大体划给 KV 缓存的块池。和别的进程共卡时要调低
  • --max-model-len:单个请求(提示词加输出)的最大长度,不指定就取模型配置里的上下文长度。块是按需分配的,它并不改变块池大小;它限制的是单个请求最多占多少块,启动时 vLLM 还会检查块池至少装得下一个满长度的请求,装不下就报错并给出估算的可用长度(设成 -1 或 auto 会自动选一个装得下的长度)
  • --block-size:块大小,不指定时默认 16,个别平台或注意力后端会自行调整,一般不需要改
  • 自动前缀缓存:当前版本默认开启,系统提示词很长、多轮对话多的场景收益最大
  • --prefix-caching-hash-algo:前缀缓存的哈希算法,默认 sha256

启动时 vLLM 会先剖析模型的显存占用,再算出能分出多少个 KV 块,日志里会打印类似 GPU KV cache size: N tokens, Maximum concurrency for L tokens per request: X x 的一行。后半句是按每个请求都用满最大长度算的,比较保守;用 N 除以你的平均请求长度,可以得到一个粗略的并发上限。

我的看法

PagedAttention 的价值不在于某个精巧的算法,而在于换了一个视角:KV 缓存的问题本质上是内存管理问题,而内存管理问题操作系统几十年前就解决过。把页、页表、引用计数、写时复制、换入换出这一整套搬过来,论文在 2023 年的实验里就换到了 2 到 4 倍的吞吐。

几点提醒:论文的 2 到 4 倍是在 2023 年的模型、基线和负载上测的,今天的推理引擎也在吸收分页的思想,别把它当成对任何场景都成立的加速比;我的模拟器是玩具,只证明机制,不证明 vLLM 的实际收益;新的混合注意力模型(滑动窗口、Mamba 等)对 KV 缓存管理提出了新要求,vLLM 官方已有专门的混合 KV 缓存管理器,但文档标注这个功能还处于早期阶段。

相关推荐
Java后端的Ai之路1 小时前
Python 进阶探索30 - Python中的装饰器
开发语言·人工智能·python·文件处理·装饰器模式
松就是我902981 小时前
如何设计多Agent
后端
海宇AI1 小时前
Java数据工程:利用海宇婚恋风险报告优化高端婚恋实名与涉诉核验合规体验
java·人工智能
zhangfeng11331 小时前
Metal 是苹果(Apple)自研的计算软件栈 DirectX 12 / HLSL Vulkan / SPIR-V CUDA ROCm
人工智能
打码的老程是远篁1 小时前
手把手教你玩转大模型——3. Transformer 的位置编码——模型怎么知道谁在前,谁在后?
人工智能·算法
ADark1 小时前
FDE 入门 · 03|Palantir 二十年前埋下的伏笔
人工智能
海宇数科1 小时前
Go数据工程:利用海宇婚恋风险报告优化实名制严肃婚恋交友合规体验
人工智能·go
高频因子挖掘机1 小时前
盘中筛选要同时看价格和盘口?按需分层比“一次全拿”更好维护
后端·github·api
xiwc1 小时前
我用 MCP + 多 Agent 搭了一条自动化内容发布流水线
人工智能·mcp
lucas_AI1 小时前
Hinton 下场写 RSI 论文:今天一年的 AI 进步,未来可能只要 5 周
人工智能·llm