第 6 章 缓存管理:把显存当操作系统来管
第 3 章算过一笔账:8B 的 GQA 模型,一条 8K 请求的 KV cache 是 1 GiB。一条请求不算多,但一个服务要同时接几十条,缓存就成了显存里最大的一块,而且它随对话变长一路涨,直到把卡撑爆。
更麻烦的是碎片。请求长度参差不齐,有的几百 token,有的一万;有的聊两句就走,有的挂在那里半小时。如果按最长长度给每条请求预留一块连续显存,浪费会大到无法接受。这一章讲的就是怎么管这块显存。思路不是推理领域原创的,它借自操作系统,所以先把那个概念补上。
先垫一块砖:虚拟内存与分页
操作系统管理内存的方式,几十年前就定型了。
程序看到的内存地址是虚拟的,不是物理内存的真实位置。操作系统把虚拟地址空间切成固定大小的页,比如 4 KB 一页,再维护一张页表,记录每个虚拟页对应哪一块物理页。程序访问一个地址时,硬件查表,找到真正的物理位置。
这样做解决了三个问题。第一是碎片:物理内存不必连续,任何一块空闲的页都能分配出去,不会因为找不到一整块连续空间而失败。第二是按需分配:程序申请一大片地址,实际用到哪页才给哪页,没用到的不占物理内存。第三是共享:两个进程的页表可以指向同一块物理页,读的时候共享,写的时候各自复制一份。
大模型推理的缓存管理,遇到的是同一类问题:长度不定、需要共享、不能浪费。PagedAttention 这个名字里的 paged,说的就是直接借用分页这套办法。
缓存为什么要存在
先回到缓存本身。
没有缓存的话,每生成一个 token,都要把前面所有 token 的 K 和 V 重新算一遍。生成长度为 T 的序列,总计算量是 T 的平方。有了缓存,每个 token 的 K、V 只算一次,存下来,后面直接读,总计算量变成 T 的线性。
这个转换的收益随长度放大。8K 上下文,平方和线性差八千倍;32K 差三万倍。所以缓存不是优化项,是长上下文能成立的前提。
缓存的形状是固定的:每一层、每一个 KV 头、每一个位置,都有一份 K 和一个 V,每个元素的字节数由精度决定。第 3 章那个公式算的就是这个体积。
连续分配为什么会失败
最直接的做法是给每条请求分配一块连续的显存,大小按最大可能长度算。
问题是请求长度不可预测。一条请求进来时,你不知道用户会聊三句还是三千句。按上限预留,8K 上限、实际用 500 token,浪费百分之九十以上;按实际增长去扩容,又会在显存里留下一个个空洞——这就是外部碎片。总空闲显存可能还有 10 GB,但没有任何一块连续的 1 GB 能分配出去。
还有一个更隐蔽的浪费:输出长度也不知道。生成到一半,缓存要往后长,如果后面那块显存已经被别人占了,就得整体搬家。搬一次要复制几个 G,延迟直接爆炸。
第 3 章算过,一条 8K 请求 1 GiB。一张 80GB 卡,权重 15 GiB,剩 65 GiB,看起来能放 65 条。实际做过服务的人都知道,能放到三四十条就不错了,差的那部分全是碎片和预留。
分页:把缓存切成固定大小的块
解法是把缓存切成固定大小的块,比如每块存 16 个 token 的 K 和 V。一条请求的缓存由若干个块组成,这些块不必连续,用一张块表记录顺序。
这样一来,分配的单位从"整条请求"变成"一个块"。显存里只要还有空闲块,就能分配出去,不存在找不到连续空间的问题。块表的开销可以忽略:一条 8K 请求用 512 个块,每个表项几个字节,总共几 KB。
按 8B 的 GQA 模型算,每 token 128 KiB,一块 16 个 token 就是 2 MiB。一张 80GB 卡,权重之外剩 65 GiB,能切出三万多块,也就是五十多万个 token 的缓存容量。同样的显存,能服务的并发比连续分配高出一大截。
块大小是一个取舍。块小,浪费少,但块表长、管理开销大;块大,管理简单,但每条请求最后一个块大概率填不满。16 个 token 是常见的折中。
共享:同一段前缀只存一份
分页带来的第二个好处是共享。
很多请求的开头是一样的:系统提示词、几个 few-shot 例子、上一轮对话。这些 token 的 K 和 V 完全相同。如果每条请求各存一份,同一段文字被存了几十遍。
有了块表,这件事变得简单:多条请求的块表可以指向同一批物理块。读的时候共享,某条请求要继续往后写的时候,只在新块上写,不碰共享的部分。这在实现上叫写时复制,和操作系统的做法完全一样。
效果取决于前缀有多长。系统提示词两千 token,一千条请求,共享之后省下的是两千 token 乘以一千份的缓存,也就是几百 GiB 的显存,以及同样量级的重复预填充计算。这就是第 5 章说的前缀缓存,它在多轮对话、代码补全、Agent 场景里收益最大,因为这三类负载的前缀又长又稳定。
块表长什么样
抽象地讲分页容易糊涂,看一个具体的表就清楚了。
假设块大小是 16 个 token。一条已经生成 500 个 token 的请求,需要 32 个块。它的块表是这样一串编号:第四号块、第九号块、第十二号块、第八十七号块,等等。这些块在显存里完全不连续,顺序由表决定。
现在来了第二条请求,前 200 个 token 和第一条完全相同,比如同一段系统提示词。它的块表前十三项直接抄第一条的编号,后面对应新内容的部分才分配新块。显存里那十三个块只有一份,但被两条请求同时指着。
每条请求的块表旁边有一个引用计数。第二条请求加入时,共享块的计数从一变成二;某条请求结束,计数减一;减到零,块才回到空闲池。这样既不会提前回收,也不会泄漏。
写的时候要小心。如果第二条请求要修改某个共享块的内容,不能直接写,得先复制一份,改副本。这和操作系统的写时复制是同一个机制,只是这里复制的是几 MB 的缓存。
块表本身的体积可以忽略:32 项,每项几个字节。真正的开销在管理:分配、回收、查表、处理碎片,这些操作每一步都要做,所以引擎的实现质量在这里体现得很明显。
缓存放不下的时候
显存总有满的时候。满了之后有三个选择。
第一个是淘汰。按最近最少使用的顺序丢掉一些块,下次用到再重算。丢的代价是重算预填充,所以淘汰策略要尽量保住"马上还会用到"的部分。多轮对话里,上一轮的缓存大概率还会用,最久没动的那些请求才该丢。
第二个是下沉。把不常用的缓存搬到 CPU 内存。GPU 和 CPU 之间的通道比显存慢,但比重新计算便宜。Grace 这类和 GPU 高速互联的 CPU,专门为这件事设计,带宽能到几百 GB/s。再往下是分布式缓存,把缓存放到别的机器的内存里,跨网络取,延迟更高,只有在集群规模下才划算。最后才是硬盘,基本只用于离线场景。
第三个是拒绝。显存实在不够,新请求排队或者直接返回错误。这是最后的手段,但必须有,否则进程会被 OOM 杀掉,连已有的请求一起丢。
缓存感知路由
单机之内,缓存管理是块表和淘汰策略的事。到了多副本,问题变成:这条请求该发给哪个副本。
如果每个副本的缓存内容不同,那么一条请求发给"正好有这段前缀"的副本,就能省掉整个预填充。反之,发给一个没有缓存的副本,就要从头算一遍。同样一条请求,首字延迟可以差几倍。
所以编排层会记录每个副本持有哪些前缀,按缓存命中来路由。这就是第 5 章提到的缓存感知路由,它和本章的块管理是同一件事的两个层面:单机管块,集群管副本。
代价是负载可能不均。缓存命中最高的副本会被持续打过来,热点集中。所以实际系统要在命中率和负载均衡之间加权,而不是单纯追求命中。
一次手算:块、并发与命中
把这一章的账算一遍。
8B GQA 模型,权重 15 GiB,一张 80GB 卡剩 65 GiB。每 token 缓存 128 KiB,一块 16 token 是 2 MiB。65 GiB 能放约三万三千块,合计约五十三万 token。
如果每条请求平均 2K token,理论上能并两百六十条。实际要打折:块内碎片、预留余量、块表开销,能到一百五十条就算不错。
再看前缀缓存。系统提示词 1K token,占 64 块,128 MiB。一百条并发共享这一份,省下的显存是 12.5 GiB,省下的预填充计算是每条约 1.4 TFLOP 乘以一百。这两个数字加起来,足以让同一张卡多接三十条请求。
如果缓存命中率从零提到八成,首字延迟的下降通常比吞吐的提升更明显,因为省掉的正是预填充那一段。
缓存的边界
缓存管理能解决的是浪费,解决不了容量。一条 128K 上下文的请求,缓存就是 16 GiB,什么块管理都省不出来,只能靠量化或者拆到多卡。
它也不能解决重复计算之外的问题。缓存命中率高,说明前缀复用做得好;但如果每条请求本身就很长、前缀又各不相同,命中率上不去,这时候该看的是负载形状,而不是继续调缓存参数。
下一章讲量化。缓存和权重都要占显存,也都受带宽限制,量化是同时作用于这两者的手段,代价是精度。