手搓系列导航
这个系列做一件事:把框架里「拿来就用」的机制,用最小可运行的代码搓出来。每个主题一段 numpy 或 Python(百行级),不依赖框架内部接口,跑得动、算得清、能复现,配套手绘图把机制画开。读的时候建议开着代码对照正文,每篇的数字都来自实测。
阅读顺序按依赖递进:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
前四篇搭出「一个 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 在显存里怎么摆放。它看起来是个工程细节,实际决定了同样的卡能同时跑几条请求。手搓一个管理器,三种策略各跑一遍模拟,用同一组预算和负载对照。
二、版本 0:静态预留,按 max_len 一口价
最简单的管理策略:每条请求来了,按最大长度(max_len = 1024)一口价预留。
capacity = TOTAL_SLOTS // MAX_LEN # 8528 // 1024 = 8 条
模拟器实测:预算 8528 个槽位,只放得下 8 条请求。而 60 条序列的实际长度均值只有 228:每条预留的 1024 个槽位里,平均只有两成多真的被用到。8 条序列合计预留 8192 槽位,实际占用约 1600,平均利用率 20%。
剩余 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% 是这次实测值)。
容量账也随之改变:不再按 max_len 预留,同样 8528 槽位的预算,静态预留只服务 8 条,分页实测放进 39 条(第 40 条按页取整后差 1 个页没进;页取整的代价后文有账),约 5 倍。论文里的说法是「有效利用率从 20-40% 提升到接近 100%」:模拟器的 20% 对 97.3%,正落在同一个区间。
五、对照真实系统
模拟器与真实系统的三个对账点:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
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是配套的计算账。
源文件索引
|
|
|
|---|---|
|
|
|
vllm/_custom_ops.py:55(快照 8b365ff9)
|
|
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+ 篇进阶内容

