CODING WITH AI · LECTURE 06

返回课堂 · 讲义

已保存可复算的 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。分页、共享和调度分别处理容量布局、重复计算/存储和接纳时机,可以配合,不是互相替代的三个名字。