跳转到内容
← 返回系统剖析
系统剖析当代约 14 分钟阅读

vLLM:把 KV 缓存当成虚拟内存

vLLM: PagedAttention and the KV Cache as Virtual Memory

vLLMPagedAttentionKV 缓存连续批处理大语言模型推理

2023 年 SOSP 上,加州大学伯克利分校 Sky Computing Lab 的 Woosuk Kwon、Zhuohan Li 与同事描述了 vLLM:一套为大语言模型在线服务设计的内存管理器。他们的观察很具体。推理时,权重是固定的,真正随请求膨胀、又最容易把显存撕成碎片的,是注意力里每一层为已生成词保存的键和值——KV 缓存。若按“每条请求预留到最大长度”去分配,内部碎片会把一张 80 GB 的卡吃成远小于纸面容量的并发度。

PagedAttention 把操作系统里那套分页搬进来:把 KV 缓存切成固定大小的块,用块表做逻辑位置到物理块的映射,允许一条序列的缓存不必在物理上连续。连续批处理负责在词的粒度上把新请求塞进正在跑的批次。两件事合在一起,服务吞吐才从“能跑”变成“一张卡上能同时活着很多条不完全等长的序列”。

破除误解

  1. 误解**误解一:LLM 服务的瓶颈永远是算矩阵乘。

    实情**

生成阶段每一步都要对已经写过的 KV 做注意力,计算量随前缀长度线性增长,但内存占用和内存带宽往往先爆。一条长请求的 KV 缓存可以达到数 GB。并发一高,显存先被缓存占满,矩阵乘单元却在等下一批被调度进来的词。把服务系统做成“更快的 GEMM”,会看错资源账单。vLLM 的第一刀砍在分配器上,不是砍在矩阵乘法的算法上。

误解二:PagedAttention 是一种新的注意力公式。

注意力公式还是缩放点积。变的是 KV 张量的布局:从“每条序列一块连续缓冲区”变成“逻辑块号经块表找到物理块”。内核在计算注意力时,要会沿着块表做一次间接访问。这和操作系统里 CPU 走页表很像,只是这里的“MMU”是写在 GPU 内核里的。公式没变,访存模式变了。把 PagedAttention 理解成另一种 softmax,会完全错过这篇论文。

误解三:连续批处理等于把很多请求做成一个静态的大 batch。

静态 batch 要等最长的那条填满,短请求坐在垫子上浪费算力。Orca(SOSP 2022)把调度粒度从整段生成改到每个词:有请求结束,它的槽位立刻让给新请求。vLLM 在这条调度之上补了内存层:没有分页,连续批处理仍会被“必须预留最大长度”卡住,槽位在纸面上空着,显存里却留着用不到的预留。调度和分配器必须成对出现。

碎片从哪里来

内部碎片来自预留。你不知道用户会在第 20 个词还是第 2000 个词停止,于是按上限分配。序列越短,浪费比例越高。外部碎片来自变长:不断分配和释放不同长度的连续缓冲,显存里留下许多“每块都不够下一条请求”的空洞。GPU 分配器不像用户态 libc 那样随便搬家——张量通常不能在设备上廉价地整体滑动——空洞很难被压实。

PagedAttention 用固定块大小同时打这两类碎片。块太小,块表变长,内核里的间接访问变碎;块太大,内部碎片回来。论文里常用的块大约覆盖 16 个词的 KV。逻辑序列是块的数组;物理上这些块可以散落。释放一条已结束的序列,只需把它的物理块还进空闲链表,不会在显存里留下不规则的洞。这正是分页在 CPU 上解决过的问题,只是现在的“页框”是 GPU 上的一块 KV。

块表还让共享变得可表达。前缀相同的请求——同一条系统提示、同一次并行采样的分叉——不必各存一份相同的 KV。物理块可以有引用计数:分叉时只复制块表,写时再拷贝将被改写的块。这和操作系统的写时复制是同一句话。提示缓存(prefix cache)把高频前缀留在空闲链表之外,让下一条带着相同系统提示的请求直接命中。共享一旦做对,显存从“每条请求一份账单”变成“按独特前缀记账”。

内核必须知道块表。普通注意力内核假定 K 和 V 在连续地址上,可以用向量化加载扫过。分页之后,一次注意力可能要在多个物理块之间跳。vLLM 把跳转写进专用内核,让计算仍然发生在 GPU 上,而不是把 KV 搬回主机再拼成连续缓冲。间接访问有代价,但代价低于碎片造成的并发度损失——论文用吞吐数字表明,在真实的长度分布下,这笔交换是正的。

调度:词一级的进入与退出

在线服务的到达过程是随机的。有人只要一句短答,有人要一篇长文。若系统按离线训练那种固定 batch 来想,必须等到凑齐同长度的一群人。连续批处理把“正在生成的那一步”当成调度单位:每一步看哪些序列还活着,新到达的请求只要显存里还有空闲块,就可以加入下一步的计算。结束的序列立刻释放块。吞吐因此跟长度分布绑定,而不是跟最大长度绑定。

抢占是灰色地带。显存被更长的请求吃满时,系统可以暂停某条序列、把它的 KV 卸到主机内存,之后再加载回来。这和操作系统把页换出到磁盘是同一类动作,只是这里的“磁盘”是 PCIe 另一侧的主机 DRAM,延迟以毫秒计,对词级延迟可见。是否抢占、抢占谁,属于策略,不是分页本身的定理。分页只保证:被抢占的序列不必作为一整块连续缓冲进出,可以按块搬。

前缀共享与连续批处理叠在一起时,会出现记账问题。一块被两条序列共享的 KV,释放其中一条不该把块删掉。引用计数必须进入分配器的热路径。实现做错,轻则泄漏显存,重则让一条还活着的序列读到已回收的块。操作系统为页框做过几十年的这类记账;GPU 上的推理运行时才刚把这套账本抄过来,测试负担与操作系统的页面回收一样,属于正确性而不是性能彩蛋。

vLLM 开源之后很快进入生产,是因为这条抽象足够窄:它不重新发明注意力公式,不要求改模型结构,只要求替换 KV 的存放与查找。模型仍是 Transformer,训练仍可以在别处完成。服务系统终于有了一个和操作系统教科书对齐的名字,来谈论那块过去被藏在框架内部的巨大缓冲区。

KV 的体积从哪来,页表如何把它切开

以常见的 Transformer 解码为例:每一层、每个注意力头,都要为已出现的每个 token 存一组键和值。半精度下,体积大致随层数、头数、头维度和序列长度线性涨。论文给出的 13B、较长上下文量级是单请求 GB 级——于是并发几十路时,KV 会先于权重把显存顶满。旧运行时按 max_seq_len 连续预留,等于按最坏长度买地,用户提前结束时地空着却不能租给邻居。

PagedAttention 把 token 维切成固定块,请求只持有逻辑块到物理块的映射。注意力核按块循环做 Q 与该块 K/V 的乘加,再在块间归约。块大小是超参:16 是论文里的工程点。太小,间接寻址和内核循环次数涨;太大,最后一页的内部碎片涨。并行采样时,多支先共享前缀块,写时复制发生在引用计数大于 1 且该支要追加的时候。这和操作系统 fork 的页表是同一张图。

预填充吃算力,解码吃 KV 带宽。调度器若让一条长 prompt 独占 GPU,所有解码都会饿死;分块预填充是把这条长计算切开,插进解码步之间。抢不到空闲块的请求被抢占,KV 卸载要付 PCIe。论文主路径尽量让分页把抢占变成稀有事件:池化之后,长度方差被块池吸收,而不是被连续缓冲放大。

前缀缓存把跨请求的公共系统提示变成可命中的热块。命中率高时,预填充被跳过,吞吐再上一截;热块被钉住,有效空闲块下降。运维要把命中率、钉住块数和抢占次数放进同一块仪表盘。只盯 token/s,会看不见块池被钉死的那天。

核的正确性比调度更难调试。逻辑序列在物理上不连续,注意力核必须按块表把该读的键值找齐。页表一乱,模型仍会吐出通顺的句子,只是句子在悄悄用错上下文。这比崩溃危险。因此分页不仅是分配器,还是一套必须被测试的寻址约定:块大小、头布局、位置编码与掩码都必须和核一致。换一种注意力变体,往往要重写这块核,而不是只改 Python 调度。系统对象停在服务运行时:它吃导出的权重,卖的是显存形状与吞吐,不是新的网络结构。

哪些负载会把分页的收益吐回去

极短请求、单路、上下文长度几乎等于预留且没有共享前缀时,连续缓冲本来就没有多少外部碎片。此时分页付出查表和间接寻址,收益接近零。赚钱场景是长度方差大、高并发、系统提示可共享。容量规划应按长度分布的尾部买显存:平均值好看,一条超长上下文就能把块池抽干。拒绝策略和抢占策略是产品,不是调度器的边角。

张量并行把层内的头切开到多卡,每张卡仍要为自己那一份键值付分页。块表变成每卡一份,调度还要在卡间对齐步。流水线并行把层切开,键值的寿命跨过阶段边界。分页没有取消这些并行策略,它只是让每张卡上的请求池更不容易被连续预留卡死。把分页理解成“可以不再做模型并行”,会在更大的模型上重新撞墙。

块池是共享公地

空闲块是所有并发请求的公共资源。一条超长请求可以合法地申请大量块,直到池子见底,邻居开始抢占或排队。这不是内核漏洞,是装箱问题的日常形态。产品必须决定:何时拒绝新请求、何时抢占旧请求、前缀热块最多允许钉住多少。没有这些规则,分页只是把碎片从连续缓冲换成了公地悲剧。

监控因此要能回答:此刻有多少块空闲、多少被钉住、抢占率是否在爬升、长度分布的尾部有没有变厚。扩卡能增加块的绝对数量,改变不了规则缺失。把显存告警只设成“用量超过百分之九十”,会在钉住块占满有效容量时仍然显示“还有空闲”,因为会计看的是字节,不是可分配的块。

预填充和解码对块池的压力形状不同。预填充一次写入整段提示的 KV,分配是突发的;解码每步只追加一块或几块,但步数随输出长度走,占用是拖尾的。调度若只按“还有空闲块”来接新请求,可能在一批长解码正在拖尾时,又放进一条巨大的预填充,把池子抽干。好的策略把两类需求分开记账:预填充有自己的预算,解码有自己的保底。分页提供的是可切割的块,不是自动的准入控制。运行时若把分配器当成调度器,会在高峰期同时输掉延迟和吞吐。

位置编码、滑动窗口和分组查询注意力,都会改变键值的寿命与布局。分页核必须跟着这些变体长,否则块表里的逻辑下标会对不准物理内容。服务运行时的升级因此绑定模型结构:换一种注意力,往往是一次运行时发布,而不只是换权重文件。把模型和运行时当成两个独立发布列车,会在第一次结构变更时对上静默错答。

跨域连接

  • 线性代数:注意力每一步仍然是查询向量与键矩阵的乘积,再对值矩阵做加权。分页没有改这组乘积,它改的是矩阵行的存放位置。块表把“第 t 个词的键”从物理行号变成一次间接寻址,于是线性代数内核必须能在非连续行上做归约。这提醒我们:矩阵乘法的数学定义不依赖行在内存里相邻,连续布局只是一种对缓存友好的表示。当表示的浪费大过间接寻址的税,非连续反而成为正确的数据结构设计。
  • 组合数学:长度分布让预留策略变成组合浪费。N 条请求、每条预留 Lmax 而实际长度是随机向量 L,浪费的块数是 Σ(Lmax − L_i) 再乘上层数与头数。外部碎片则是不同长度区间能否拼进空洞的装箱问题。固定块大小把装箱问题退化成“还剩多少个相同大小的箱子”,这是分页总是赢过可变分区的组合原因。块大小的选择是在块表长度与箱内浪费之间做截断,没有对所有分布都最优的单一整数。
  • 概率论:在线服务是到达与服务时间的随机过程。连续批处理把服务时间的方差从“必须对齐到批次里的最大值”改成“每条序列按自己的停止时间退出”。吞吐的期望因此跟长度的期望有关,而不再跟长度的极值有关。抢占策略则是在显存这个有限容量的排队系统里决定谁被换出:长度尾部越重,KV 占用的方差越大,空闲块数的波动越剧烈。分配器要按分布留出余量,不能按平均值把卡塞满。
  • 图论:块表是一棵极浅的地址翻译图:逻辑页号指向物理块,共享则让多条序列的表指向同一物理顶点,写时复制在即将分叉的顶点上插入新节点。引用计数是顶点上的度数约束——入度为零才能回到空闲表。把 KV 缓存看成图而不是看成数组,才能说清楚为什么前缀共享是一次图上的顶点复用,而不是一次字节拷贝。注意力计算沿着这条翻译图去取边,而不是沿着物理地址的线性序去取边。

参考文献

  • Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. ACM SOSP, 2023.
  • Yu, G.-I. et al. Orca: A Distributed Serving System for Transformer-Based Generative Models. USENIX OSDI, 2022.(词级连续批处理)
  • Vaswani, A. et al. Attention Is All You Need. NeurIPS, 2017.
  • Pope, R. et al. Efficiently Scaling Transformer Inference. Proceedings of Machine Learning and Systems, 2023.
  • Dao, T. et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS, 2022.(对照:算子层的 IO 优化,与分配器层正交)

延伸阅读

  • vLLM 项目文档中关于 KV cache manager、block size 与 prefix caching 的说明(随版本更新).
  • Kilpatrick, L. 等后续关于分离式预填充与解码、多机 KV 传输的服务系统论文,用来看分页抽象如何跨设备。
  • Silberschatz, A., Galvin, P. B., Gagne, G. Operating System Concepts 中分页与写时复制章节——读 PagedAttention 时最省事的对照物。