home/tutorial/M2 内存

Month 2 · PagedAttention

皇冠章节。这一个月学透,后面所有内容都顺。

这是 vLLM 之所以是 vLLM 的核心 idea。论文(Kwon et al., SOSP'23)的副标题是 "Efficient Memory Management for Large Language Model Serving with PagedAttention"。 注意是 Memory Management——这是一篇系统论文,不是机器学习论文。 作者用 OS 虚拟内存做类比,把 KV cache 的利用率从 ~40% 拉到 ~96%,端到端 throughput 提升 2–4×。 这一章把这套思路完整重建:从 KV cache 的数学,到 OS 分页的原理,到 vLLM 怎么把两者拼到一起。

驱动问题
在 LLM 推理时,每生成一个 token,模型每一层 attention 都要看"之前所有 token 的 K 和 V"。 把它们缓存(KV cache)下来是必须的。 问:如果给每个请求预分配 max_len × hidden_size 的连续显存来存 KV,会出什么问题?至少说 3 个。
写下你的 3 个回答,再展开
  1. 内部碎片 · 大部分请求实际 token 数远小于 max_len,未用的槽位浪费。
  2. 外部碎片 · 请求结束释放出来的"洞"未必能装下下一个声明 max_len 更大的请求。
  3. 无法共享 · 两个请求 prefix 相同(system prompt 一样),KV 没法复用。
  4. (隐藏奖励) 不能动态长度 · 同一个 batch 里如果不同请求当前长度差距大,没法 vectorize。

这四个问题——任何看过 OS 教材的人都会脱口而出:"这就是早期 OS 给进程分配内存的痛苦"。

论文里有一张真实数据:朴素分配下显存利用率仅 20–40%,因为最长 prompt 的预算统治了所有槽位。这就是这一章要解的问题。

01本章导航

这一章很长,因为内容是 vLLM 的核心。按下面的顺序走:

  1. §02 KV cache 的数学 · 先把"KV cache 到底有多大"算清楚。
  2. §03 朴素实现为什么失败 · 把驱动问题量化。
  3. §04 OS 虚拟内存复习 · 这是 vLLM 的"母概念"。
  4. §05 PagedAttention 设计 · 一对一对照映射。
  5. §06 代码深读 · KVCacheManager、BlockPool、attention kernel。
  6. §07 Prefix caching 深读 · "page cache"的现代版。
  7. §08 CPU offload / Swap · 当显存真的不够。
  8. §09 同类设计的对比 · SGLang RadixAttention、H2O。
  9. §10 动手实验 · 跑出真实数字。
  10. §11 常见误解 · 把"以为懂"挤出来。
  11. §12 本月 PR 候选
  12. §13 自检

02KV cache 到底是什么、有多大

读 vLLM 源码前,先把这个对象的大小算清楚。这一步省了,后面看 block_size 选 16 的讨论会觉得云里雾里。

2.1 数学定义

Transformer 的每一层都有自己的 K 和 V。对一个 token,每层产生一份 K 向量和一份 V 向量(每个 attention head 各一份):

     模型每一层:

     输入 token 的 embedding x  ─→  Q = x @ W_q       (这一步不需要 cache)
                                ─→  K = x @ W_k       ← 这个要 cache
                                ─→  V = x @ W_v       ← 这个要 cache

     未来 token i 算 attention:

        attn = softmax(Q_i @ K_{:i}^T / √d) @ V_{:i}
                              ─────┬─────         ┬──
                              过去所有 K        过去所有 V    ← 必须从 cache 里取

所以单个 token 在整个模型占的 KV cache 大小是:

per_token_kv_bytes = 2 × num_layers × num_kv_heads × head_dim × dtype_size
                     │   │            │              │          │
                     │   │            │              │          └ fp16 → 2 字节
                     │   │            │              └ 每个 head 的维度(通常 128)
                     │   │            └ KV 头数(GQA 模型 ≠ 注意力头数)
                     │   └ 层数(Llama 3.1 8B = 32)
                     └ K 和 V 各一份

累计到 seq_len 个 token + 一个序列时:

seq_kv_bytes = per_token_kv_bytes × seq_len

2.2 实际数字 · Llama 3.1 8B (GQA)

把 Llama 3.1 8B 代入,立刻能算:

参数来源
num_layers32config.json · num_hidden_layers
num_kv_heads8config.json · num_key_value_heads (GQA)
head_dim128hidden_size / num_attention_heads = 4096/32
dtypefp16 / bf162 字节
per_token_kv_bytes = 2 × 32 × 8 × 128 × 2 = 131072 bytes ≈ 128 KB / token

把它代入"我能装多长 / 多少并发"的问题:

显存预算能装多少 token典型并发场景
1 GB~8K tokens1 个 8K 上下文 / 4 个 2K 对话
10 GB~80K tokens40 个 2K 用户 / 8 个 10K 用户
20 GB (A10 减权重后)~160K tokens典型 7B 单卡 serving 的可用预算
💡 GQA 是省 KV cache 的大功臣
Llama 3.1 8B 有 32 个 attention head 但只有 8 个 KV head(4:1 group)。 如果是没有 GQA 的等价架构,KV cache 会是现在的 4×(每 token 512 KB)。 这就是为什么 Llama 2 → Llama 3 时 KV cache 急剧瘦身。
长 context(>100K)模型几乎全用 GQA 或 MLA(Multi-head Latent Attention,DeepSeek 用的)就是这个原因。

2.3 70B / 长 context 的麻烦

同样算法,对 Llama 3.1 70B(80 层、8 KV head、head_dim 128):

per_token_kv_bytes = 2 × 80 × 8 × 128 × 2 = 327680 bytes ≈ 320 KB / token

一个 32K 的 prompt 单序列 KV ≈ 10 GB。一张 A100 80GB 减掉权重(140 GB / 2 卡 TP = 70 GB / 卡,所以放不下;实际要 8 卡 TP,每卡承担 ~18 GB 权重),KV 预算就更紧。
所以"KV cache 利用率"这种听起来抽象的指标,直接决定了你能 serve 几个用户

03朴素实现为什么失败(把驱动问题量化)

把 §02 的数字应用到"朴素 contiguous KV",每个问题都能算出具体百分比:

3.1 内部碎片 · 量化

假设朴素策略:每个请求来时按 max_model_len = 4096 预分配。如果用户实际只生成 100 token:

分配槽位 = 4096 × 128 KB = 512 MB / 请求
实际使用 = 100 × 128 KB =  12.5 MB / 请求
利用率 = 12.5 / 512 = 2.4%

论文 Figure 2 显示真实 workload 下利用率长期在 20–40%。大部分显存在等永远不会来的 token

3.2 外部碎片

请求 A 用完 512 MB 段释放回内存池。下一个请求 B 想要 max_model_len=8192,需要 1 GB 连续段。即使总自由内存有 2 GB,分散在 4 个 512 MB 小段里,依然装不下 B。这是经典 buddy allocator 痛点

3.3 无法共享 prefix

"你是一个客服" 这种 system prompt 在 100 个并发请求里被算 100 次 KV,存 100 份。如果能共享,节省 = 100 × (system prompt 长度 × per_token_kv)。 system prompt 100 token、并发 100 时,能省 100 × 12.5 MB = 1.25 GB,几乎一整张消费级 GPU 的容量。

3.4 不能动态适配 batch 长度

同一个 batch 里请求长度参差不齐(一个 100 token、一个 3000 token)。朴素做法 = padding 到最长。3000 token 的 batch 矩阵:

(batch_size × 3000 × hidden_size) 的输入
其中 99% 是 padding,浪费的 FLOPs ≈ 99%

结论:四个问题都是数量级浪费,加起来轻松 60-80% 资源被烧。这就是 PagedAttention 之前 LLM serving 的现实。

现在反过来问
这四个问题,1960s 的 OS 设计师有没有遇到过?
答:完全一样。"程序需要的内存有限但不可预测、释放后碎片化、不同进程能共享代码段"——这正是分页虚拟内存被发明的动机
下一节我们把 OS 解法搬过来。

04OS 虚拟内存复习(必要的母概念)

如果你完全没接触过虚拟内存,下面这段速通;学过的扫一眼当复习。后面的对应才能立刻生效。

4.1 为什么发明分页

1960 年代的计算机:每个程序看到的是物理内存的连续段。运行多个程序很难,因为它们要竞争同一块物理 RAM 的不同区段。解法 = 把"程序看到的地址" 和 "实际物理位置" 解耦

程序眼里的"虚拟地址空间" 虚拟页 V0 虚拟页 V1 虚拟页 V2 虚拟页 V3 连续、易理解 页表 (page table) V0 P7 V1 P2 V2 P11 V3 P0 每进程一份,OS 维护 物理 RAM P0 (= V3) P1 (other proc) P2 (= V1) P3 P4 P5 P6 P7 (= V0) 分散、混合多进程
虚拟页连续、物理页分散,靠页表桥接。代价是每次访问要查表(TLB 缓存这个)。

4.2 关键带走的 4 点

4.3 TLB · 页表的代价

查页表本身要访问内存,每次内存访问变两次(一次查表、一次取数据)。为了不爆炸,CPU 有 TLB(Translation Lookaside Buffer)——硬件 cache,存最近用过的 V→P 映射,~64-512 项,几个 cycle 命中。TLB miss → 重新走页表 → 慢 ~100 cycle。

这一点要记住:indirection 是有成本的。后面我们会看到 vLLM 在 GPU 上"模拟 TLB"的方式(其实是直接把整个 block table 塞进 kernel 的常量参数)。

4.4 COW Fork · prefix caching 的祖宗

Unix fork() 复制整个进程。复制 4GB 地址空间太慢,所以现代实现是 copy-on-write:父子进程的页表初始指向同一份物理页,标记只读;任一方写时才真的复制。

这就是 prefix caching 的原型:两个请求 prompt 相同时,它们的 block table 指向同一份物理 KV,谁开始生成不同的 token 谁分裂出新 block。我们在 §07 详细看。

💡 速补虚拟内存
没系统学过的,直接读 OSTEP 第 13、15、18、21、22 章(共 ~80 页)。 免费在线。 这是 6 个月里你需要"啃书"的唯一章节。MIT 6.S081 lecture 4 是视频替代。

05PagedAttention:把上面这套搬到 KV cache

vLLM 的做法结构上完全平行

Sequence A · 逻辑块 "Hello, how are you ..." L0 · tokens 0-15 L1 · tokens 16-31 L2 · tokens 32-39 A 的 block table L0 → P3 L1 → P7 L2 → P1 Sequence B · 逻辑块 "Hello, how are you ..." 同 prompt L0 · tokens 0-15 L1 · tokens 16-31 L2 · tokens 32-? B 的 block table L0 → P3 ★ L1 → P7 ★ L2 → P5 ★ A 和 B 的 prefix 相同 ⇒ block 共享 GPU 显存 · 物理块池 P0 P1 ← A:L2 P2 P3 ← A:L0 + B:L0 ★ P4 P5 ← B:L2 P6 P7 ← A:L1 + B:L1 ★ P8 P9 (free)
两个序列、各自的 block table、共享物理块池。注意 P3/P7 被两个序列同时引用——这就是 prefix caching。

5.1 把 OS 概念逐行映射到 vLLM

OSvLLM说明
virtual address spacesequence 的 token 序列程序眼中的"连续"
virtual page (4KB)logical block (16 tokens)固定大小切片
physical framephysical block显存里实际的存储槽
page tableblock table每 sequence 一份,记录 L → P 映射
page fault新 token 跨入下一个 block 时分配vLLM 是"懒分配"
shared page (COW)prefix cache 命中的共享 block"复制时分裂"做法不同
page replacement (LRU)prefix cache eviction见 §07
swap to diskswap to host RAM / NVMe见 §08
TLB(没有专门的——block table 直接在 GPU)查找开销直接在 kernel 里
kmalloc / buddy allocatorBlockPool(free list + ref count)分配粒度变粗(一块 = 16 token KV,几十-几百 KB)

5.2 一个 physical block 物理上是什么

vLLM 把整个 GPU 显存的 KV 区域看成一个大 tensor,按 block 切:

kv_cache = torch.zeros(
    num_blocks,          # 池里有多少块
    block_size,          # 一块装多少 token(16)
    num_kv_heads,        # 8
    head_dim,            # 128
    dtype=torch.float16,
)
# K 和 V 各一份,所以模型每层有两个这样的 tensor
# shape 在新版可能略不同(layout: NHD vs HND),但大小一样

从这个 layout 你可以反算单块大小

block_bytes = block_size × num_kv_heads × head_dim × dtype_size × 2_for_KV
            = 16 × 8 × 128 × 2 × 2 = 65536 bytes = 64 KB / block / layer
全模型一块:
            = 64 KB × 32 layers = 2 MB / block / sequence_token-coverage_16

所以 vLLM 启动时打的日志 GPU KV cache size: X tokens,X = num_blocks × block_size。从此你看到这个数字会立刻能解读

5.3 Block size 为什么是 16

这是 PagedAttention 论文 §6.4 实验出来的 sweet spot。trade-off:

太小(如 4)

  • ✅ 内部碎片更少
  • ❌ Block table 太大,序列越长 lookup 越多
  • ❌ Attention kernel 每次 gather KV 的 block 数量增加 4×
  • ❌ Prefix cache hash 粒度太细,命中率低(短 prompt 共享不到)

太大(如 256)

  • ✅ Block table 短,kernel 干净
  • ❌ 内部碎片回来了:用户只生成 17 token 就 EOS,浪费 239 个槽位
  • ❌ Prefix cache 命中粒度太粗(短 prefix 用不上)
  • ❌ Preemption 颗粒变大(要释放就一次释放 256 token)

论文图 18 显示 4/8/16/32 几乎相当,64+ 开始掉。16 是最甜的点,对 16-token Llama tokenizer 的 BPE 分布也对得齐(平均一个英文词 4 token,约 4 个词 = 一句话短语)。

5.4 attention kernel 怎么用 block table

读到这里你应该好奇:朴素 attention 一个 torch.matmul(Q, K.transpose(-1,-2)) 就完事,要求 K 是连续 tensor。如果 K 散在 16 块物理 block 里,怎么矩阵乘?

答案:kernel 内部 gather。论文叫 "PagedAttention kernel"。伪代码:

# 每个 query token 一个 thread block
# 任务:算这个 token 对过去所有 KV 的 attention

q = load_query()                       # 一次性加载(query 只有 1 个 token)
running_max = -inf
running_sum = 0
out = 0

for logical_block_idx in range(my_seq_len // 16):
    # 关键的间接寻址:查 block table
    physical_block_idx = block_table[seq_id, logical_block_idx]

    # 从物理块加载 16 个 token 的 K 和 V
    K_block = kv_cache_K[physical_block_idx]   # shape (16, n_kv_heads, head_dim)
    V_block = kv_cache_V[physical_block_idx]

    # 算这块的 attention scores
    scores = q @ K_block.T / sqrt(d)           # shape (16,)

    # online softmax 累加(FlashAttention 的核心 trick,见 M4)
    block_max = max(scores)
    new_max = max(running_max, block_max)
    correction = exp(running_max - new_max)
    running_sum = running_sum * correction + sum(exp(scores - new_max))
    out = out * correction + (exp(scores - new_max) @ V_block)
    running_max = new_max

return out / running_sum

关键就是 block_table[seq_id, logical_block_idx] 这一行 indirection。它把"逻辑上连续的 KV"翻译成"物理上分散的 block 号"。这就是 OS 页表在 GPU 上的样子

💡 为什么 prefill 不用 PagedAttention(多数情况)
Prefill 时整个 prompt 在一个 forward 里算,K 和 V 本来就是连续生成的,没分散问题。 所以 prefill 通常调 FlashAttention 这类标准 kernel。
Decode 才是 PagedAttention 大展身手的地方——前序 KV 早就散在 64 MB 的 block pool 里了。

06代码深读 · KV cache 子系统

下面的 anchor 是这个月的主菜。按顺序读,期间随时回头看上面的图和数字。5 段加起来 ~10-12 小时,分 3-4 天完成。

6.1 第一遍 · 整体结构

读 ① · 2 小时 · 入口
这是 v1 引擎里 KV cache 的总管。先不深究算法,只看 KVCacheManager 类的方法签名:
  • allocate_slots(request, num_tokens) → KVCacheBlocks — 给一个 request 在这一步多要几个 token 的槽
  • free(request) — 请求结束,释放它的所有 block
  • get_computed_blocks(request) — prefix cache 命中查询
  • cache_blocks(request, num_tokens) — 把已经算完的 block 注册到 prefix cache
注意 v1 把 KV cache 的"账本"完全留在 scheduler 进程里(CPU 侧),worker 拿到的只是"用第几块"的指示。这是 v0 → v1 一个重要重构。
"allocate" 在 vLLM 里是什么动作?跟 malloc 区别在哪?(答:它不分配显存,只是登记"哪几块归你"。显存在启动时就分配好了。)

6.2 第二遍 · BlockPool

读 ② · 2 小时 · 块池
实际的 free list + hash-based 共享。重点看:
  • BlockPool.__init__ — 一次性建出所有 num_blocksKVCacheBlock。注意这是 Python 对象,不是显存(显存在 worker 上预分配)。
  • self.free_block_queue — 一个双向链表(FreeKVCacheBlockQueue),实现 O(1) 的 pop_head / append。LRU 用的。
  • self.cached_block_hash_to_block — dict[hash → block],prefix cache 的查找表。
  • get_new_blocks(num) — 从 free_queue 头部取若干块。
  • free_block(block) — 引用计数减一,到 0 就放回 free_queue。
  • get_cached_block(hash) → block | None — 看 prefix 是否已被缓存。
两个 sequence 怎么发现它们 prefix 相同?hash 怎么算的(搜 hash_block_tokens)?

关键发现:free 不等于丢弃。一个 block 引用计数到 0、回到 free_queue 之后,如果还没被覆盖,它依然在 cached_block_hash_to_block 里——下次同 prefix 进来命中,可以从 free 列表里"救回"它。这就是 prefix cache 在 free 状态下仍然能命中的原因。

6.3 第三遍 · Hash 怎么算

Prefix cache 的命中靠 hash。同样的 token 序列必须 hash 到同一个 key。关键代码:

# 类似实现(具体函数名以源码为准,搜 hash_block_tokens)
def hash_block_tokens(parent_block_hash, token_ids):
    """链式 hash —— 一块的 hash 依赖上一块的 hash。

    这样保证: 只有"从开头到这块"完全相同的两个序列才会撞 hash。
    避免: 只是"中间一段相同"被错误命中。
    """
    return hash((parent_block_hash, tuple(token_ids)))

这是chain hash,跟 Merkle tree 的思路一样。每一块的指纹包含了它的父块指纹,保证 prefix 一致性。

思考
如果不用 chain hash,只 hash 当前块的 token,会出什么问题?
:序列 A = "你好世界" 和 B = "再见世界" 的最后一块都是 "世界",会被错误地共享 KV。 但 "世界" 在 A 和 B 的语境中的 attention 应该不一样(因为前面 K 不同),共享后输出会错。

6.4 第四遍 · 每序列的"翻页"逻辑

读 ③ · 1.5 小时 · 序列状态
vllm/v1/core/kv_cache_utils.py 或 single_type_kv_cache_manager.py
每个序列的 block table 是怎么存的。看每次新增 token 是怎么决定 "需不需要再申请一块"。 关键逻辑:
# 伪代码
def append_token(request, new_token):
    request.token_ids.append(new_token)
    # 当前 token 数已经填到第几块的第几格?
    num_blocks_needed = ceil(len(request.token_ids) / block_size)
    while len(request.block_ids) < num_blocks_needed:
        new_block = block_pool.get_new_blocks(1)[0]
        request.block_ids.append(new_block.id)
block size 是 16 token,意味着每 16 个 token 才会"翻页"。这个数字为啥不能更大或更小?(回看 §5.3)

6.5 第五遍 · 走到 GPU 端

读 ④ · 1 小时 · worker 接口
Worker 怎么把 block table 喂给 attention kernel。搜 block_tableslot_mapping 这两个变量。
  • block_table · 形状 (num_seqs, max_num_blocks),每行是一个序列的 logical → physical 映射。
  • slot_mapping · 形状 (total_tokens,),对这一步要写入的每个新 token,给出"该写到哪个 (physical_block, offset_in_block)"。
attention kernel 拿到一个 query token 后,怎么找到它对应的所有 KV?这两个变量分别答了什么问题?(block_table 答"读哪里",slot_mapping 答"写哪里")

6.6 attention kernel 端绑定

读 ⑤ · 1.5 小时 · 走到 CUDA
这个 kernel 是 vLLM 自己写的 paged-attention kernel。不要试图全部看懂,看以下几点:
  • kernel 签名里有 block_tablesseq_lens 参数
  • 外层循环遍历 KV 的 block
  • 每次循环用 block_tables[seq_idx][block_idx] 算出物理块的内存地址
  • 从该地址 load K, V 到 shared memory,做 dot product
  • online softmax 累加(这是 FlashAttention 的核心,M4 详讲)
这就是 §5.4 的伪代码在 CUDA 里的真身。看不懂细节没关系,认出结构就够

07Prefix Caching · "page cache" 的现代版

OS 里 page cache 是:"读过的磁盘块缓存在内存,下次再读直接命中"。 vLLM 里 prefix cache 是:"算过的 prompt 的 KV 缓存在显存,下个共享 prompt 的请求直接命中"

7.1 命中的时机线

请求 1 prompt:  [system: 你是一个客服] + [user: 怎么退货?]
                ─────────────── 16 tokens ───────────────  +  ─── 5 tokens ───
请求 2 prompt:  [system: 你是一个客服] + [user: 怎么改地址?]
                ─────────────── 16 tokens ───────────────  +  ─── 6 tokens ───

   前 16 tokens 的 KV 完全相同
   →  请求 1 算完后,把 P3 这块标记为可共享 (cache_blocks 写入 hash 表)
   →  请求 2 进来 hash 一对:命中!直接引用 P3,跳过 prefill 这一段
   →  延迟降低(少算 16 token 的 prefill)、显存少占(少存 16 token 的 KV)

7.2 三种状态

每个 block 在生命周期里有三种状态:

状态ref_count在 hash 表?能被 reuse 吗
USED (in-flight)≥1能被命中(share)
EVICTABLE (free 但还热)0能命中("救回");也能被新请求覆盖
FRESH (新分配 / 已被覆盖)≥1计算前不能命中

关键设计:EVICTABLE 状态让 prefix cache 的"命中率"不依赖于请求并发。即使两个共享 prefix 的请求错开到达(请求 1 已经结束、请求 2 后来),只要中间没把 P3 覆盖掉,请求 2 还能命中。

7.3 Eviction 策略

显存有限。free_queue 是双向链表,head 是最近释放的、tail 是最久没用的。取新块从 head 取(LRU 的 fresh-first);如果不够,从 tail 强制 evict(覆盖掉旧的 EVICTABLE block)

这和 OS page cache 完全是同一个算法(Linux 用的是 active/inactive LRU 链表,思路相同)。差别只在粒度(4KB vs 16 token KV)。

7.4 OS page cache vs vLLM prefix cache · 完整对照

OS page cachevLLM prefix cache
cache key(file inode, offset)chain hash(token sequence)
命中粒度4KB 页16 tokens 块
失效写文件、删除显存压力下被 evict(LRU)
读延迟收益SSD → 几 µs跳过整个 prefill 段,几十-几百 ms
命中前提同一文件 + offset同 prefix (chain hash 一致)
写回机制dirty bit + flush不需要(KV 是 cache 不是 store)
典型命中率~80% (热数据)~30-70% (取决于 workload,system prompt 共享多则高)

7.5 限制:只能命中 block-aligned prefix

vLLM 的 prefix cache 是整块粒度。如果两个 prompt 在第 17 个 token 才分叉(第 16 个 token 之后),只能共享前 16 token 的那一块(因为 chain hash 是按 16-token 块算的)。第 17 token 那块的 hash 已经因为该 token 不同而完全不同。

SGLang 的 RadixAttention 用细粒度 radix tree 解了这个问题——但代价是 lookup 更慢、实现更复杂。当 prompt 共享是动态分叉(agent 多分支推理)时 SGLang 优;当 prompt 共享是固定 system prompt(90% serving 场景)时 vLLM 的简单方案足够。

08CPU Offload / Swap · 当显存真的不够

分页解决了"碎片",prefix 共享解决了"重复"。但如果新请求来了、所有可 evict 的 block 都已经被 evict、仍然不够呢?两条路:

  1. Preempt(M3 主题)—— 把某个 RUNNING 请求挤出去,让新请求/高优先级请求进来
  2. Swap(本节)—— 把不活跃的 block 从 GPU 显存搬到 host RAM 或 NVMe,腾出空间

8.1 Swap 的工程现实

层级带宽典型容量swap 一块 (64 KB × 32 layer = 2MB) 用时
GPU HBM ↔ Host RAM (PCIe Gen4)32 GB/s无限大~62 µs
GPU HBM ↔ Host RAM (PCIe Gen5)64 GB/s无限大~31 µs
GPU HBM ↔ NVMe (Direct Storage)~14 GB/s巨大~140 µs

看似还行。但 swap 是全模型那么大:一个序列 4K context 大约 4096/16 = 256 块 × 2 MB = 512 MB。swap 一个 4K 序列 ~16 ms,**比一次 decode step (~10ms) 还慢**。所以 swap 必须提前异步,否则 decode 直接 stall。

8.2 代码入口

读 · CPU offload
vllm/v1/worker/ · 搜 swap_in / swap_out / cpu_offload
vLLM 的 swap 实现散在 worker 端(实际做 GPU↔CPU 拷贝)和 scheduler 端(决定何时 swap)。v1 把这部分简化了不少 —— 默认 preempt 走 recompute,swap 是可选项。
Recompute 跟 swap 各适合什么场景?(短请求 recompute 划算;长请求且显存压力是阵发的,swap 更省。M3 会重新讨论。)

09同类设计的对比 · 横向看 KV 管理

把 PagedAttention 放回大家族里:

设计核心思路命中粒度典型场景
朴素 contiguous每请求预分配 max_len 连续段HuggingFace generate(), 早期 Triton Inference
PagedAttention (vLLM)固定块 + block table,OS 分页类比block (16 token)大部分线上 serving,system prompt 共享强
RadixAttention (SGLang)Radix tree 索引 token 序列token 级多分支推理、Tree of Thoughts、长 agent 对话
H2O eviction只保留 heavy-hitter token 的 KV,其他丢token 级 (drop)极长 context 容量受限场景,可接受少量精度损失
StreamingLLM固定 sliding window + attention sinktoken 级 (drop)无限 context 流式输出,对早期 token 不依赖
Mooncake (Moonshot)把 KV cache 跨节点共享 (分布式 KV)块级大集群多 host serving

把这张表收藏 —— 看新论文时先定位"它在哪一行"。Prompt 看不出来? 看它怎么处理 KV cache,方向立刻明白。

10动手 · 复现碎片对比

这是本月的硬核作业。跑通它,PagedAttention 就刻进肌肉里了。

10.1 实验设计

  1. 租 A10 (24 GB),跑 Llama 3.1 8B fp16,max_model_len = 4096。
  2. 启动 vLLM 时打开日志:能看到 GPU KV cache size: X tokensMaximum concurrency: Y。把这两个数字记下来。
  3. benchmarks/benchmark_serving.py 跑三组 workload:
    • A · 短输出:100 个请求,prompt 50 tokens,max_tokens=10
    • B · 长输出:100 个请求,prompt 50 tokens,max_tokens=2000
    • C · 共享 prompt:100 个请求,prompt = 同一个 200 token system prompt + 不同 20 token 短问 ,max_tokens=100
  4. 对每组记录:throughput (tok/s)、TTFT (time to first token p50/p99)、平均显存利用率(看 nvidia-smi 或 vLLM metrics)。
  5. 再跑一遍 C 但加 --no-enable-prefix-caching,看 throughput 差多少。

10.2 命令样例

# Terminal 1: 起 server
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.85 \
  --enable-prefix-caching

# Terminal 2: benchmark
python benchmarks/benchmark_serving.py \
  --backend vllm \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sonnet \
  --dataset-path benchmarks/sonnet.txt \
  --num-prompts 100 \
  --request-rate 10 \
  --save-result

10.3 期待的数字

✓ 应该看到
  • A 比 B 在 throughput 上高 3-5×(B 显存压力大、动态分配 + eviction 多)。
  • C 跟 A 类似的 throughput,但 prefix cache hit rate > 80%(看 vLLM metrics 里 prefix_cache_hits)。
  • C 关掉 prefix caching ⇒ throughput 砍半,TTFT 升 2-3×。
数字对不上?回去看你的 prompt 是不是真的有共享 prefix。

10.4 进阶 · 看 metrics

vLLM 暴露 Prometheus metrics(默认开在 :8000/metrics):

curl http://localhost:8000/metrics | grep vllm
# vllm:num_requests_running
# vllm:num_requests_waiting
# vllm:gpu_cache_usage_perc
# vllm:cpu_cache_usage_perc
# vllm:num_preemptions_total
# vllm:prefix_cache_hits_total
# vllm:prefix_cache_queries_total

这些指标值得每个一看。prefix_cache_hits_total / queries_total = 命中率,这是你下次调参的关键数字。

11常见误解 · 把"以为懂"挤出来

"PagedAttention 就是一个新的 attention 算子"
错。PagedAttention 是 kernel + memory layout 的协同设计。 Kernel 是普通的 attention,唯一区别是它能从非连续地址聚集 K 和 V。 真正的 idea 在 memory layout(block table 那一层 indirection),不在数学。 去掉 indirection 这层、kernel 还是同一个 attention。
"block size 越小越好,碎片越少"
错。block 太小 → block table 太大、attention kernel 的 gather 循环变长、prefix cache 命中粒度太细。 16 是 vLLM 实验出来的 sweet spot。如果你的 workload 平均输出极短(<16 token),可以试 8; 长 context 用 32 也无伤大雅。
"prefix caching 总是开着是赚的"
几乎是,但代价是 hash + 引用计数的 CPU 开销。 在 prompt 完全没重复的场景(个性化高的 chat),收益接近 0、开销仍在。 vLLM 默认开是因为大部分线上场景有 system prompt。 高度个性化的场景(每用户独立 prompt template)可以关掉省 CPU。
"GPU memory utilization 高 = throughput 高"
不严格成立。memory utilization 是占用不是有效使用。 所有 block 都被占着但 90% 是 idle 的低优先级请求 = 占用 100%、吞吐 = 0。 更准的指标是 num_requests_runninggpu_cache_usage_perc,搭配 throughput 看。
"vLLM 的 swap 跟 OS 的 swap 一样"
几乎,但 vLLM 的 swap 粒度是整个序列的所有 block(要么全留要么全走),不像 OS 可以单页 swap。 原因:attention 必须看到完整 KV,不能用一半。 一些研究系统(FlexGen)做了"按 token 分片 swap",但工程复杂,主流 vLLM/SGLang 还没采用。
"GQA / MLA 让 PagedAttention 没意义了,KV 已经够小"
反了。GQA 让单序列 KV 变小 = 一张卡能装更多并发 = 调度复杂度上升 = PagedAttention 更重要。 MLA (DeepSeek) 把 KV 进一步压缩到 latent 维度,块大小也跟着变,但 paged 思路不变。

12本月 PR 候选方向

Month 2 的 KPI 是一个内存子系统相关的 PR。难度梯度:

12.1 入门(500-行内,1-2 周)

12.2 中等(一两周 + 设计讨论)

12.3 高难(vLLM RFC 级,1+ 月)

💡 找 issue 的诀窍
Issue tracker 用:is:open is:issue label:kv-cachelabel:prefix-caching
按 most-commented 排,前 3 页通常是大家都觉得该做但没人开始的。挑一个评论里 "good first issue" / "help wanted" 的,主动到 Slack 的 #vllm-dev 频道认领。

13本页自检 · 这是个长清单

Month 2 结束时这些应该全部 ✓

14延伸阅读