已保存可复算的 CPU 教学输出。ICL fixture 为合成评分测试,所有 Lab 均未调用真实模型。
LAB 03 · KV、分页、前缀与请求调度
模型参数可供许多请求共用,当前 K/V 则随输入与生成增长。我们用课堂同一个配置算容量,再以离散请求解释分页、前缀共享和动态接纳。
证据身份:CPU 内存账本与固定请求模拟;时间轴单位是人工定义的 decode step,不是毫秒,也没有测量 GPU 吞吐。 全本只需 Python 标准库。建议 25–35 分钟。
基线配置:32 层,8 个 KV 组,每组 128 个数,BF16 每数 2 bytes,K 与 V 两份。GB=10⁹ bytes,GiB=2³⁰ bytes。
查看与修改计算代码
import math, json
layers=32; kv_heads=8; head_dim=128; bytes_per_number=2
per_token=2*layers*kv_heads*head_dim*bytes_per_number
print('每token:',per_token,'bytes =',per_token/1024,'KiB')
for length,requests in [(8192,1),(8192,8),(32768,1)]:
print(length,'tokens ×',requests,'请求 =',per_token*length*requests/2**30,'GiB')
assert per_token==131072
保存的本地 CPU 输出 · kv-serving-01
每token: 131072 bytes = 128.0 KiB 8192 tokens × 1 请求 = 1.0 GiB 8192 tokens × 8 请求 = 8.0 GiB 32768 tokens × 1 请求 = 4.0 GiB
只改一项:把序列长度翻倍,KV 容量也翻倍。把 KV 组从 8 改为 32,容量变为四倍;query heads 多少不能单独代入这个保存 K/V 的账本。这里不含模型权重、激活与工作区,也没有假设这些层结构来自某个真实 checkpoint。
1. 逻辑连续,物理可以分散
块长设为 4 token,逻辑块 0、1、2 映射到物理块 7、1、5。一个 6 token 请求需要两块;一个 9 token 请求需要三块。最后一个块未填满的空间仍占容量。
查看与修改计算代码
block_size=4
physical=[7,1,5]
def page_table(length,size=4,physical=(7,1,5,9,3)):
count=math.ceil(length/size)
if count>len(physical): raise ValueError('物理空闲块不足')
return [{'logical':i,'physical':physical[i],
'token_start':i*size,'used':min(size,length-i*size)} for i in range(count)]
for length in (6,9):
table=page_table(length,block_size,physical)
print('长度',length,'表',table,'分配token位',len(table)*block_size,'尾块浪费',len(table)*block_size-length)
for token in range(9):
print('token',token,'→ physical block',physical[token//4],'offset',token%4)
保存的本地 CPU 输出 · kv-serving-03
长度 6 表 [{'logical': 0, 'physical': 7, 'token_start': 0, 'used': 4}, {'logical': 1, 'physical': 1, 'token_start': 4, 'used': 2}] 分配token位 8 尾块浪费 2
长度 9 表 [{'logical': 0, 'physical': 7, 'token_start': 0, 'used': 4}, {'logical': 1, 'physical': 1, 'token_start': 4, 'used': 4}, {'logical': 2, 'physical': 5, 'token_start': 8, 'used': 1}] 分配token位 12 尾块浪费 3
token 0 → physical block 7 offset 0
token 1 → physical block 7 offset 1
token 2 → physical block 7 offset 2
token 3 → physical block 7 offset 3
token 4 → physical block 1 offset 0
token 5 → physical block 1 offset 1
token 6 → physical block 1 offset 2
token 7 → physical block 1 offset 3
token 8 → physical block 5 offset 0
分页可以减少必须预留整段连续最大长度的浪费,但块越大,短请求的尾块内部碎片可能越大。下一段保持请求长度 [6,9,17],只改块长。分页本身没有减少注意力数学运算,FlashAttention 与 PagedAttention 处理的是不同层面的开销。
查看与修改计算代码
lengths=[6,9,17]
for size in (1,4,16):
allocated=sum(math.ceil(n/size)*size for n in lengths)
print('块长',size,'实际token',sum(lengths),'分配token位',allocated,'内部碎片',allocated-sum(lengths),'块表条目',sum(math.ceil(n/size) for n in lengths))
保存的本地 CPU 输出 · kv-serving-05
块长 1 实际token 32 分配token位 32 内部碎片 0 块表条目 32 块长 4 实际token 32 分配token位 40 内部碎片 8 块表条目 10 块长 16 实际token 32 分配token位 64 内部碎片 32 块表条目 4
块长 1 在这个纯容量模型里没有尾块浪费,却需要更多页表条目;真实 kernel 的访问效率和元数据成本未进入模拟。不能直接从本表选生产环境最优块长。
2. 前缀共享:需要精确相同的计算条件
三条请求各有 4096 token 相同前缀和 2048 token 独有后缀。它们若使用同一模型权重、位置约定与其他影响 K/V 的条件,共享一份前缀可以从 2.25 GiB 降到 1.25 GiB。
以下小例子只用字符串表示已确定的 token ID 序列。缓存键包含模型、位置与 token 前缀。看起来意思相近但 token 不同,不算命中;模型变了也不算命中。
查看与修改计算代码
prefix=4096; suffix=2048; requests=3
no_share=requests*(prefix+suffix)*per_token/2**30
shared=(prefix+requests*suffix)*per_token/2**30
print('不共享',no_share,'GiB;精确共享',shared,'GiB;节省',no_share-shared,'GiB')
assert no_share==2.25 and shared==1.25
cache={('model-v1',0,(101,202,303)):'physical-prefix-7'}
def cache_hit(model,position,tokens): return (model,position,tuple(tokens)) in cache
for label,args in [('完全相同',('model-v1',0,[101,202,303])),('改一个token',('model-v1',0,[101,202,304])),('改模型',('model-v2',0,[101,202,303])),('改起始位置',('model-v1',1,[101,202,303]))]:
print(label,'命中=',cache_hit(*args))
保存的本地 CPU 输出 · kv-serving-07
不共享 2.25 GiB;精确共享 1.25 GiB;节省 1.0 GiB 完全相同 命中= True 改一个token 命中= False 改模型 命中= False 改起始位置 命中= False
共享前缀还需引用计数;请求释放时不能误删其他请求仍使用的块。共享后的不同后缀仍各自增长,decode 并未被消除。这里用精确键说明必要条件,不完整实现 RadixAttention 或服务缓存策略。
3. 固定请求轨迹:A 完成后,C 能否立即加入?
设每个活跃请求在一个人工 step 中生成一个 token;容量为两条请求。A 和 B 在 step 0 到达,分别需要 2、4 步;C 在 step 1 到达,需要 2 步。静态批处理等待当前整批都结束才接新请求;连续批处理在下一步填入空位。
prefill、带宽竞争、不同请求长度导致的每步真实耗时都被暂时忽略。因此下面能说明空位与等待,不能输出真实 GPU 加速倍数。
查看与修改计算代码
REQUESTS=[{'id':'A','arrival':0,'output_tokens':2},{'id':'B','arrival':0,'output_tokens':4},{'id':'C','arrival':1,'output_tokens':2}]
def simulate(requests,capacity=2,continuous=True):
remaining={r['id']:r['output_tokens'] for r in requests}
arrival={r['id']:r['arrival'] for r in requests}
active=[]; done={}; started={}; trace=[]; step=0
while len(done)<len(requests):
waiting=[r['id'] for r in requests if arrival[r['id']]<=step and r['id'] not in done and r['id'] not in active]
if continuous or not active:
for name in waiting[:capacity-len(active)]: active.append(name); started[name]=step
row={'step':step,'slots':active.copy()+['空']*(capacity-len(active))}
for name in list(active):
remaining[name]-=1
if remaining[name]==0: done[name]=step+1; active.remove(name)
trace.append(row); step+=1
if step>1000: raise RuntimeError('模拟未终止')
return {'trace':trace,'start':started,'finish':done,'makespan_steps':step,'occupied_slot_steps':sum(r['output_tokens'] for r in requests),'total_slot_steps':step*capacity}
for mode in (False,True):
print('连续接纳' if mode else '静态批次',json.dumps(simulate(REQUESTS,continuous=mode),ensure_ascii=False,indent=2))
assert simulate(REQUESTS)['start']['C']==2
assert simulate(REQUESTS,continuous=False)['start']['C']==4
保存的本地 CPU 输出 · kv-serving-09
静态批次 {
"trace": [
{
"step": 0,
"slots": [
"A",
"B"
]
},
{
"step": 1,
"slots": [
"A",
"B"
]
},
{
"step": 2,
"slots": [
"B",
"空"
]
},
{
"step": 3,
"slots": [
"B",
"空"
]
},
{
"step": 4,
"slots": [
"C",
"空"
]
},
{
"step": 5,
"slots": [
"C",
"空"
]
}
],
"start": {
"A": 0,
"B": 0,
"C": 4
},
"finish": {
"A": 2,
"B": 4,
"C": 6
},
"makespan_steps": 6,
"occupied_slot_steps": 8,
"total_slot_steps": 12
}
连续接纳 {
"trace": [
{
"step": 0,
"slots": [
"A",
"B"
]
},
{
"step": 1,
"slots": [
"A",
"B"
]
},
{
"step": 2,
"slots": [
"B",
"C"
]
},
{
"step": 3,
"slots": [
"B",
"C"
]
}
],
"start": {
"A": 0,
"B": 0,
"C": 2
},
"finish": {
"A": 2,
"B": 4,
"C": 4
},
"makespan_steps": 4,
"occupied_slot_steps": 8,
"total_slot_steps": 8
}
基线中连续接纳把 C 的开始从 step 4 提到 step 2,让 C 与 B 的剩余两步并行。下面只把 B 需要的步数从 4 改为 2,其他请求需求及到达时刻不变:A/B 同时结束,静态整批释放已经没有等待劣势,连续接纳收益消失。
查看与修改计算代码
boundary=[{'id':'A','arrival':0,'output_tokens':2},{'id':'B','arrival':0,'output_tokens':2},{'id':'C','arrival':1,'output_tokens':2}]
for mode in (False,True): print('continuous=',mode,'finish=',simulate(boundary,continuous=mode)['finish'])
assert simulate(boundary,continuous=False)['finish']==simulate(boundary,continuous=True)['finish']
保存的本地 CPU 输出 · kv-serving-11
continuous= False finish= {'A': 2, 'B': 2, 'C': 4}
continuous= True finish= {'A': 2, 'B': 2, 'C': 4}
4. 什么时候需要更复杂的服务系统?
长 prefill 会占用资源并可能干扰正在 decode 的请求。把 prefill 与 decode 分开调度,需要传递 K/V,并承担传输和管理成本;如果传输成本超过调度收益,拆开可能更慢。本本没有网络、GPU 或分布式系统测量,不能决定具体系统选型。
来源:vLLM / PagedAttention、SGLang。分页、共享和调度分别处理容量布局、重复计算/存储和接纳时机,可以配合,不是互相替代的三个名字。