这篇重点学什么
本篇重点:对比使用与不使用 KV Cache 的结果,分清先处理提示内容(prefill)和逐步生成(decode),再检查查询与键数量不同时的掩码、分页共享及写时复制。
开始读正文 ↓学过相近内容?查看哪些可以跳过
两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。
成本与推理性能
共同基础先说清统计哪些调用、哪些阶段,再比较费用、耗时和单位时间处理量(吞吐)。
另一条路线补充什么?
逐次记录调用费用,缺少用量时标为未知;拆开从请求开始到结束各阶段的耗时,再设置应用预算。
读完这篇,你能做什么
在 CPU 上对比完整与增量注意力结果;用 Java 检查分页引用、分支修改前复制,以及容量不足时失败不会留下半完成状态。
学习理由、准备要求与知识点
为什么学它
缓存用错可能悄悄改变输出,能容纳多少缓存、怎样调度也影响并发。单个请求跑得快,不能代表服务在多请求下也表现好。
先读这些内容
知识点清单
- 预填充与解码阶段
- 每层K和V缓存
- 位置及非方形掩码
- KV容量估算
- 分页与内部碎片
- 前缀共享写时复制
- 连续批处理与公平性
- 首token和逐token延迟
运行前先确认论文机制按文中版本说明;Java 17 完整算法,Python 3.14 与 PyTorch 2.9.1+cpu 的小型 CPU 实验。未执行 GPU、多机或大规模模型训练。
01先弄懂它是什么
语言模型逐个生成 token 时,如果每次都重算全部历史,会做很多重复工作。对因果 Transformer,在模型权重、位置规则和前缀固定的推理过程中,过去位置的表示不依赖后来追加的 token,因此可以保存每层已经算出的键 K 和值 V,让新查询 Q 直接读取,这就是 KV Cache。它缓存的是中间张量;改了权重或前缀后,不能直接沿用旧缓存,也不能把它当作整份回答缓存。
一次请求通常先处理已有提示,建立初始缓存,这叫预填充;随后进入解码,为每个活跃请求逐步计算一个或少量新 token。两阶段的计算形状、内存读取和调度压力不同。缓存能省掉反复计算历史 K、V 的工作,但新查询仍要读取允许范围内的历史键值。因此上下文变长后,每一步的成本仍可能增加。
粗略KV容量可按2×层数×批量×缓存长度×KV头数×头维度×每元素字节估算,二表示K和V。使用分组查询注意力时KV头数可能小于查询头数,不能把所有头数混用。公式只估计张量有效载荷,分配器碎片、页表、激活、模型权重和运行时工作区还要另算。
文中这几个词,先认一认
- 预填充
- 先处理提示里已有的多个 token,并为各层建立历史缓存的阶段。
- 写时复制
- 多个分支先共享同一份数据,某个分支准备修改时再复制一份,避免影响其他分支。
- 首token延迟
- 从请求到达到用户能看到第一个输出 token 的时间,包含排队、处理提示等等待。
02看看它怎么工作
在Python单头实验中,序列长度五、向量维度四。完整注意力用5×5下三角掩码;增量第t步只有一个查询和t+1个历史及当前键,这些键全部允许,因此不再额外套上左对齐下三角。带三token前缀再计算两token块时,则需按绝对位置构造2×5布尔掩码。
PyTorch2.9的scaled_dot_product_attention在非方形且is_causal为真时使用上左对齐因果偏置。若把长度一的新查询配上完整历史键,这种默认形状语义只允许开头部分,并不自动理解缓存位置。实验同时展示正确显式掩码和错误默认用法的结果差异,防止“API名称带causal就一定正确”的误解。
PagedAttention将逻辑连续的KV切成固定大小块,通过映射使用非连续物理空间,并支持共享与写时复制。Java仅用整数token页建模引用关系:分叉时共享前缀,向共享未满尾页追加前先复制,最后一个引用离开才释放。它不存实际KV张量、不执行GPU内核,也没有实现高并发调度。
先跑通一个最小例子
PyTorch CPU执行experiment.py,固定五token单头注意力,对比完整、增量和带前缀分块计算。
import math
import torch
import torch.nn.functional as F
torch.set_num_threads(1)
torch.manual_seed(47)
x=torch.randn(5,4,dtype=torch.float64)
wq,wk,wv=[torch.randn(4,4,dtype=torch.float64) for _ in range(3)]
q,k,v=[x@w for w in (wq,wk,wv)]
def attend(q,k,v,**kwargs):
return F.scaled_dot_product_attention(q[None,None],k[None,None],v[None,None],dropout_p=0.,**kwargs)[0,0]
full=attend(q,k,v,is_causal=True)
keys=torch.empty((0,4),dtype=torch.float64);values=keys.clone();out=[]
for t in range(5):
keys=torch.cat((keys,x[t:t+1]@wk));values=torch.cat((values,x[t:t+1]@wv))
out.append(attend(x[t:t+1]@wq,keys,values,is_causal=False)) # 只有历史与当前键,单步可看全部缓存。
incremental=torch.cat(out)
assert torch.allclose(full,incremental,atol=1e-10,rtol=1e-10)
assert keys.shape==(5,4) and values.shape==(5,4)
assert torch.allclose(keys,k) and torch.allclose(values,v)
# 带三token前缀的两token块,按绝对位置建立2乘5掩码。
allowed=torch.arange(5)[None,:]<=torch.arange(3,5)[:,None]
chunk=attend(q[3:],k,v,attn_mask=allowed,is_causal=False)
assert torch.allclose(chunk,full[3:],atol=1e-10,rtol=1e-10)
wrong=attend(q[-1:],k,v,is_causal=True) # 非方形默认上左对齐,不能表达当前位置4。
assert not torch.allclose(wrong,full[-1:],atol=1e-8,rtol=1e-8)
assert torch.isfinite(incremental).all()
print(f"max_cache_error={(full-incremental).abs().max().item():.3e} cache_tokens={len(keys)}")
print("PASS 6 checks; CPU single-head attention, no GPU serving benchmark")
核对运行结果max_cache_error小于1e-10,cache_tokens=5,PASS 6 checks;未运行GPU吞吐测试。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
N0["预填充建立每层键值缓存"]
N1["按实际位置更新缓存和掩码"]
N2["解码读取允许的历史键值"]
N3["共享前缀并在写入前复制"]
N4["按容量和延迟预算调度回收"]
N0 --> N1
N1 --> N2
N2 --> N3
N3 --> N4按顺序看,每一步做了什么
- 预填充建立每层键值缓存
- 按实际位置更新缓存和掩码
- 解码读取允许的历史键值
- 共享前缀并在写入前复制
- 按容量和延迟预算调度回收
03对照实现代码
PyTorch 2.9文档;实验2.9.1+cpu · torch.nn.functional.scaled_dot_product_attention,attn_mask与is_causal
原创CPU注意力桥梁,参考固定PyTorch接口;未实现PagedAttention CUDA内核或完整Transformer。
import torch
import torch.nn.functional as F
def cached_chunk(query, keys, values, past_length):
positions = past_length + torch.arange(query.shape[-2]) # 当前查询的绝对位置。
allowed = torch.arange(keys.shape[-2])[None,:] <= positions[:,None] # True表示可见历史,含当前但不含未来。
return F.scaled_dot_product_attention(query, keys, values, attn_mask=allowed, dropout_p=0., is_causal=False) # 原创CPU桥梁,显式掩码负责因果约束。
这个注意力接口的布尔 attn_mask 用 True 表示允许参与,而且形状必须能按广播规则扩展到查询与键的权重矩阵。换别的掩码 API 时,要重新检查 True 的含义。本篇短函数限定 CPU 张量、没有 padding,past_length 表示已有前缀长度;真实批次中各请求长度可能不同,还必须结合每条请求的有效长度与填充位置来限制读取。
文档给出非方形is_causal的上左对齐行为。本文显式按绝对位置生成掩码,并令is_causal为假避免重复指定不同规则;单步增量可见全部缓存是该实验无未来键时的特例,不代表训练或多token块总能关闭因果限制。
Transformers固定版本缓存说明强调新token的cache_position以及包含过去与当前位置的掩码长度。动态缓存拼接便于理解,但反复torch.cat有复制开销,不能据此推断生产缓存应采用同样分配方式。实际模型还包含多层、多头、位置编码和不同缓存类型。
PagedAttention原论文讨论逻辑块与物理块映射、共享引用及写时复制,以减少预留和碎片浪费。本文Java对应其中数据管理直觉,不是vLLM源码移植;吞吐提升必须在指定硬件、请求长度分布和并发条件下测量,CPU张量等价性测试不能提供这样的证据。
代码先调用谁,再调用谁
- 读取缓存有效长度与查询位置
- 组合历史及当前K和V
- 构造绝对位置可见掩码
- 调用注意力后保存更新缓存
04动手写一个例子
支持分支与写时复制的分页缓存模型
以固定容量整数页保存序列,支持创建、追加、分叉、读取和释放;共享未满尾页先复制,容量不足在修改引用前拒绝。
- 输入 1
页大小2,a=[1,2,3],分叉b并追加4- 输出 1
a保持[1,2,3],b=[1,2,3,4],共3页
- 输入 2
容量1,未满页被两分支共享,再追加- 输出 2
拒绝且两分支内容不变
序列保存页ID列表,物理页保存已用长度和引用次数。分叉只复制页表并增加对应页引用,不复制内容;共享的完整页永不原地追加,新增token进入新页。共享尾页尚有空位时才需要写时复制,从而保证一个分支写入不会污染另一分支。
页引用次数等于所有存活序列页表中指向它的项数,归零才移除。申请新页先检查容量,再改变旧引用与页表,因此显式配额失败保持原状态;它不承诺处理虚拟机内存耗尽等任意异常。实现仅单线程,真实并发需要锁或其他同步与生命周期协议。
设页大小B、单序列页数P、有效token数T:非分配追加摊还O(1),新页零初始化或写时复制为O(B),分叉和释放O(P),读取O(T+P)。若U个物理页、所有序列页表项总数M,缓存存储为O(UB+M)。不能把共享分支创建误写成常数时间,因为页表仍需复制。
为什么这样写是对的每页refs等于存活页表中的引用数量;共享页修改前复制,非尾页已满,归零页释放。
B为页大小,U为物理页数,M为所有页表项总数;哈希操作按期望常数计。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static void near(double expected,double actual) {
if (!Double.isFinite(actual) || Math.abs(expected-actual)>1e-9*Math.max(1,Math.abs(expected)))
throw new AssertionError(expected+" != "+actual);
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
static void bad(Runnable r) {
try { r.run(); } catch(IllegalArgumentException e) { return; }
throw new AssertionError("invalid input accepted");
}
static final class Page {
final int[] tokens;int used=0,refs=1;
Page(int size){tokens=new int[size];}
}
static final class Cache {
final int blockSize,capacity;int nextId=0;
final Map<Integer,Page> pages=new HashMap<>();
final Map<String,List<Integer>> chains=new HashMap<>();
Cache(int blockSize,int capacity) {
if(blockSize<1||capacity<1) throw new IllegalArgumentException("capacity");
this.blockSize=blockSize;this.capacity=capacity;
}
void create(String name) {
if(name==null||name.isBlank()||chains.containsKey(name)) throw new IllegalArgumentException("name");
chains.put(name,new ArrayList<>());
}
List<Integer> get(String name) {
List<Integer> chain=chains.get(name);if(chain==null) throw new IllegalArgumentException("unknown sequence");return chain;
}
int allocate(Page copy) {
if(pages.size()>=capacity||nextId==Integer.MAX_VALUE) throw new IllegalArgumentException("capacity exhausted"); // 配额先检查,失败不修改引用关系。
Page page=new Page(blockSize);
if(copy!=null) {System.arraycopy(copy.tokens,0,page.tokens,0,copy.used);page.used=copy.used;}
int id=nextId++;pages.put(id,page);return id;
}
void append(String name,int token) {
List<Integer> chain=get(name);Page last=chain.isEmpty()?null:pages.get(chain.get(chain.size()-1));
if(last==null||last.used==blockSize) {int id=allocate(null);chain.add(id);last=pages.get(id);}
else if(last.refs>1) {
int id=allocate(last);last.refs--;chain.set(chain.size()-1,id);last=pages.get(id); // 共享的未满尾页在写入前复制。
}
last.tokens[last.used++]=token;
}
void fork(String from,String to) {
List<Integer> copy=new ArrayList<>(get(from));create(to);
for(int id:copy) pages.get(id).refs++;chains.put(to,copy); // 分支共享页,页引用次数按序列映射计数。
}
int[] read(String name) {
List<Integer> chain=get(name);int size=0;for(int id:chain)size+=pages.get(id).used;
int[] out=new int[size];int at=0;
for(int id:chain){Page p=pages.get(id);System.arraycopy(p.tokens,0,out,at,p.used);at+=p.used;}return out;
}
void release(String name) {
List<Integer> chain=get(name);chains.remove(name);
for(int id:chain) if(--pages.get(id).refs==0) pages.remove(id); // 最后一位引用者离开后才释放物理页。
}
}
public static void main(String[] args) {
Cache c=new Cache(2,4);c.create("a");c.append("a",1);c.append("a",2);
check(c.pages.size()==1&&Arrays.equals(c.read("a"),new int[]{1,2}));
c.append("a",3);check(c.pages.size()==2&&Arrays.equals(c.read("a"),new int[]{1,2,3}));
c.fork("a","b");c.append("b",4);check(c.pages.size()==3&&Arrays.equals(c.read("a"),new int[]{1,2,3})&&Arrays.equals(c.read("b"),new int[]{1,2,3,4}));
c.release("b");check(c.pages.size()==2);c.release("a");check(c.pages.isEmpty());
Cache full=new Cache(2,2);full.create("a");full.append("a",1);full.append("a",2);full.fork("a","b");full.append("b",3);
check(full.pages.size()==2&&Arrays.equals(full.read("a"),new int[]{1,2}));
Cache tight=new Cache(2,1);tight.create("a");tight.append("a",1);tight.fork("a","b");bad(()->tight.append("b",2));
check(tight.pages.size()==1&&Arrays.equals(tight.read("b"),new int[]{1}));tight.release("b");tight.append("a",2);check(tight.read("a").length==2);
bad(()->new Cache(0,1));
bad(()->c.release("a"));
System.out.println("PASS 8 cases");
}
}
这些特殊情况也要试一试
- 完整共享页追加只分配新页
- 共享未满尾页触发复制
- 配额失败不改变已存内容
- 重复释放和非法页大小拒绝
进阶选读 · 生产服务中的容量调度与隔离
场景长请求、短请求和多个候选共享有限KV空间。
做法按token预算做准入和连续批处理,记录页占用与回收;前缀键包含模型和位置等兼容信息,跨租户复用必须满足隔离策略;在目标硬件测首token及逐token尾延迟。
复杂度真实KV读写随层、头和上下文长度增长,分页减少分配浪费并不消除注意力计算;调度和GPU内核开销需要实测。
好处与代价较大页减少页表开销但增加尾页浪费,前缀共享节省空间却增加引用管理与隐私隔离复杂性。
05项目里怎么用
服务性能报告应区分排队、预填充和解码耗时,并给请求长度分布、并发数、输出长度及采样配置。平均tokens每秒可以随着批处理增大而提高,同时单个用户尾延迟变差;因此需要吞吐与延迟共同约束的测试,而不是用单请求最快一次代表生产能力。
缓存复用条件必须包含影响KV值的全部因素,例如模型权重或适配器、token前缀、位置编码和相关执行配置。只比较可见文本不足以证明token序列相同,更不能忽略租户权限。取消、超时和异常退出都应释放引用,重复释放或错误共享可能造成泄漏与跨请求污染。
本文CPU实验验证完整与增量注意力在同一数学设置下相符,Java验证分页内容和容量协议。没有测量GPU显存、FlashAttention、真实vLLM服务或多机通信;后续应在可控负载下加入取消、抢占、长短混合和OOM恢复实验,再讨论实际吞吐收益与资源成本。
容易踩的坑
- KV缓存被当答案缓存
- 非方形causal掩码忽略位置
- 共享尾页原地写入污染分支
- 只统计权重忽略KV容量
- 单请求速度当生产吞吐保证
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 连续动态缓存 | 代码直观 | 增长复制与分配开销 | 小型验证和原型 |
| 固定预分配缓存 | 位置稳定、减少动态分配 | 预留未用空间 | 长度上限明确的工作负载 |
| 分页共享缓存 | 减少碎片并复用前缀 | 页表与引用管理复杂 | 并发服务与多候选生成 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为何缓存K和V而非所有旧Q?
展开参考答案与推导
新步骤用新Q查询过去K和V,旧Q的输出已计算,不需要重新查询。
02缓存后注意力每步常数时间吗?
展开参考答案与推导
不是,新查询仍读取允许的历史键值,成本随上下文和模型形状变化。
03写时复制为何必要?
展开参考答案与推导
共享未满页被一个分支写入会改变其他分支看到的数据,先复制可保留隔离。
换个情况,还会处理吗
01增量输出与完整不同先查什么?
展开参考答案与推导
检查缓存长度、位置、掩码、padding、dtype及模型是否同一评估设置。
02并发增加吞吐升尾延迟也升?
展开参考答案与推导
拆开排队和执行,按延迟目标限制批次与准入,而非只追求总吞吐。
03取消后显存一直不降?
展开参考答案与推导
检查页引用和回收路径,区分可复用内存池保留与真正仍被活跃请求引用。
写代码试一试
01构造两查询五键掩码。
- 输入 / 条件
过去3个token,新2个- 输出 / 预期
首行可见0至3,次行0至4
提示 按绝对位置比较
展开参考答案与推导
键位置小于等于past加当前查询偏移时允许。
02验证共享尾页。
- 输入 / 条件
a为123,页大小2,b追加4- 输出 / 预期
a不变且b为1234
提示 复制引用数大于一的尾页
展开参考答案与推导
先申请复制页,再减旧引用并替换b页表。
03估计KV载荷。
- 输入 / 条件
层数L,批量N,长度T,KV头H,维度D,字节S- 输出 / 预期
2×L×N×T×H×D×S
提示 K和V各一份
展开参考答案与推导
另外报告页表、碎片、工作区与权重,不能当设备总内存。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
在 CPU 上对比完整与增量注意力结果;用 Java 检查分页引用、分支修改前复制,以及容量不足时失败不会留下半完成状态。
本篇重点加练
位置身份与共享缓存生命周期决定增量计算正确性,重点检查同内容不同计算条件。
- 位置及非方形掩码
- 前缀共享写时复制
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为四 token 前缀再生成三个 token 的解码画调用时序,记录每步 Q、K、V 的逻辑长度与位置身份,区分预填充和解码。
- 提交内容
- 时序与形状表
- 怎样算完成
- 缓存位置可追踪
- 历史和当前 token 区分
练到的知识点预填充与解码阶段 · 每层K和V缓存 · 位置及非方形掩码
- 作业 02代码推演
设两层、批量 3、KV 头 2、头维 8、长度 17、每元素 2 字节,计算有效载荷;再用页大小 4 比较预留与尾部浪费。
- 提交内容
- 容量预算表
- 怎样算完成
- K
/V双份及单位明确 - 载荷与分配开销分开
- K
练到的知识点KV容量估算 · 分页与内部碎片
- 作业 03原理问答
两个请求 token 前缀相同,但位置起点或适配器不同。为共享 KV 定义兼容条件,区分可共享原始 token 与可共享已计算 KV。
- 提交内容
- 缓存对象与兼容性矩阵
- 怎样算完成
- 缓存层次明确
- 条件对应实际计算依赖
练到的知识点位置及非方形掩码 · 前缀共享写时复制
综合应用
- 作业 04编程实践
实现 CPU 小注意力的完整、单 token 增量和分块增量三条路径,对长度 7 的输入比较输出及掩码。
- 提交内容
- 实现与三路径差异记录
- 怎样算完成
- 绝对位置一致
- 非方形掩码显式验证
练到的知识点每层K和V缓存 · 位置及非方形掩码
- 作业 05排错实验
给分页缓存加入三次分叉、两个取消和一次容量拒绝,用随机但固定种子的操作序列核对内容、引用计数和回收。
- 提交内容
- CPU 模型测试与状态日志
- 怎样算完成
- 失败操作的原状态可检查
- 模型不冒充真实 GPU 缓存
练到的知识点分页与内部碎片 · 前缀共享写时复制
- 作业 06面试场景
长短请求混合到达时,总吞吐上升但短请求等待加长。设计调度评价,分别定义排队、首 token 与逐 token 延迟。
- 提交内容
- 负载协议与指标表
- 怎样算完成
- 延迟起止点明确
- 公平性按子群报告
练到的知识点连续批处理与公平性 · 首token和逐token延迟
- 作业 07编程实践
CPU 小注意力保存一个前缀缓存后分叉两条不同续写,分别与无共享完整计算核对;注入位置错配并验证检查器拒绝。
- 提交内容
- 分叉实验及差异记录
- 怎样算完成
- 分支位置可追踪
- 共享不改变其他分支结果
练到的知识点位置及非方形掩码 · 前缀共享写时复制
- 作业 08排错实验
为不超过十页的 CPU 缓存模型设计快照恢复实验,重载后分叉写入并检查共享关系;注入页 ID 遗漏,记录恢复器如何识别不完整状态。
- 提交内容
- 生命周期账本与不变量测试
- 怎样算完成
- 快照对象与共享关系可追踪
- 不完整页记录不会静默丢弃
练到的知识点前缀共享写时复制
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
选做真实推理服务基准:固定模型、长度分布和并发,比较两种批处理策略;无目标设备时仅提交可执行测量计划。
- 提交内容
- 实测报告或待执行协议
- 怎样算完成
- 设备与软件版本完整
- 不从 CPU 数学核验推断 GPU 吞吐
练到的知识点预填充与解码阶段 · KV容量估算 · 连续批处理与公平性 · 首token和逐token延迟
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 跨域迁移
把 KV 共享迁移到编译器公共子表达式缓存:文本相同为何可能还不足以复用?建立上下文、版本和位置的对应关系,并给出一个错误复用反例。
- 提交内容
- 跨域依赖图与反例
- 怎样算完成
- 类比对应具体计算输入
- 未映射的差异明确保留
练到的知识点位置及非方形掩码 · 前缀共享写时复制
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
PagedAttention论文v1 (新标签页)↗
阅读逻辑物理块、共享与写时复制及实验条件。
- 实践参考文档英文
PyTorch2.9注意力接口 (新标签页)↗
核对布尔掩码、非方形causal与dropout参数。
- 深入参考文档英文
Transformers4.57.1缓存说明 (新标签页)↗
理解
cache_position、缓存形状与动态更新。