大数跨境

手搓 KV Cache:从静态预留到 PagedAttention 的页表

手搓 KV Cache:从静态预留到 PagedAttention 的页表 AI 原力注入
2026-11-09
3
导读:手搓系列第四篇:用纯标准库 Python 写一个 KV Cache 显存管理模拟器,静态预留、动态连续区、分页三种策略实测对照。同一份预算,静态预留只服务 8 条请求(利用率 20%),分页放进 39

 

手搓系列导航

这个系列做一件事:把框架里「拿来就用」的机制,用最小可运行的代码搓出来。每个主题一段 numpy 或 Python(百行级),不依赖框架内部接口,跑得动、算得清、能复现,配套手绘图把机制画开。读的时候建议开着代码对照正文,每篇的数字都来自实测。

阅读顺序按依赖递进:

#
篇目
搓的是什么
实测产出
1
手搓 Tokenizer:从字符级到 BPE
子词切分怎么长出来
压缩曲线与过拟合实测,tiktoken 同语料对照
2
手搓 RoPE:一百行 numpy 讲透旋转位置编码
旋转位置编码与外推
配对约定差 3.385、相对不变性 3e-13、三种外推策略频率谱
3
手搓 Attention:从三行循环到看懂 FlashAttention
注意力及其 IO-aware 改写
四版递进实跑,从 3 行朴素版到分块 online softmax
4
手搓 KV Cache:从静态预留到 PagedAttention 的页表
KV 显存管理与分页
模拟器实测三种策略:碎片、入住失败与 5 倍容量的数字来源
5
手搓 MLA:从 MHA 到 weight absorption
KV 的 latent 压缩与吸收变换
真实权重上吸收等价性 fp32 逐位成立;KV 压到同形状 MHA 的约 1/57

前四篇搭出「一个 Transformer 怎么算、怎么存」,第五篇开始动缓存的形状。


 

 

手搓 KV Cache:从静态预留到 PagedAttention 的页表

本站手搓 Attention那篇算的是 attention 的计算账:分数矩阵为什么不能落显存、online softmax 怎么滚。这篇算另一本账,存储账:算过的 K/V 得留在显存里给后续 token 用,而这些中间状态怎么放,直接决定一块卡同时能伺候多少条请求。同样的显存预算,管理策略从「按最大长度预留」换成「按页分配」,容量实测差 5 倍。这也是手搓系列的第四篇,三个版本递进,每一组数据都是真机实跑出来的。

核对基准:PagedAttention(arXiv:2309.06180)的利用率口径;页大小 16 取 vLLM GPU 默认(本地 vLLM 快照 commit 8b365ff9,vllm/_custom_ops.py:55 核对)。离散事件模拟器为纯标准库 Python(脚本已入库同目录 hand_roll_kv_cache.py):显存预算 8528 个 token 槽位(恰好装下第一批入驻序列,消除尾部空白干扰);60 条序列长度按高斯分布生成(seed 42,均值 228,最长 593);页 16 token/页。模拟器只管「token 槽位怎么分配」,不模拟张量本身的计算。

一、这本账在算什么

自回归生成时,每生成一个 token,它的 K 和 V 就要留在一个可查询的地方:后续每个新 token 的 attention 都要读全部历史的 K/V。这笔缓存的增长是线性的:序列越长、批次越多,占用越大。具体的「每 token 多大」公式与真实模型数字(如 Llama-2 70B 每 token 约 320 KB),KV Cache 原理简介和PagedAttention 讲解都算过,不重复。

源码:https://github.com/ForceInjection/AI-fundamentals/blob/main/09_inference_system/kv_cache/01_concepts/01_basic/03_hand_roll_kv_cache.py

这篇关心的是更底层的一个问题:这些 K/V 在显存里怎么摆放。它看起来是个工程细节,实际决定了同样的卡能同时跑几条请求。手搓一个管理器,三种策略各跑一遍模拟,用同一组预算和负载对照。

KV Cache 随生成逐 token 追加
KV Cache 随生成逐 token 追加

二、版本 0:静态预留,按 max_len 一口价

最简单的管理策略:每条请求来了,按最大长度(max_len = 1024)一口价预留。


   
   
   
   
    
   
   
   
   capacity = TOTAL_SLOTS // MAX_LEN      # 8528 // 1024 = 8 条

模拟器实测:预算 8528 个槽位,只放得下 8 条请求。而 60 条序列的实际长度均值只有 228:每条预留的 1024 个槽位里,平均只有两成多真的被用到。8 条序列合计预留 8192 槽位,实际占用约 1600,平均利用率 20%。

静态预留:每条按 max_len 一口价,锯齿形的浪费
静态预留:每条按 max_len 一口价,锯齿形的浪费

剩余 52 条请求全部被拒。浪费的根源:为「可能永远不会到的长度」提前占了坑。

好书推荐:

三、版本 1:动态连续区,以及外部碎片

改进思路:按需增长。请求先占一小段,生成多少占多少,但要求占的是连续槽位(这样序列的 K/V 在显存里是一整块,地址好算)。

模拟器跑一个更接近真实服务的三阶段场景:40 条序列入驻(占去 8496 槽位);然后 20 条完成使命被释放(隔一放二地释放,模拟请求陆续结束);最后一条 800 token 的新序列想入驻。实测结果:

  • • 总空闲 4810 槽位,远大于新序列要的 800;
  • • 但最大的一块连续空洞只有 359 槽位;
  • • 入住失败。

这就是操作系统课上见过的外部碎片:空闲是够的,但被在住序列切成了棋盘状的小块,每一块都单独不够用。PagedAttention 讲解里那个「浏览器、IDE、终端共挤 4 GB 内存」的类比说的就是它,这里我们用数字把它跑了出来:释放越随机、请求长度越参差,棋盘越碎。

外部碎片:总空闲够,最大连续块不够
外部碎片:总空闲够,最大连续块不够

四、版本 2:分页,用页表绕开连续性

既然病根是「要求连续」,那就不要连续。把显存切成固定大小的页(16 token/页,vLLM 的 GPU 默认口径),每条序列维护一张块表(block table):逻辑上第 i 页的内容存在哪个物理页,查表即得。新序列要 800 个槽位?凑 50 个空闲页就行,页在显存的哪个位置无所谓。

实测同样的三阶段场景:新序列成功凑齐 50 页入驻;在住 20 条序列的页内浪费(最后一页没填满的部分)合计 101 槽位,占实际占用 3760 的 2.7%。对比一下:外部碎片是不定型的(取决于释放模式,极端时丢一半容量),页内浪费是有上界的(每条序列最多浪费 15 个槽位,2.7% 是这次实测值)。

block table:逻辑页到物理页的离散映射
block table:逻辑页到物理页的离散映射

容量账也随之改变:不再按 max_len 预留,同样 8528 槽位的预算,静态预留只服务 8 条,分页实测放进 39 条(第 40 条按页取整后差 1 个页没进;页取整的代价后文有账),约 5 倍。论文里的说法是「有效利用率从 20-40% 提升到接近 100%」:模拟器的 20% 对 97.3%,正落在同一个区间。

容量对比:静态预留 8 条 vs 分页 39 条
容量对比:静态预留 8 条 vs 分页 39 条

五、对照真实系统

模拟器与真实系统的三个对账点:

项目
模拟器实测
真实系统
静态预留利用率
20%
PagedAttention 论文口径 20-40%
分页后浪费
2.7%(页内)
「接近 100%」有效利用
页大小
16
vLLM GPU 默认 16(vllm/_custom_ops.py:55)

模拟器省略了很多真实复杂度:没有 prefill/decode 两阶段的分配节奏,没有前缀共享(多条请求共用同一段系统提示词的页,进一步省显存),没有页的换入换出。但「分页消灭外部碎片、页内浪费有上界」这个核心结论,与真实系统一致。

配套代码:

 


    
    
    
    
     
    
    
    
    # 手搓 KV Cache 管理器:静态预留 -> 动态连续区 -> 分页
# 配套文章:hand_roll_kv_cache.md

# 运行:python hand_roll_kv_cache.py(纯标准库;无需 GPU)

# 用一个离散事件模拟器实测三种显存管理策略,验证文章的每一笔账:

#   版本 0  静态预留(按 max_len 一口价):容量小、利用率低

#   版本 1  动态连续区:省显存但释放后产生外部碎片,新序列入住失败

#   版本 2  分页(block=16,vLLM GPU 口径):无外部碎片,页内浪费 ~3%

import
 random

random.seed(42)
MAX_LEN = 1024               # 单序列最大长度(静态预留口径的上限)
MEAN_LEN = 220               # 序列实际长度的均值(长尾分布)
BLOCK = 16                   # 页大小(vLLM GPU 默认口径,vllm/_custom_ops.py:55)

# 生成 60 个序列请求:长度 32~MAX_LEN,均值约 220

lens = [min(MAX_LEN, max(32, int(random.gauss(MEAN_LEN, 160)))) for _ in range(60)]
lens_head = lens[:40]
# 显存预算恰好装下前 40 条入驻序列(消除尾部大片空白,让碎片现象真实显形)

TOTAL_SLOTS = sum(lens_head) + 32
print
(f"60 个序列:均值 {sum(lens)/len(lens):.0f} tokens,最长 {max(lens)},总需求 {sum(lens)}")
print
(f"显存预算 {TOTAL_SLOTS} token 槽位,页大小 {BLOCK}\n")


# ---------- 版本 0:静态预留(按 max_len 一口价) ----------

def
 run_v0():
    capacity = TOTAL_SLOTS // MAX_LEN
    reserve = capacity * MAX_LEN
    used = sum(min(l, MAX_LEN) for l in lens[:capacity])
    return
 capacity, reserve, used


# ---------- 版本 1:动态连续区(三阶段:入驻 -> 释放 -> 再入驻) ----------

def
 run_v1():
    slots = [None] * TOTAL_SLOTS
    seqs = {}
    sid = 0
    for
 l in lens_head:                        # 阶段一:按到达顺序连续入驻
        run, start = 0, -1
        for
 i, v in enumerate(slots):
            run = run + 1 if v is None else 0
            if
 run == l:
                start = i - l + 1
                break

        if
 start >= 0:
            for
 i in range(start, start + l):
                slots[i] = sid
            seqs[sid] = start
            sid += 1
    n_phase1 = sid
    freed = [k for k in list(seqs) if k % 2 == 0]      # 阶段二:隔一放二地释放
    for
 k in freed:
        for
 i, v in enumerate(slots):
            if
 v == k:
                slots[i] = None
        del
 seqs[k]
    total_free = slots.count(None)
    largest_gap, run = 0, 0
    for
 v in slots:
        run = run + 1 if v is None else 0
        largest_gap = max(largest_gap, run)
    need = 800                                  # 阶段三:新序列想入住
    return
 n_phase1, len(freed), total_free, largest_gap, need, largest_gap >= need


# ---------- 版本 2:分页(block table 离散映射,同样三阶段) ----------

def
 run_v2():
    free_blocks = TOTAL_SLOTS // BLOCK
    pages, seq_len = {}, {}
    sid = 0
    for
 l in lens_head:                        # 阶段一:按页入驻
        need = (l + BLOCK - 1) // BLOCK
        if
 need <= free_blocks:
            pages[sid] = need
            seq_len[sid] = l
            free_blocks -= need
            sid += 1
    n_phase1 = sid
    freed = [k for k in list(pages) if k % 2 == 0]     # 阶段二:释放
    for
 k in freed:
        free_blocks += pages.pop(k)
        seq_len.pop(k)
    need = 800                                  # 阶段三:新序列按页凑
    need_blocks = (need + BLOCK - 1) // BLOCK
    ok = need_blocks <= free_blocks
    wasted = sum(pages[k] * BLOCK - seq_len[k] for k in pages)
    occupied = sum(pages[k] * BLOCK for k in pages)
    return
 n_phase1, len(freed), free_blocks * BLOCK, ok, wasted, occupied


if
 __name__ == "__main__":
    capacity, reserve, used = run_v0()
    print
("== 版本 0:静态预留 ==")
    print
(f"容量只有 {capacity} 条({TOTAL_SLOTS}/{MAX_LEN});平均利用率 {used/reserve:.0%}")

    print
("\n== 版本 1:动态连续区(三阶段)==")
    n1, freed1, total_free, largest_gap, need, ok1 = run_v1()
    print
(f"入驻 {n1} 条,释放 {freed1} 条;一条 {need} token 的新序列想入住:")
    print
(f"  总空闲 {total_free} 槽位(足够),最大连续空洞只有 {largest_gap} 槽位"
          f" -> {'成功' if ok1 else '入住失败(外部碎片)'}"
)

    print
("\n== 版本 2:分页(block=16,同样三阶段)==")
    n2, freed2, free2, ok2, wasted, occupied = run_v2()
    print
(f"入驻 {n2} 条,释放 {freed2} 条;同样 {need} token 新序列:"
          f"空闲 {free2} 槽位,离散页随便挑 -> {'成功' if ok2 else '失败'}"
)
    print
(f"在住序列页内浪费 {wasted} 槽位,占实际占用 {occupied} 的 {wasted/occupied:.1%}")

    print
(f"\n容量对比:静态预留 {capacity} 条 vs 分页 {n2} 条"
          f"({n2/capacity:.1f} 倍),且分页无外部碎片"
)

 

六、总评

三种管理策略递进下来,分页的价值是一笔容量账:同样 8528 槽位的预算,静态预留服务 8 条,分页放进 39 条,约 5 倍。外部碎片的危害也被数字跑实:总空闲 4810 槽位,最大连续块只有 359,一条 800 token 的请求进不来。页内浪费 2.7% 有上界,与 PagedAttention 论文「有效利用率从 20-40% 提升到接近 100%」的口径落在同一区间。

这些判断的适用边界:

  • • 全部结论来自只模拟「token 槽位怎么分配」的离散事件模拟器:无 prefill/decode 分配节奏、无前缀共享、无换入换出
  • • 「8 条 vs 39 条、利用率 20% vs 97.3%」依赖 seed 42、60 条高斯长度、8528 槽位这一组场景;换分布数字会变,「约 5 倍」不是普适比值
  • • 外部碎片的程度取决于释放模式(§三自述「释放越随机、请求长度越参差,棋盘越碎」);359/4810 是单次场景值
  • • 页大小 16 取自 vLLM GPU 默认口径(vllm/_custom_ops.py:55);页内浪费上界随页大小变化,其他引擎口径不同

生产环境用 vLLM、SGLang 的现成实现,它们的 BlockSpaceManager 还处理了页共享、写时复制、驱逐与换出,都是这篇模拟器没有的。手搓的价值在于:现在读 vLLM 的 block_table、num_gpu_blocks 这些字段,对应关系是清楚的:每条请求的 block table 就是模拟器里那张页表,num_gpu_blocks 就是那个总预算。

相关几篇文章:KV Cache 原理简介讲缓存为什么必须存在;PagedAttention 讲解有完整的 OS 类比与交互可视化;前缀共享的展开见 KV 复用目录;手搓 Attention是配套的计算账。

源文件索引

来源
引用点
Kwon et al., 2023(arXiv:2309.06180)
有效利用率口径(§五对账)
vLLM vllm/_custom_ops.py:55(快照 8b365ff9)
页大小 16 的默认口径(§五)
本仓:03_hand_roll_kv_cache.py
三种策略的模拟器实现与全部实测数字

参考资料

  • • Kwon et al., 2023, Efficient Memory Management for Large Language Model Serving with PagedAttention(arXiv:2309.06180):分页管理的原始论文
  • • vLLM 仓库:https://github.com/vllm-project/vllm(页大小 16 的实现见 vllm/_custom_ops.py)
  • • KV Cache 目录:从原理到分布式管理的 60+ 篇进阶内容

 


【声明】内容源于网络
0
0
AI 原力注入
微软 CEO 萨提亚曾说:“所有产品都值得用 AI 重做一遍。” 我们正处在一场深刻变革中,唯有用 AI 赋能自身,才能拥抱未来。原力注入从云原生迈向 AI 新时代,期待在这个伟大时代中持续成长、不断突破。
内容 590
粉丝 0
AI 原力注入 微软 CEO 萨提亚曾说:“所有产品都值得用 AI 重做一遍。” 我们正处在一场深刻变革中,唯有用 AI 赋能自身,才能拥抱未来。原力注入从云原生迈向 AI 新时代,期待在这个伟大时代中持续成长、不断突破。
总阅读8.0k
粉丝0
内容590