Lecture 7 · KV cache 管理:PagedAttention 与 Mooncake · KV Cache Management

Efficient ML Inference at Scale · Lecture 07。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:Stanford CS349E / EE339 Efficient ML Inference at Scale(Autumn 2026),原课第 10 次课 · Thu Oct 22 · KV Cache Management(Fredrik Kjolstad)。本讲义把它提前到”推理系统”部分(见 SYLLABUS §0.1)。阅读材料:W. Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention(SOSP 2023);R. Qin et al., Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving(阅读材料链接的是 ACM 期刊版;会议版发表在 FAST 2025)。 依据说明:课程网站只公开了课表和阅读材料,没有 slides。本讲以两篇阅读材料为主线(Mooncake 依据 arXiv 2407.00079 的 v5 版本),另外用到这些公开文献:SGLang 的 RadixAttention(Zheng et al., 2024)、vLLM 的自动前缀缓存文档、操作系统教材里的虚拟内存。补充材料是我选的,不代表课堂上实际讲了什么。 配套代码 Code:code/lecture07/(numpy;三个模块 + 5 个脚本,最长的约 50 秒) 和 CS349D 的关系:本仓库 CS349D 第 3 讲(PagedAttention 与 SGLang 的前缀缓存)和第 9 讲(Mooncake、分层 KV 存储)读的是同样的论文,偏服务系统;本讲更多从显存和存储层次的账来看。 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 说清楚 KV cache 为什么难管:它很大、随生成动态增长、长度事先不知道;以前的系统把每个请求的 KV 放在一整块连续显存里,浪费在三个地方——预留、内部碎片、外部碎片。
  2. 用操作系统的虚拟内存解释 PagedAttention:物理块、块表、按需分配、引用计数、写时复制、换出;知道块大小怎样取舍。
  3. 说出共享带来的好处:并行采样、束搜索、共同前缀;会算共享省下的显存和复制量。
  4. 解释显存不够时的抢占:为什么是”全有或全无”、换出和重算各在什么时候划算;为什么分页 + 抢占不会像第 6 讲那样死锁。
  5. 理解前缀缓存:链式哈希怎样认出”这段前缀以前算过”;缓存容量和命中率的关系;LRU 为什么比 LFU 好。
  6. 复述 Mooncake 的设计:把 KV 当成集群的一等公民,放在 CPU 内存 / SSD 组成的池子里(“用存储换计算”);按 KV 的位置调度 prefill;过载时提前拒绝及其副作用。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲会讲 Covered here后面再展开 Later
第 2 讲 §5–6:KV cache 的大小、MHA / GQA / MLA显存的三种浪费;分页、块表、共享、写时复制、抢占分页注意力内核怎么写(第 10、11 讲)
第 5 讲:prefill / decode 解耦、TTFT / TPOT前缀缓存、链式哈希、缓存策略KV 的量化(第 3 讲 §3 已讲)
第 6 讲:迭代级调度、选择性批处理、KV 预留与死锁Mooncake:分层 KV 池、按 KV 调度、提前拒绝把 KV 搬过网络的代价(第 5 讲 §7 已讲)
P1:存储层次、带宽与延迟操作系统虚拟内存的十分钟入门(§2)

0.3 本讲地图 Map

§1  KV cache 为什么难管:大、会长、长度未知
§2  背景:操作系统的虚拟内存(十分钟入门)
§3  以前的系统:显存去哪了                                         ← 代码 02
§4  PagedAttention:块、块表、按块注意力;块大小                     ← 代码 01、02
§5  共享:并行采样、束搜索、共同前缀;写时复制                        ← 代码 01、04
§6  调度与抢占:全有或全无;换出 vs 重算                             ← 代码 03
§7  端到端:分页让服务快多少                                        ← 代码 03
§8  跨请求的前缀缓存:链式哈希、命中率、缓存策略                      ← 代码 04
§9  Mooncake:以 KV cache 为中心的集群                              ← 代码 04、05
§10 其他让 KV 变小、变便宜的办法
§11 现状(2025–2026)
§12–16  小结、常见误区、练习(含答案)、延伸阅读、下一讲预告

0.4 记号 Notation

记号含义
BB块大小(一个物理块存几个 token 的 KV);vLLM 默认 16,Mooncake 的 trace 按 512
槽位 slot存一个 token 的 KV 的空间
块表 block table一个序列的”逻辑块 → 物理块”映射
引用计数 ref count有几个序列的块表指向这个物理块
写时复制 CoWcopy-on-write:往共享的块里写之前先复制一份
Orca (Max / Pow2 / Oracle)vLLM 论文给 Orca 设的三种预留方式:按最大长度、输出按 2 的幂、恰好按真实长度

1. KV cache 为什么难管 · Why KV cache is hard to manage

第 2 讲 §5.5 算过 KV cache 的大小:每个 token 要存 2×L×G×dh2 \times L \times G \times d_h 个数(K 和 V、每层、每组 KV 头)。几个例子(BF16):

模型注意力每 token 的 KV一个 2048 token 的请求
OPT-13B(vLLM 论文用的)MHA,40 头819 KB1.68 GB
Llama-3.1-8BGQA,8 组128 KB0.27 GB
LLaMA2-70B(Mooncake 的哑模型)GQA,8 组320 KB0.67 GB

OPT-13B 放在一块 A100 40GB 上:权重 26 GB,KV 能用约 12 GB(论文表 1)——只够 7 个最大长度的请求。第 1 讲 §4.4 说过:decode 受带宽限制,一步的时间几乎不随批大小变化,所以能同时放下几个请求,吞吐就是几倍。KV 的显存管理直接决定吞吐。

难在三点:

  1. 大:每个请求几百 MB 到几 GB。
  2. 会长:decode 每一步每个序列多一个 token,KV 跟着长;请求结束又一下子释放。
  3. 长度事先不知道:用户的提示长短不一,生成多长要等模型输出 EOS 才知道。

第 6 讲 §3.5 已经撞上了这堵墙:按最大长度预留,显存大部分是”占着不用”;按请求声明的上限预留,仍是实际用量的约 2.5 倍;不预留,负载一高就死锁。vLLM 的答案来自一个老地方:操作系统。


2. 背景:操作系统的虚拟内存 · Background: virtual memory in ten minutes

如果你没学过操作系统,这一节是 PagedAttention 需要的全部背景。

问题:很多程序同时运行,每个程序需要的内存大小不一、随时变化。如果每个程序必须占一整段连续的物理内存,就会出现:

  • 内部碎片(internal fragmentation):分给你的比你用的多,多出来的别人也用不了;
  • 外部碎片(external fragmentation):空闲内存加起来够,但被切成很多不连续的小段,一个大请求放不进去。

分页(paging)的办法:

  1. 把物理内存切成固定大小的页(page,常见 4 KB);
  2. 每个程序看到的是连续的虚拟地址,由一张页表(page table)把”虚拟页 → 物理页”翻译过去——物理页可以散在任何地方;
  3. 按需分配(demand paging):程序真正用到某一页时才给它分配物理页;
  4. 共享:两个程序的虚拟页可以指向同一个物理页(比如共享库);用引用计数记录有几个人在用;
  5. 写时复制(copy-on-write):fork() 创建子进程时不复制内存,父子共享所有页,并标记为只读;谁先写某一页,就给谁复制一份;
  6. 换出(swapping):物理内存不够时,把某些页写到磁盘,需要时再读回来。

代价是一次间接寻址(查页表),硬件用 TLB 缓存页表来加速。

操作系统vLLM
物理页(4 KB)物理 KV 块(BB 个 token 的 K、V)
进程的虚拟地址空间一个序列的逻辑 KV(位置 0, 1, 2, …)
页表块表(block table)
按需分配页序列长到需要新块时才分配
fork() + 写时复制并行采样 / 束搜索:共享块,往共享块里写时复制
共享库共同的系统提示 / few-shot 前缀
换出到磁盘换出到 CPU 内存,或干脆丢掉以后重算

vLLM 论文 §8 指出了两个”LLM 特有”的改造:(1) 一个序列的所有块总是一起被用到(每次 decode 都要读全部 KV),所以换出是”全有或全无”;(2) KV 可以重算出来(操作系统的页丢了就没了,KV 丢了还能从 token 重新算),所以恢复多了一个选项。


3. 以前的系统:显存去哪了 · Where the memory went

代码:02_fragmentation.py

3.1 三种浪费

vLLM 论文 §3.1(图 3):以前的系统(FasterTransformer、Orca)要求一个请求的 KV 是一个连续的张量,于是按”最大可能长度”给每个请求预留一整块。浪费有三种:

  1. 预留(reserved):为将来要生成的 token 预留的槽位。它们最终会被用到,但在请求的整个生命周期里一直占着,本来可以先给别的请求用。
  2. 内部碎片(internal fragmentation):预留的比请求最终用到的多——请求提前结束了,或者分配器向上取整了。永远用不到。
  3. 外部碎片(external fragmentation):分配器(比如伙伴分配器)在请求之间留下的空隙。

论文图 2 的测量:以前的系统里,真正存着 token 的 KV 只占 20.4%–38.2%;vLLM 是 96.3%。

3.2 伙伴分配器的例子

vLLM 论文假设 Orca 用伙伴分配器(buddy allocator)在一大块显存里分配:它只分配 2 的幂大小的块,释放时和”伙伴”合并。代码 02 演示 1(总共 16 个槽位):

事件结果空闲合计最大的连续空闲块
A 要 5 个给 8 个(内部碎片 3)88
B 要 3 个给 4 个(内部碎片 1)44
C 要 2 个给 2 个22
B 结束–64
D 要 6 个失败64

D 失败时空闲的 6 个槽位正好够,但它们是 4 + 2 两块、不连续——外部碎片。

3.3 一块 A100 上的账

代码 02 演示 2 用迭代级调度(第 6 讲的 Orca)模拟 OPT-13B 在一块 A100 40GB 上服务(KV 能用 12,596 个 token 的槽位),过载时各种管理方式下显存的去向(按迭代平均):

管理方式存着 token预留(将来会用)内部碎片外部碎片
Orca (Max):按 2048 预留23.3%15.6%58.3%2.4%
Orca (Pow2):输出按 2 的幂预留23.5%15.8%49.8%10.4%
Orca (Oracle):恰好按真实长度预留39.4%26.4%24.1%9.2%
vLLM:分页,块 1692.8%1.6%01.3%

(ShareGPT 式的负载:平均输入 161、输出 338 个 token,长度分布是我按论文的平均值合成的;3 req/s,过载。)

  • 按 2048 预留时,真正存着 token 的约四分之一,大头是内部碎片——预留了却永远用不到。
  • 即使事先知道每个请求生成多长(Oracle,现实中做不到),伙伴分配器向上取整、加上”还没生成到的部分被提前占着”,有效利用率也只有约四成。
  • 分页时九成以上存着 token。

3.4 能同时跑几个请求

代码 02 演示 3(论文图 13 的设置):

负载MaxPow2OraclevLLM
ShareGPT,2 req/s6.06.010.118.0
Alpaca,30 req/s6.031.634.535.2

ShareGPT 上分页能同时跑的请求数约是 Oracle 的 1.8 倍、Max 的 3 倍(论文在 OPT-13B 上测到 2.2 倍和 4.3 倍)。Alpaca 的请求很短(平均输入 19、输出 58),除了 Max 以外显存都不是瓶颈,批大小由到达速率决定。


4. PagedAttention · Paged KV blocks

代码:01_paged_attention.py、paged_kv.py

4.1 块与块表

PagedAttention 把每个序列的 KV 切成固定大小的KV 块,每块存 BB 个 token 的 K 和 V(论文 §4.1)。显存被预先切成很多物理块;每个序列有一张块表,记录”第 ii 个逻辑块在哪个物理块、填了几个”(§4.2)。

代码 01 演示 1 复现论文图 6 的例子:提示”Four score and seven years ago our”(7 个 token),块大小 4。池子里有些块已经被别人占着:

这一步块表(逻辑 → 物理)已写入最后一块
prefill 7 个 token[4, 2]73/4
decode:写入”fathers”[4, 2]84/4
decode:写入”brought”[4, 2, 0]91/4
  • 按需分配:prefill 只分配 2 块;第一次 decode 写进第 2 块剩下的槽位;第 2 块满了才分配第 3 块。
  • 物理块不连续:逻辑上连续的 KV 散在显存各处,靠块表找回来。
  • 浪费只在最后一块:任何时候每个序列最多浪费 B−1B - 1 个槽位。

4.2 按块计算注意力

注意力要用到一个序列全部的 K 和 V,可它们现在散在各处。论文式 (4) 把注意力写成按块的形式:对第 jj 个块 KjK_j、VjV_j,

Aij=exp⁡(qi⊤Kj/d)∑t=1⌈i/B⌉exp⁡(qi⊤Kt1/d),oi=∑j=1⌈i/B⌉VjAij⊤.A_{ij} = \frac{\exp(q_i^\top K_j / \sqrt{d})}{\sum_{t=1}^{\lceil i/B \rceil} \exp(q_i^\top K_t \mathbf{1} / \sqrt{d})}, \qquad o_i = \sum_{j=1}^{\lceil i/B \rceil} V_j A_{ij}^\top.

内核按块表逐块读 K、V(vLLM 让一个 warp 读一个块,保证合并访存),算分数、做 softmax、乘 V。代码 paged_kv.blockwise_attention 就是这样写的:和”把块拼成连续数组再算”相比,最大差 3e-16。

代码 01 演示 2 更进一步:把第 6 讲的选择性批处理(ragged_forward)接到分页的 KV 上——它只要求 cache 有 length / write / read 三样东西,分页的序列实现这三样就行。4 个长短不一的序列做 prefill + 20 步 decode,不管块大小是 1、4、16 还是 64,logits 和连续的 KV 一致到 6×10−156 \times 10^{-15}。分页只改变 KV 放在哪里,不改变算什么。

4.3 代价:间接寻址

查块表是一次额外的间接寻址,内核要处理不规则的访问、变长的序列。vLLM 论文图 18a:PagedAttention 的注意力内核比 FasterTransformer 高度优化的内核慢 20%–26%。但它只影响注意力,不影响线性层,端到端仍然快得多(§7)。为了减小开销,vLLM 写了三个融合内核(§5.1):写 KV 时一次完成”切块 + 重排 + 写入”;读 KV 时把”按块表读”融合进注意力;写时复制要复制的块攒起来一次复制。

4.4 块大小的取舍

代码 02 演示 4(ShareGPT 式的长度分布,最终长度平均 493 个 token):

块大小最后一块的浪费平均块表长度一个块(一层的 K 或 V)
10.00%49310 KB
161.48%31164 KB
645.97%8655 KB
25620.11%22.6 MB
  • 块越大,最后一块平均浪费越多(约半块)。
  • 块越小,块表越长、每次读的数据越碎,GPU 的并行度用不满;换出时要做很多次小拷贝(§6.2)。
  • vLLM 论文图 18b:块大小 16–128 在 ShareGPT 上最好,Alpaca(短序列)上 16、32 最好;默认取 16。

4.5 系统结构

vLLM 用一个集中式调度器(论文图 4):调度器里的块管理器维护所有序列的块表;每次迭代,调度器决定哪些序列这次跑、各需要几个新块,然后把 token 和块表一起发给 GPU worker。张量并行时(第 4 讲),每块 GPU 处理一部分注意力头,但所有 GPU 处理的是同样的 token 位置,所以它们共用同一张块表、同样的物理块编号——每块 GPU 的物理块里只存自己那几个头的 K、V(§4.6)。


5. 共享:并行采样、束搜索、共同前缀 · Sharing

代码:01_paged_attention.py 演示 3–4、04_prefix_caching.py 演示 2

5.1 并行采样:fork + 写时复制

代码补全里常见”给我 n 个候选”:同一个提示生成 nn 个样本。提示的 KV 只需要算一次、存一份(论文图 8):

  1. prefill 完成后,把这个序列 fork 出 nn 个——新序列的块表指向同样的物理块,每块的引用计数 +nn;
  2. 每个样本往自己的最后一块写新 token 的 KV。如果最后一块是共享的(引用计数 > 1),先复制一份再写;
  3. 样本结束时 free:引用计数 −1,减到 0 的块才真正回到空闲链表。

代码 01 演示 3(提示 100 个 token,各生成 40 个,块大小 16):

样本数 nn输出 vs 各自一份 KV最后一步用的块不共享时要的块平均省下写时复制次数
2相同121837.6%1
4相同183656.4%3
6相同245462.7%5

提示的前 96 个 token 在 6 个满块里,第 7 块只填了 4 个。往那个半满的块里写时,前 n−1n-1 个样本各复制一次,最后一个发现引用计数已经是 1,直接写——所以复制 n−1n - 1 次。论文图 15 测到并行采样省 6.1%–9.8%(Alpaca)、16.2%–30.5%(ShareGPT);我们的提示比输出长,所以省得更多。

5.2 束搜索:共享一起走过的路

束搜索每一步保留累计概率最高的 kk 个候选;新的候选来自旧候选的扩展,几个新候选可能来自同一个父候选,也有父候选被淘汰。候选之间不只共享提示,还共享一起走过的那段路,而且共享关系每一步都在变(论文图 9,像操作系统里一连串 fork 出来的进程树)。

以前的系统要在候选之间频繁复制 KV:一个子候选要继承父候选的全部 KV。分页时,子候选 fork 父候选(共享块),只有往共享的最后一块里写时才复制一块。代码 01 演示 4(提示 64、生成 32 步、块大小 16):

束宽 kkkk 个结果整份复制:复制的 token分页:写时复制的 token平均省下的块
2相同5,08820839.1%
4相同10,17656060.3%
6相同15,26497667.5%

复制量少了 16–24 倍;显存省 39%–68%(论文:37.6%–55.2% Alpaca、44.3%–66.3% ShareGPT)。结果完全相同。

5.3 共同前缀

很多请求以同样的系统提示或 few-shot 例子开头。服务方可以预先算好这些前缀的 KV、放在一组物理块里(像操作系统的共享库),新请求的块表直接指向它们,只算自己的部分(论文图 10)。§8 会把这个想法推广成自动的前缀缓存。

vLLM 用三个操作实现所有这些解码方式(§5.2):fork(从已有序列创建新序列)、append(追加 token)、free(删除序列)。模型和内核只看到每个序列的物理块编号列表,不需要知道共享关系——不同解码方式的请求可以放在同一个批里。


6. 调度与抢占 · Scheduling and preemption

代码:03_paged_serving.py 演示 3–4

6.1 不预留,就要能抢占

分页不预留:接纳一个请求时只看”现在放不放得下它的输入”。之后序列变长,块可能不够——第 6 讲 §3.5 的死锁就是这么来的。vLLM 的办法是抢占(§4.5):

  • 先到先服务:最早到的请求优先;要抢占时,抢最晚到的。这保证最老的请求总能前进——只要单个请求放得下,就不会死锁。
  • 全有或全无:一个序列的所有块总是一起被用到,所以要么全部换出、要么全部留下。一个请求里的几个序列(束搜索的候选、并行采样的样本)共享块,作为一个序列组一起调度、一起抢占。

6.2 被抢占的 KV 怎么恢复:换出还是重算

  • 换出(swapping):把 KV 块拷到 CPU 内存,之后再拷回来。代价是 PCIe 的带宽,以及每次拷贝的固定开销。
  • 重算(recomputation):直接丢掉,之后把”提示 + 已生成的 token”当作一个新的提示做一次 prefill。prefill 是一次大的矩阵乘法,比当初逐个 decode 快得多。

代码 03 演示 4 的模型(OPT-13B;PCIe 有效 25 GB/s、每次拷贝 10 µs,都是假设值;vLLM 每层的 K、V 各自成块,一个序列要拷贝 层数 × 2 × 块数 次):

序列长度块大小换出 + 换回重算
102411.71 s143.77 ms
102416169.51 ms143.77 ms
10246492.71 ms143.77 ms
102425673.51 ms143.77 ms

块小时拷贝次数多、固定开销占主导,重算快;块大时换出快。重算的时间和块大小无关。这和论文图 19a 的形状一致;论文实测重算最多比换出慢约 20%(在我们的假设下块很大时换出快约一倍),端到端在块大小 16–64 时两者相当。vLLM 默认用重算。

6.3 过载时的代价

代码 03 演示 3(ShareGPT,1500 个请求):

速率 req/s每 token 延迟抢占次数重算的 token / 所有输入 token
1.529 ms9910%
253 ms70963%
3465 ms2,099159%
4890 ms2,517191%

刚过容量时抢占很少;严重过载时被抢占的请求反复重算,重算量超过了原本的输入量——这是用计算换显存。实际的 vLLM 还留一点水位(watermark)、限制每步接纳的数量,减少来回抢占。


7. 端到端:分页让服务快多少 · End-to-end

代码:03_paged_serving.py 演示 1–2

代码 03 演示 1 画了延迟–速率曲线(论文图 12 的模拟版;每格是平均每 token 延迟,ms):

ShareGPT0.511.522.53 req/s
Orca (Max)3015003404438749815377
Orca (Oracle)2365769153321032522
vLLM23252953208465

每条曲线先平、后突然上翘:速率超过系统的处理能力以后,队列无限变长。要求”平均每 token 延迟 ≤ 50 ms”时二分查找最高速率(演示 2):

负载MaxPow2OraclevLLMvLLM / OraclevLLM / Max
ShareGPT0.630.510.951.982.1×3.2×
Alpaca4.0122.0940.1591.902.3×22.9×

论文的数字:ShareGPT 上 vLLM 能承受的速率是 Orca (Oracle) 的 1.7–2.7 倍、Orca (Max) 的 2.7–8 倍,是 FasterTransformer 的最多 22 倍;摘要里概括为”同样延迟下吞吐提高 2–4 倍”。共享前缀的翻译负载上,比 Orca (Oracle) 高 1.67 倍(1-shot)和 3.58 倍(5-shot);聊天负载上 2 倍。我们的模拟方向和量级一致。注意 Oracle 是”事先知道输出长度”的理想情况——分页不需要知道就超过了它。

论文 §8 也说了什么时候分页没用:显存不是瓶颈的负载(训练时形状固定;非 LLM 的模型多半受算力限制),分页的间接寻址只会带来开销。


8. 跨请求的前缀缓存 · Prefix caching across requests

代码:04_prefix_caching.py

8.1 从”共享”到”缓存”

§5.3 的共同前缀需要服务方事先知道哪些前缀会被共享。更一般的做法:请求结束后不立刻释放它的 KV 块,留在一个缓存里;新请求进来时,看看它的开头有没有已经算过的部分。真实负载里这种机会很多:系统提示、few-shot 例子、同一篇长文档上的多个问题、多轮对话(第 tt 轮的输入 = 第 t−1t-1 轮的输入 + 回答 + 新问题)、智能体的反复调用。

两种主流实现:

  • SGLang 的 RadixAttention:用一棵基数树(radix tree)按 token 序列组织所有缓存的 KV,新请求沿树往下走找到最长的公共前缀;淘汰时按 LRU 从叶子开始删(CS349D 第 3 讲详细讲过)。
  • vLLM 的自动前缀缓存(automatic prefix caching)和 Mooncake:按块做链式哈希,哈希相同的块就是可以复用的块。

8.2 链式哈希

每个完整块的哈希 = hash(前一块的哈希 ‖ 这一块的 token)(Mooncake 图 3)。代码 04 演示 1(块大小 4):两个请求共享系统提示和开头,前 3 块哈希相同、第 4 块不同;把系统提示里一个 token 改掉,那一块以及之后所有块的哈希都变了。

为什么要”链式”?因为一个 token 的 K、V 不只取决于它自己,还取决于它前面的全部 token(注意力看得见前文)。两段 token 完全相同、但前文不同的块,KV 是不一样的,不能复用。链式哈希让”哈希相同”⇔“从开头到这一块完全相同”。所以前缀缓存只能复用从开头开始连续相同的部分。不满一块的零头不进缓存。

8.3 在真实模型上复用

代码 04 演示 2 复现 vLLM 论文图 16 的设置:8 个请求共享一段 few-shot 前缀(1-shot 80 个 token、5-shot 341 个 token),各自的问题 10–30 个 token。第一个请求从头算,把完整的块登记进缓存;后面的请求按哈希找到缓存的块,块表直接指向它们(引用计数 +1、不复制),只算自己的后缀:

前缀8 个请求的输入 token实际算的省下和从头算的最大差
1-shot(80)79123171%0
5-shot(341)2,87552382%0

5-shot 的前缀是 21 个满块 + 5 个零头,零头每次要和后缀一起重算。前缀越长、后缀越短,省得越多。

8.4 命中率与缓存容量

Mooncake 开源了一个真实 trace(1 小时、23,608 个请求,只有到达时间、输入输出长度和按 512 个 token 一块的哈希编号,不含内容):平均输入 8,590、输出 182 个 token(平均输入是平均输出的约 47 倍,论文说逐请求的输入 / 输出比平均约 720——Kimi 以长上下文著称)。超过一半的块之后再没被用到,少数块被访问上万次。论文表 1:单一的全局缓存,容量从 1,000 块增加到 50,000 块,LRU 的命中率从 0.34 涨到 0.55,再大就几乎不涨。

代码 04 演示 3 用一个仿这些统计量的合成 trace(4,157 个请求、24 分钟,平均输入 8,709、输出 183;56% 的块只出现一次):

策略 \ 容量(块)无限30,00010,0003,0001,000
LRU0.580.580.560.500.31
LFU0.580.580.500.300.20
论文 LRU0.550.540.46–0.34
论文 LFU0.550.500.40–0.34
  • 命中率先快速上升、再平缓:无限容量时的上限由”第一次出现的块”决定,再大的缓存也没用。
  • LRU 比 LFU 好(论文也是):复用多半发生在时间上相近的请求之间(同一个会话的下一轮);LFU 让”历史上很热、现在没人用”的块赖着不走。

8.5 省下的计算,和需要的存储

代码 04 演示 4 把命中率换算成 prefill 的 GPU 时间(LLaMA2-70B、8 块 A800):无限缓存时 prefill 的总 GPU 时间从 1.05 小时降到 0.42 小时(2.46 倍),容量 10,000 块时 2.34 倍、1,000 块时 1.46 倍。

但缓存很大:一块 512 个 token 的 KV 是 168 MB,10,000 块要 1.68 TB——一台 8 卡机器的 HBM 一共只有 0.64 TB。前缀缓存想要高命中率,就必须走出 GPU 显存。这就是 Mooncake 的出发点。


9. Mooncake:以 KV cache 为中心的集群 · Mooncake

代码:05_mooncake.py、kv_store.py

9.1 背景与目标

Mooncake 是月之暗面(Moonshot AI)的 Kimi 的服务平台。它面对的现实:长上下文(输入是输出的几十倍)、大量可复用的前缀、GPU 供给有限、经常过载。作为模型即服务(MaaS)的提供方,目标是:在满足 TTFT 和 TBT(time between tokens,即第 5 讲的 TPOT)SLO 的前提下,最大化有效吞吐——只有完整完成的请求才算数,做了一半被放弃的请求,前面花的资源全部白费(§2)。

9.2 架构

(论文图 1、图 4)

  • prefill 池和 decode 池分开(第 5 讲的解耦);
  • 再加一个分布式 KV 池:用 GPU 集群里本来闲着的 CPU 内存、SSD 和 RDMA 网卡,按块存放 KV(块带链式哈希,见 §8.2),淘汰可以用 LRU / LFU 等;块在 CPU 和 GPU、机器和机器之间的传输由一个基于(GPUDirect)RDMA 的组件 Messenger 负责;
  • 中心是全局调度器 Conductor:为每个请求选一对 prefill 实例和 decode 实例,并决定 KV 块的复制和换出。

一个请求的四步:(1) KV 复用:把能复用的前缀 KV 从(可能是远程的)CPU 内存读到选中的 prefill 实例;(2) 增量 prefill:只算没命中的部分,长的输入切成块、流水线执行,新算出的 KV 写回 CPU 内存;(3) KV 传输:按层把 KV 流式传到 decode 实例的 CPU 内存,和 prefill 重叠;(4) decode:KV 到齐后加入 decode 实例的连续批处理。decode 实例在接纳时会再确认一次 TBT SLO,可能拒绝——这时 prefill 就白做了(§9.6 的问题)。

9.3 用存储换计算

为什么值得把 KV 存到 CPU 内存、甚至 SSD 上?代码 05 演示 1 比较每个 token”读回来”和”重算”的时间(带宽都是假设值,按一台 8 卡机器合计):

模型与硬件每 token 的 KV重算本机 CPU 内存(PCIe)远程内存(RDMA 800 Gbps)本机 SSD
LLaMA2-70B(GQA),8 块 A800328 KB98.9 µs1.6 µs(60×)3.3 µs(30×)13.7 µs(7×)
OPT-13B(MHA),1 块 A100819 KB155.1 µs32.8 µs(4.7×)65.5 µs(2.4×)273.1 µs(0.6×)

(括号是”重算 / 读回”,大于 1 表示读回更快。)

  • LLaMA2-70B 每个 token 的 KV 只有 320 KB(GQA 把 KV 头从 64 减到 8),重算一个 token 却要约 140 GFLOP:从哪一层存储读回来都比重算快一个数量级以上——“用存储换计算”。
  • MHA 的 OPT-13B 每个 token 的 KV 是 819 KB,计算量却只有 70B 的五分之一:读回来只快几倍,从 SSD 读反而更慢。KV 缓存值不值得放到慢的存储上,取决于模型结构——GQA、MLA 这类 KV 小的设计(第 2 讲 §6)让分层缓存更划算。这是又一个模型和系统协同设计的例子。

另外,Mooncake 按层重叠(§5.2 layer-wise prefill):第 ll 层计算时异步读第 l+1l+1 层的 KV、异步写出第 ll 层新算的 KV。代码 05 演示 1:一个 8.7K token、命中一半的请求,按层重叠时 prefill 从 870 ms 降到 454 ms,读 KV 的时间几乎全藏在计算后面;不重叠时从 SSD 读要多 60 ms。论文还指出这让 prefill 节点几乎不受 GPU 显存大小的限制(只要放得下一个请求)。

9.4 按 KV 调度:Conductor 的算法

(论文算法 1、§6)

以前的负载均衡只看每个实例上有多少请求。Mooncake 的 prefill 调度还要看谁手里有这个请求的前缀:对每个 prefill 实例,估计

TTFT=Tqueue+Tprefill(输入长度,本地命中的前缀长度)\text{TTFT} = T_\text{queue} + T_\text{prefill}(\text{输入长度}, \text{本地命中的前缀长度})

选最小的(排队时间 = 排在前面的请求的预计 prefill 时间之和;prefill 时间用离线测得的预测模型)。如果最好的那个实例太忙,而另一个实例命中的前缀长得多(超过一个阈值倍数),就把那段 KV 拉过来(估计时加上传输时间),拉过来的块存在本地——热门的前缀因此自动复制到多台机器上(hot-spot migration),避免所有请求都挤到同一台机器去拉。如果哪个实例都满足不了 TTFT SLO,直接返回 HTTP 429。

代码 05 演示 2(8 个 prefill 实例,各有 3,000 块的本地缓存,TTFT SLO 5 秒):

调度策略平均 TTFT(7.1 req/s)TTFT ≤ 5 s命中的输入 token实际算的 token
随机17.99 s23.8%27%26.6 M
按负载(队列最短)10.61 s43.9%27%26.3 M
按缓存(估计 TTFT 最小)0.56 s100%56%16.1 M
按缓存 + 热点 KV 复制0.42 s100%59%14.8 M

随机或按负载分配时,同一篇文档的请求散到 8 个实例上,每个实例的缓存都只命中一小部分;按缓存分配让后续请求自然落到算过它的实例上,命中率翻倍、要算的 token 少约 40%,负载高时 TTFT 差一个数量级以上。加上 KV 复制再好一些(多了 6.8 M 个 token 的网络传输)。排序和论文图 8 一致(论文在 8P+8D 的集群上回放 23,000 个真实请求)。

9.5 长上下文的 prefill:分块流水线并行

长上下文(128K 甚至 1M token)的 prefill 一台 8 卡机器太慢。跨机器做张量并行要每层两次跨机 all-reduce(第 4 讲),太贵;序列并行(ring attention 等)每层也要跨机通信,还要和 KV 传输抢网络。Mooncake 用分块流水线并行(chunked pipeline parallelism,CPP,§5.1):把 XX 台 prefill 机器组成一条流水线(每台负责一部分层),把一个长请求的输入切成块,不同的块同时在不同的机器上算——利用了 decoder-only 的因果性(后面的块只依赖前面块的 KV)。只在流水线段的边界通信,容易和计算重叠;对短请求几乎没有额外开销,也不需要频繁调整并行配置。

9.6 过载:提前拒绝与振荡

(论文 §7)

真实服务经常过载:用户请求的增长远快于 GPU 的增长。过载时必须拒绝一部分请求,问题是什么时候拒绝。

  • 基线:到达时只看 prefill 能不能按时做完。做完 prefill 才发现 decode 实例放不下(TBT SLO),拒绝——prefill 白做了。
  • 提前拒绝(early rejection):到达时就同时看 prefill 和 decode 的负载,decode 现在已经满了就直接拒绝。
  • 振荡:可是”现在的 decode 负载”和”这个请求真正进入 decode 时的负载”之间隔着一个 prefill 的时间。decode 满时拒绝一切,prefill 闲下来;decode 空出来以后一下子接很多,等它们做完 prefill 涌进 decode,decode 又满了——prefill 和 decode 的负载此起彼伏(论文图 9、10a,在 20 台机器的集群上观测到)。
  • 基于预测的提前拒绝:预测这个请求 prefill 完成那一刻的 decode 负载。Mooncake 目前用系统级的预测:假设每个请求的 decode 都花同样的时间 tdt_d,据此推算那一刻哪些请求已经结束、哪些新请求会进来。

论文表 3(8P+8D、真实 trace 2 倍速回放):拒绝的请求数从 4,183(基线)降到 3,771(提前拒绝)和 3,589(基于预测)。

代码 05 演示 3 做了一个小模拟(8 个 prefill + 8 个 decode 实例;为了分开”负载本身的波动”和”调度引起的波动”,用平稳的泊松到达,速率是 decode 容量的 2 倍):

策略完成prefill 后才拒绝白做的 prefillprefill 与 decode 负载的相关系数
基线4,1291,8711,646 秒+0.15
提前拒绝4,156564480 秒−0.33
按预测提前拒绝(tdt_d 相同)3,9241,2041,091 秒+0.23
  • 提前拒绝让白做的 prefill 少了约 70%,负载出现了论文描述的反相(相关系数为负)。
  • 但完成的请求几乎一样多:在这个模拟里 decode 池才是瓶颈,完成数由它决定;省下来的是 prefill 的算力(可以少开几台 prefill 机器,或者去做别的事)。
  • 按论文的办法(假设每个请求 decode 时间相同)做预测,在这个输出长度长尾分布的负载上预测不准:白做的 prefill 只少了约三分之一,完成数反而少了约 5%。我们的玩具复现不出论文”拒绝得更少”这一点——论文的 SLO 统计(P90 TTFT / TBT)、decode 的接纳条件、真实负载的形状都和这里不同。我把这个结果如实留下:提前拒绝的价值首先是不浪费资源;它能不能让更多请求完成,取决于哪个阶段是瓶颈,以及预测有多准。

9.7 结果

论文的端到端实验都用和 LLaMA2-70B 同结构的哑模型(保护商业信息),8×A800 节点、800 Gbps RDMA:

  • 公开数据集(ArXiv 摘要、L-Eval)上,Mooncake-[3P+1D] 比 vLLM-[4M] 吞吐高 20% 和 40%(L-Eval 的前缀缓存命中率高);
  • 模拟的长上下文数据(16K–128K)上,吞吐高 50%–525%(vLLM 的长 prefill 严重干扰 decode);
  • 真实负载回放:Mooncake-[10P+10D] 约 100% 的请求满足 TBT SLO,vLLM-[20M] 只有 57%;同样的 SLO 下多处理约 75% 的请求。

10. 其他让 KV 变小、变便宜的办法 · Other ways to make KV cheaper

本讲的方法都不改变计算结果(分页、共享、缓存、换出)。还有一类方法直接让 KV 变小,代价是可能改变模型的输出:

  • 改结构:MQA / GQA / MLA(第 2 讲 §6)。DeepSeek-V3 的 MLA 每个 token 只缓存一个压缩的潜在向量。
  • 量化 KV:FP8 / INT8 / INT4 的 KV cache(第 3 讲 §3.5)。显存和带宽都按比特数缩小。
  • 丢掉不重要的 token:StreamingLLM(只保留开头几个”注意力汇聚”token 和最近的窗口)、H2O、SnapKV 等按注意力分数挑选要保留的 token。对长上下文很有效,但是有损的。
  • 压缩后传输:CacheGen 等把 KV 编码压缩,减少跨机器传输和存储的字节(Mooncake 这类系统的补充)。
  • 稀疏注意力:只读一部分 KV(如 DeepSeek 的稀疏注意力),减少带宽,但 KV 仍要存。

这些和本讲的方法正交,可以叠加:分页管理量化过的 KV,前缀缓存存 MLA 的潜在向量。


11. 现状(2025–2026)· State of the field

  • 分页是默认:vLLM、SGLang、TensorRT-LLM 都用分页的 KV;FlashAttention、FlashInfer 都有直接读块表的注意力内核。
  • 前缀缓存是默认:vLLM V1 的自动前缀缓存(按块哈希)、SGLang 的 RadixAttention 都默认打开;多轮对话、智能体负载(第 15 讲)让命中率非常高。
  • KV 走出 GPU:KV 卸载到 CPU 内存和 SSD 成为常见功能(LMCache、NVIDIA Dynamo 的 KV 块管理器、Mooncake Store);Mooncake 的传输引擎开源后被集成进 vLLM 和 SGLang 的 prefill / decode 解耦实现。
  • KV 感知的路由:多副本部署时,按”哪个副本缓存了这个请求的前缀”路由(SGLang router、Dynamo 的 KV-aware router),和本讲的 Conductor 是同一个思路。
  • 硬件跟进:更大的 HBM(Blackwell Ultra 288 GB,第 1 讲 §5.6)、CPU 和 GPU 之间更快的互连(Grace Hopper / Grace Blackwell 的 NVLink-C2C),让”KV 放在 CPU 内存里”更便宜。

12. 小结 · Summary

  1. KV cache 大、会长、长度事先不知道;能同时放下几个请求,decode 的吞吐就是几倍,所以 KV 的显存管理直接决定吞吐。
  2. 连续分配有三种浪费:预留、内部碎片、外部碎片。vLLM 论文测到以前的系统只有 20%–38% 的 KV 显存存着 token;我们的模拟里按最大长度预留约 23%、事先知道长度也只有约 39%。
  3. PagedAttention 把 KV 切成固定大小的块、用块表映射,按需分配,浪费只在最后一块;块大小 16 是浪费和效率的折中;注意力内核慢 20%–26%,但端到端快得多。
  4. 块可以共享:引用计数 + 写时复制让并行采样、束搜索、共同前缀省下大量显存和复制;结果完全相同。
  5. 不预留就要能抢占:先到先服务、抢最晚到的、全有或全无;恢复时块小用重算、块大用换出。
  6. 端到端:同样延迟下,分页能承受的请求速率约是 Oracle 的 2 倍、按最大长度预留的 3 倍以上(论文 1.7–2.7 倍、2.7–8 倍)。
  7. 前缀缓存用链式哈希认出算过的前缀;命中率随容量饱和、LRU 好于 LFU;高命中率需要 TB 级的存储,GPU 显存装不下。
  8. Mooncake 把 KV 放到集群的 CPU 内存 / SSD 池里(GQA 让读回比重算快一个数量级以上)、按 KV 的位置调度 prefill(命中率翻倍、TTFT 降一个数量级)、过载时提前拒绝(少浪费 prefill,但可能引起负载振荡;能否多完成请求取决于瓶颈和预测)。

13. 常见误区 · Common pitfalls

  1. “分页会改变注意力的结果”——分页只改变 KV 放在哪里;代码 01 验证 logits 一致到 10−1510^{-15}。
  2. “预留的显存反正会被用到,不算浪费”——它在请求的整个生命周期里占着显存,本来可以先给别的请求用;而且实际生成往往远短于声明的上限。
  3. “块越小越好,浪费最少”——块太小,块表变长、内核读的数据太碎、换出要做很多次小拷贝;vLLM 取 16。
  4. “共享前缀的 KV 只要 token 相同就能复用”——还要求前面的全部内容相同(注意力看得见前文),所以用链式哈希、只复用从开头连续相同的部分。
  5. “不预留 KV 会死锁”——没有抢占才会;分页 + 抢占最晚到的请求,最老的请求总能前进。
  6. “换出总比重算好,因为不浪费计算”——块小时换出被拷贝次数拖垮;prefill 很快时重算更省事。
  7. “前缀缓存越大越好”——命中率会饱和(第一次出现的块再大的缓存也帮不上);TB 级的缓存要放在 CPU 内存 / SSD 上,读回的带宽和延迟要算进去。
  8. “KV 放到 SSD 上一定比重算快”——取决于每 token 的 KV 字节和计算量之比:GQA 的 70B 模型快 7 倍,MHA 的 13B 模型反而更慢(代码 05 演示 1)。
  9. “提前拒绝一定让更多请求完成”——它首先是少浪费 prefill;decode 是瓶颈时完成数几乎不变,还可能引起负载振荡(代码 05 演示 3)。
  10. “LFU 适合热门内容,所以比 LRU 好”——KV 的复用多半在时间上相近的请求之间,LRU 更好(Mooncake 表 1 和我们的模拟都是)。

14. 练习 · Exercises

练习 1(KV 有多大) OPT-13B(40 层、hidden 5120、MHA、FP16)。(a) 每个 token 的 KV 多少字节?(b) 一个 2048 token 的请求呢?(c) A100 40GB 上权重 26 GB,如果剩下的全给 KV,最多放几个 2048 token 的请求?

答案:(a) 2×40×5120×2=819,2002 \times 40 \times 5120 \times 2 = 819{,}200 字节 ≈ 819 KB(论文按 1 KB = 1024 B 写作”800 KB”)。(b) 2048×819,200≈1.682048 \times 819{,}200 \approx 1.68 GB。(c) (40−26)/1.68≈8.3(40 - 26) / 1.68 \approx 8.3,最多 8 个;按代码里给激活留 10% 的算法,KV 有 12,596 个槽位,⌊12596/2048⌋=6\lfloor 12596 / 2048 \rfloor = 6 个。

练习 2(伙伴分配器的内部碎片) 三个请求分别要 300、600、1100 个槽位,伙伴分配器各给多少?内部碎片占分配总量的多少?

答案:512、1024、2048,共 3584;浪费 212+424+948=1584212 + 424 + 948 = 1584,占 44%。

练习 3(块大小与浪费) 请求的最终长度平均 493 个 token。假设最后一块平均填一半,估算块大小 16 和 256 时最后一块的浪费比例;和代码 02 演示 4 的 1.48%、20.11% 比较。

答案:16:8/493≈1.6%8/493 \approx 1.6\%,和 1.48% 接近。256:128/493≈26%128/493 \approx 26\%,实测 20.11% 更低——“平均填一半”对大块不成立:块比很多请求还长时,浪费 = 块大小 − 长度,和长度分布有关,不再是半块。

练习 4(并行采样的写时复制) nn 个样本 fork 自同一个序列,最后一块半满。写下一个 token 时一共发生几次写时复制?如果提示长度正好是块大小的整数倍呢?

答案:n−1n - 1 次:前 n−1n - 1 个写的样本各复制一次,每次复制后原块的引用计数 −1,最后一个样本看到引用计数是 1,直接写。整数倍时最后一块是满的,下一个 token 写到新分配的块里,不需要复制(代码 01 的自检也测了这种情况)。

练习 5(束搜索的复制量) 束宽 k=4k = 4,提示 64 个 token,生成 32 步。“整份复制”的做法:第一步 4 个子候选各复制 64 个 token 的 KV;之后第 ss 步(s=1,…,31s = 1, \dots, 31)每个子候选复制父候选当时的 64+s64 + s 个 token。一共复制多少个 token?和代码 01 演示 4 的 10,176 对照。

答案:4×[64+∑s=131(64+s)]=4×[64+31×64+496]=4×2544=10,1764 \times [64 + \sum_{s=1}^{31}(64 + s)] = 4 \times [64 + 31 \times 64 + 496] = 4 \times 2544 = 10{,}176。✓ 分页时只复制了 560 个 token(35 次写时复制 × 16)。

练习 6(换出 vs 重算的交叉点) 用代码 03 演示 4 的模型(OPT-13B,PCIe 25 GB/s,每次拷贝 10 µs,每层 K、V 各自成块),1024 个 token 的序列,重算要 143.77 ms。块大小 32 时,换出 + 换回要多久?哪个更快?

答案:每块一层的 K(或 V)=32×40×128×2=327,680= 32 \times 40 \times 128 \times 2 = 327{,}680 字节,拷贝一次 10 μs+327680/25e9=23.1 μs10\ \mu s + 327680 / 25\text{e}9 = 23.1\ \mu s;次数 =40×2×32=2560= 40 \times 2 \times 32 = 2560,换出 59.2 ms,换出 + 换回 118.3 ms < 143.77 ms,换出更快。交叉点在块大小 16 到 32 之间。

练习 7(共享前缀省多少) 5-shot 前缀 341 个 token(块大小 16),8 个请求,后缀平均 20 个 token。估算前缀缓存省下的 prefill token 比例。

答案:341 = 21 个满块(336)+ 5 个零头。第一个请求算 341+20341 + 20;其余 7 个各算 5+205 + 20。实际算 361+175=536361 + 175 = 536,总共 8×361=28888 \times 361 = 2888,省 81%(代码 04 演示 2 实测 82%,后缀长度是随机的)。

练习 8(用存储换计算) LLaMA2-70B:(a) 每个 token 的 KV(80 层、8 组 KV、dh=128d_h = 128、BF16)?(b) 每个 token 的矩阵乘法约 137 GFLOP,8 块 A800 按 60% 峰值(每块 312 TFLOPS),重算一个 token 要多久?(c) 从 100 GB/s 的 RDMA 读一个 token 的 KV 要多久?从一块 6 GB/s 的 SSD 呢?

答案:(a) 2×80×8×128×2=327,6802 \times 80 \times 8 \times 128 \times 2 = 327{,}680 字节。(b) 137e9/(8×312e12×0.6)≈91 μs137\text{e}9 / (8 \times 312\text{e}12 \times 0.6) \approx 91\ \mu s(加上注意力约 99 µs,代码 05 演示 1)。(c) RDMA 3.3 μs3.3\ \mu s(快约 28 倍);一块 SSD 54.6 μs54.6\ \mu s——仍然比重算快(约 1.7 倍)。

练习 9(为什么 GQA 让分层缓存更划算) 比较 OPT-13B 和 LLaMA2-70B 的”每 FLOP 的 KV 字节”。这个比值和”读回 vs 重算”有什么关系?

答案:OPT-13B:819,200/26.2e9≈3.1×10−5819{,}200 / 26.2\text{e}9 \approx 3.1 \times 10^{-5} B/FLOP;LLaMA2-70B:327,680/137e9≈2.4×10−6327{,}680 / 137\text{e}9 \approx 2.4 \times 10^{-6} B/FLOP,小约 13 倍。读回时间 ∝ KV 字节 / 带宽,重算时间 ∝ FLOPs / 算力,所以读回 / 重算 = (B/FLOP) × (算力 / 带宽)。同样的硬件上,B/FLOP 小 13 倍,读回就相对便宜 13 倍——GQA / MLA 让”把 KV 存在慢存储上”变得划算。

练习 10(链式哈希) 一个请求有 5 个完整的块。如果第 3 块里的一个 token 被改了,哪些块的哈希会变?为什么不能每块单独哈希?

答案:第 3、4、5 块的哈希都变(第 1、2 块不变)。每块单独哈希会把”token 相同但前文不同”的块当成同一块——可它们的 KV 不同(注意力看得见前文),复用就会算错。

练习 11(提前拒绝什么时候能让更多请求完成) 代码 05 演示 3 里提前拒绝几乎没有增加完成的请求数。说说在什么条件下它会增加。

答案:完成数由瓶颈阶段决定。decode 是瓶颈时,基线也能把 decode 一直填满(prefill 多做的那些只是白做),完成数不变。如果 prefill 也是瓶颈,白做的 prefill 会挤占别的请求的 prefill,让它们的 TTFT 超过 SLO 被拒绝——这时提前拒绝把 prefill 留给”之后进得了 decode”的请求,完成数会增加。另外,如果 TTFT / TBT 的 SLO 按 P90 统计(论文的做法),排队变短也会让更多请求达标。预测要准,否则(像我们按”decode 时间相同”预测长尾输出时)会误拒能完成的请求。

练习 12(为什么分页 + 抢占不会死锁) 第 6 讲 §3.5 里”不预留”会死锁。vLLM 也不预留,为什么不会?

答案:显存不够时,vLLM 抢占最晚到的请求,释放它的全部块。只要单个请求放得下,最早到的那个请求永远不会被抢占,它总能前进直到完成、释放显存;然后下一个最早的请求前进……所以系统总有进展。代价是被抢占的请求要换出或重算(代码 03 演示 3:严重过载时重算量可以超过原本的输入量)。


15. 延伸阅读 · Further reading

阅读材料

前缀缓存与 KV 存储

  • L. Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs, NeurIPS 2024(RadixAttention). https://arxiv.org/abs/2312.07104
  • Y. Liu et al., CacheGen: KV Cache Compression and Streaming for Fast Large Language Model Serving, SIGCOMM 2024. https://arxiv.org/abs/2310.07240
  • vLLM 文档:Automatic Prefix Caching(设计说明里有按块哈希的细节)。

让 KV 变小

操作系统背景

  • R. Arpaci-Dusseau & A. Arpaci-Dusseau, Operating Systems: Three Easy Pieces(免费在线教材),“Paging”、“Free-Space Management”(含伙伴分配器)两章。

本仓库

  • CS349D 第 3 讲:PagedAttention 与 SGLang 的 RadixAttention(基数树、LRU 叶子淘汰、缓存感知调度)。
  • CS349D 第 9 讲:Mooncake、CacheGen 和分层 KV 存储的更多模拟。
  • CS349D 前置讲 P3:操作系统基础(分页、写时复制、页面替换算法)。

16. 下一讲预告 · Next lecture

到这里,“推理系统”这一部分结束了:我们从一步 decode 的 roofline(第 1 讲)出发,讲了模型怎样改(第 2、3 讲)、怎样切到多块芯片上(第 4 讲)、prefill 和 decode 怎样分开(第 5 讲)、decode 怎样批处理和投机(第 6 讲)、KV 怎样管理(本讲)。一直有一个东西被我们当成黑盒:硬件本身——“峰值算力”和”显存带宽”从哪里来?为什么 H100 的矩阵乘法比向量运算快 15 倍?为什么 TPU 用脉动阵列?

第 8 讲打开这个黑盒:

  • GPU:SM、warp 调度、SIMT、Tensor Core 的演进(Volta → Hopper → Blackwell)、TMA 与异步拷贝、warp specialization、存储层次;
  • TPU:脉动阵列怎样让每个数据被用很多次;Jouppi et al. 2017 的测量——为什么推理(尤其是 decode 式的负载)受限于内存带宽;
  • SRAM 芯片:把权重放在片上的另一条路。

→ 第 8 讲:硬件引擎

← 返回课程目录