第 5 章 分页:把 KV cache 当虚拟内存管
先修:第 2–4 章 · 深度:L2 · 预计阅读:30 分钟
一句话:缓存不是"分够了就浪费一点",是"分多了根本分不下去"。
五个长合同,十五个短问题
回到那二十条请求:五份 8K 合同,十五个两百字的问题。
最直白的做法,是给每条都按最大长度预留。max_model_len 设 8192,那就是每条预留 4 GiB。
十四条的位置,十四条都按 4 GiB 占着,一共 56 GiB。
实际用了多少?五条长的各 4 GiB,十五条短的各 100 MiB 左右:
5 × 4 + 15 × 0.1 = 21.5 GiB
占了 56,用了 21.5,剩下的三十多 GiB 就在那里空着,谁也拿不走。
浪费是怎么来的
两种。
内部碎片:你给一条请求划了 4 GiB,它只用 100 MiB,尾巴上那 3.9 GiB 别人不能用。请求越短,浪费比例越高。
外部碎片:请求陆续结束、陆续到来,显存里留下大小不一的空洞。总空闲可能还有 8 GiB,但凑不出一块连续的 4 GiB,新请求照样进不来。
两者叠加,实际能跑的并发远低于"显存总量除以单条缓存"。
分页:不再按最大值划地
思路是从操作系统借来的:别给整块地,给页。
把缓存切成固定大小的块,比如一块装 16 个 token。7B 的缓存是每 token 512 KiB,一块就是 8 MiB。
一条 8K 请求 = 512 块。一条 200 token 的请求 = 13 块,最后一块只用了一点。
请求要多少给多少,用完就还。块可以散落在显存的任何地方,靠一张页表把"第几块逻辑块"映射到"哪块物理显存"。
于是内部碎片只剩最后一块的零头,外部碎片基本消失——任何一块 8 MiB 的空位都能用。
vLLM 的 PagedAttention 就是这套东西,论文里报告把缓存浪费压到 4% 以下,吞吐提升 2 到 4 倍。数字是他们在自己的实验里测的,你的机器上不一定一样,方向可以信。
顺带解决的共享问题
二十个同事用同一个系统提示词,五百个 token。
以前每条请求各算一遍、各存一份:二十份,约 4.9 GiB。
有了页表,这件事变得自然:二十条请求的页表,指向同一批物理块。前缀算一次就够,后面的请求直接接上。
这就是前缀缓存(prefix caching)。同一条系统提示、同一份长文档被反复提问,收益最大。不同前缀的请求之间没有便宜可占。
还有个细节:两条请求共享着同一块,其中一条要改,怎么办?复制一份再改。这是写时复制,操作系统里也是这套。
代价
页表要查。每个 token 生成时,注意力要按页表把分散的块找回来,比连续内存多一次间接寻址。
块不能太大,也不能太小。块大,最后一块浪费多;块小,页表变长,查表开销上来。十六个 token 一块是常见折中。
实现复杂度也上去了。内存分配器、块管理、共享与回收,这些在连续分配的方案里都不存在。
什么时候分页没用
batch=1 时,没有并发就没有浪费,分页只增加开销。
请求长度都很接近、上下文都很短时,内部碎片本来就少。
更关键的是:分页解决的是"分配方式",不解决"总量不够"。二十条要 80 GiB,卡只有 72,分页再聪明也放不下。
经验:先看利用率,再谈扩卡
显存报警,先别加卡。看一眼缓存的实际利用率和块的使用数。
利用率低、块却分配不出来,是碎片问题,分页能救。
利用率已经九成以上,是总量问题,只能减负载或者换硬件。
这两个判断差一张卡的钱。
练习
六道题,先闭卷,再翻 ANSWERS.md。
- 二十条请求,五条 8K、十五条 200 token,按 8K 预留,浪费多少 GiB?
- 一块 16 token,8K 请求要几块?200 token 要几块?
- 分页消掉了哪一种碎片?哪一种还留着?
- 找错:"用了 PagedAttention,显存就够了。"
- 二十条请求共享 500 token 的系统提示,能省多少缓存?
- 起一次 vLLM,打开 prefix caching,用同一段长前缀发十次请求,观察 TTFT 的变化。
这一章就记一句
按最大值预留,短请求会浪费掉绝大部分显存,十五个短问题就能空占三十 GiB。分页把缓存切成十六 token 一块,按需分配,浪费降到零头,还能让相同前缀共享同一批块。
真只记一句:显存不够时,先分清是"总量不够"还是"分配太糙";前者加卡,后者分页。
下一章换一个方向:不改分配,改存储——把权重和缓存本身压小。