KV cache 分页与前缀复用:吞吐上去后正确性怎么验
沿 block table、前缀哈希与抢占回收拆开缓存一致性风险,说明错页复用为何表现为「偶发胡话」;用确定性前缀集、强制抢占和逐 token 对照验收。

1. 吞吐先上去了,事故却像随机噪声
大模型推理服务做完连续批处理、PagedAttention 和 prefix caching 后,最直观的收益通常不是单请求更快,而是同一张卡能容纳更多并发序列:prefill 阶段把长 prompt 的 KV 写进块池,decode 阶段只为新 token 追加少量 KV,调度器按活跃序列动态拼 batch。压测曲线很好看,显存碎片少了,长上下文请求不再轻易把 allocator 打爆,热门系统提示词也能被多个请求复用。
麻烦在于,吞吐优化把原本线性的状态变成了共享、分页、可抢占的状态。一个请求的第 个 token 不再简单读自己数组里下标 的 KV,而是沿 block table 找到一串物理块;其中一段可能来自前缀缓存,后一段可能刚从空闲链表分配,某些块还经历过被抢占、回收、重新绑定。只要某个映射、哈希或生命周期判断错一次,模型看到的上下文就会变成“绝大部分正确,某几页来自别的请求”。这类错误不会像越界写那样稳定崩溃,更多表现为偶发胡话、重复片段、格式突然漂移,甚至只在高并发和长 prompt 混合时出现。
因此 KV cache 的验收不能只看 tokens/s、TTFT 和显存占用。PagedAttention 的正确性边界是:逻辑 token 位置到物理 KV 页的映射必须稳定,前缀复用必须保证语义等价,抢占回收后不能留下可被误读的脏块。下面沿这三条线拆开,再给一套逐 token 对照的验收方法。
2. Block table 是推理时的页表
Transformer 自注意力在第 步需要历史键值:
传统实现常把每条序列的 KV 预留成连续缓冲区,长度按最大上下文估计。真实流量里请求长度高度不均,短请求浪费尾部,长请求又容易触发搬迁。PagedAttention 把 KV 切成固定 token 数的 block,例如每块 16 或 32 个 token。每条序列持有一张 block table:逻辑块号 映射到物理块号,块内偏移是 。注意力 kernel 根据 table 间接读取 KV。
这个设计把显存管理问题从“大数组是否连续”改成“表项是否正确”。逻辑上很像操作系统页表,但它服务的是每层、每头、每个 token 的 KV。一次请求可能有几十层,每层 KV 的 stride、dtype、head 布局都要和 table 解释一致。只要 scheduler 认为某序列已经绑定了物理块,kernel 就会信任这张表;kernel 通常不会再验证这页是否属于当前请求,因为那会把热路径拖慢。
最小不变量可以写成三条:第一,同一序列内逻辑块号单调追加,除非明确重算或截断;第二,物理块在引用计数非零时不可进入 free list;第三,一个 table entry 指向的块必须与该序列的模型版本、KV dtype、层数布局和位置编码状态一致。吞吐优化经常破坏的不是矩阵乘法,而是这些朴素契约。
struct BlockRef {
int physical_block;
int start_pos;
int token_count;
uint64_t owner_seq;
uint32_t ref_count;
uint32_t generation;
};
// hot path 不能只信 physical_block;调试构建至少要校验 generation。
bool valid_for(const BlockRef& ref, uint64_t seq, uint32_t seen_generation) {
return ref.owner_seq == seq && ref.generation == seen_generation && ref.ref_count > 0;
}生产 kernel 未必带 owner_seq,但调试元数据必须能重建这件事。没有可观测的 owner、generation、引用计数,后面所有“偶现”都会变成猜测。
3. 错页复用为何像模型变笨
错页复用指逻辑块表里某个 entry 指到了不属于当前上下文的物理块。它可以来自 table 写错、free list 过早回收、prefix cache 命中错误,也可以来自多流并发下的元数据竞争。由于单页只覆盖一小段 token,错误上下文通常混在大量正确上下文中。模型前几十个 token 仍可能正常,等注意力分布落到错页附近时才开始偏航。
这种偏航有几个特征。第一,输出不是完全随机,而是带着另一个请求的主题、格式或语言风格;第二,错误可能在低温度贪心解码下仍可复现,但只对某个调度交错复现;第三,logprob 会在某一步突然分叉,后续差异被自回归放大。业务侧常把它误判为模型幻觉,实际上是缓存一致性损坏。
排查时不要只对比最终字符串。最终字符串一旦分叉,后面每个 token 都不同,无法定位第一处污染。应保存每一步的 logits 或至少保存 top-k、采样前 token、block table 快照和块 generation。若第 117 个输出 token 首次不同,就回看该步 attention 可见的逻辑块集合,检查这些逻辑块在基线和分页实现中的物理块身份是否一致。
一个实用信号是“同 prompt、同 seed、同采样配置,关闭 prefix caching 后正常,开启后偶发异常;降低并发后正常,提高并发后异常”。这说明矩阵 kernel 本身未必错,生命周期和复用路径更可疑。若只在长 prompt 加短 decode 混合流量中出现,要重点看最后一个非满块:它既可能被追加写,又可能被错误当成可共享前缀块。
4. Prefix caching 的等价条件不止 token 相同
Prefix caching 的核心是把一段 prompt 的 KV 以哈希键存入缓存,后续请求前缀相同就直接引用已有块,跳过 prefill。最粗糙的键是 token 序列哈希,实际系统还必须把模型、tokenizer、LoRA adapter、rope scaling、chat template、特殊 token 策略、KV dtype、并行切分方式纳入键空间。因为 KV 不是文本,它是“某个模型配置在某个位置编码规则下对这串 token 的中间状态”。
等价条件可以表述为:
只有当 相同且缓存块覆盖的 token 边界一致时,才能复用对应 KV。这里的“边界一致”很容易被忽略。若 block size 为 16,请求 A 的公共前缀长度是 48,可以复用 3 个满块;请求 B 的公共前缀长度是 50,只能复用前 48 个 token,后 2 个 token 必须在自己的非满块里写入。把非满块也放进全局前缀缓存,下一条请求可能读到 A 的尾部填充或后续私有 token。
哈希碰撞不是唯一风险,哈希失效更常见。比如服务升级了 tokenizer,但 cache namespace 没变;系统提示词模板多了一个不可见换行,但上游仍用旧的 prefix key;同一基座模型挂了不同 LoRA,却只把 base model 写进 key;RoPE scaling 从默认改成 long-context 配置,却继续复用旧 KV。这些场景不会触发类型错误,输出却会从某一步开始轻微偏移。
因此 prefix key 要可审计。键里不一定直接存完整 token,但应能在日志中打印:token count、token digest、模型 revision、tokenizer revision、adapter revision、位置编码配置、block size、KV layout version。缓存命中日志不要只写 hit=true,至少写命中的逻辑块数和最后一块是否满块。生产日志可采样,验收环境必须全量。
5. 前缀哈希碰撞要按系统事故处理
很多团队会说“用了 128 位哈希,碰撞概率可以忽略”。从概率上看这通常成立,但系统正确性不能只依赖一句概率。第一,哈希实现可能被截断,例如为了节省索引空间只存低 64 位;第二,组合 key 时可能出现歧义,如把字段直接拼字符串而没有长度前缀;第三,bug 造成的“逻辑碰撞”远多于数学碰撞,比如 adapter 字段为空时所有 LoRA 都落到同一 namespace。
正确做法是把 prefix cache 分成快速键和验证载荷。快速键用于查表,命中后仍可在调试或抽样路径验证 token digest、配置 digest 和块长度。对高风险发布,可以在影子环境开启“双键确认”:短哈希命中后再比较完整 SHA-256 digest;若不一致,计为 cache corruption 而不是普通 miss。
from dataclasses import dataclass
import hashlib
@dataclass(frozen=True)
class PrefixKey:
model_rev: str
tokenizer_rev: str
adapter_rev: str
rope_cfg: str
kv_layout: str
tokens: tuple[int, ...]
def digest(key: PrefixKey) -> str:
h = hashlib.sha256()
for field in key.__dict__.values():
if isinstance(field, tuple):
h.update(len(field).to_bytes(8, "little"))
for token in field:
h.update(token.to_bytes(4, "little", signed=False))
else:
data = field.encode("utf-8")
h.update(len(data).to_bytes(4, "little"))
h.update(data)
return h.hexdigest()注意这里给每个字段写长度,不把 "ab"+"c" 和 "a"+"bc" 交给字符串拼接猜。真实实现还要区分空 adapter 与无 adapter,区分默认 RoPE 与显式 RoPE。把这些写进 key 不是洁癖,而是让缓存命中具备可解释性。
6. 抢占回收后,脏块最容易躲进空闲链表
连续批处理为了吞吐,会在显存压力大时抢占某些序列:暂停低优先级请求,把它的 KV 块释放给更急的请求;等资源回来,要么从 CPU swap 恢复,要么重算 prefill。问题在于 block pool 通常是高性能组件,free 和 allocate 都很轻,稍有疏忽就会让旧块带着旧 generation 回到新序列。
脏块有两类。数据脏块是物理 KV 内容未清零,新序列以为自己写满了整块,实际只写了前半块,后半块仍是旧请求的 KV。元数据脏块更隐蔽:物理内容会被覆盖,但 block table、ref_count、prefix cache 索引或 swap handle 没有同步失效,导致旧索引还能找到这块。前者会污染 attention,后者会让同一物理块被两个逻辑所有者同时认为合法。
非满块是高危点。decode 每步只追加一个 token,最后一块长期处于“部分有效”状态。若回收时没有记录 valid token count,新请求复用该块后,kernel 根据上下文长度读取到块尾旧内容,就会出现只在某些长度触发的错误。另一个高危点是抢占后恢复:恢复路径若先把 table 指回物理块,再异步 DMA KV 内容,decode 线程可能读到尚未完成恢复的页。
可执行的不变量是:释放物理块必须递增 generation,并从所有可达索引中删除;分配物理块必须重置 valid length;共享前缀块必须只读,任何追加写都应 copy-on-write 到私有块;恢复完成前 table entry 不可见。调试构建可以把释放块填成 NaN 或固定哨兵,在 attention kernel 前检查 valid range,宁可早崩,也不要把脏块伪装成模型输出。
7. 调度交错比单元测试更接近真实风险
很多 KV cache bug 在单请求单 batch 下测不出来,因为没有共享、抢占和回收。验收流量要刻意制造交错:长 prompt A 触发多块 prefill,短 prompt B 借用系统前缀,长 prompt C 在 decode 中被抢占,随后 B 结束释放块,C 恢复继续 decode。只有这种路径才覆盖 block pool 的生命周期。
构造用例时应固定随机种子,使用贪心解码或 temperature=0,关闭 dropout 和任何非确定性采样。为了让错误尽早显形,prompt 不要全是闲聊,可以放入互相冲突的事实和格式约束。例如 A 要求只输出 JSON,B 要求写古诗,C 要求回答 C++ 内存模型。若错页复用,输出风格污染会很明显。更重要的是,每条请求都要有唯一 canary token 或短语,且这些 canary 不应出现在其他请求中;一旦输出或 logits top-k 中出现 чуж canary,就直接定位到缓存隔离问题。
还要覆盖边界长度:0 个共享块、1 个满块、多个满块、满块加 1 个 token、满块减 1 个 token。Prefix caching 最常出错的是最后一块;分页读取最常出错的是跨块边界;抢占最常出错的是释放后立即分配。把这些长度组合成确定性前缀集,而不是随机抽几条线上 prompt。
8. 逐 token 对照:先找第一处分叉
正确性验收的基线应是“不使用分页复用的朴素实现”或同一引擎关闭 prefix caching、关闭抢占后的路径。对每个测试请求,记录基线每一步输入 token、position id、logits 摘要、采样 token;再运行优化路径,逐 token 对齐。只比最终文本会把定位信息丢掉。
def compare_steps(reference, candidate, atol=1e-4):
for step, (r, c) in enumerate(zip(reference, candidate)):
if r["input_ids"] != c["input_ids"]:
return step, "input diverged"
if r["position_ids"] != c["position_ids"]:
return step, "position diverged"
if r["next_token"] != c["next_token"]:
return step, {
"reason": "token diverged",
"ref_topk": r["topk"],
"cand_topk": c["topk"],
"ref_blocks": r["block_table"],
"cand_blocks": c["block_table"],
}
if max_abs(r["logits"], c["logits"]) > atol:
return step, "logits drift"
return None, "match"浮点实现可能因为 kernel、并行归约或量化带来微小差异,所以验收不能要求所有 logits bitwise 相等;但在相同 kernel、相同 dtype、只改变缓存管理策略时,贪心 token 应长期一致,logits 差异应停留在小阈值内。若第一个分叉点发生在某个块边界后,优先查 block table;若分叉点正好是 prefix 命中后的第一个 decode token,优先查 prefix key;若分叉点只在抢占恢复后出现,优先查 generation 和 valid length。
对照日志还应保存“该步看到的缓存视图”:每层逻辑块数、物理块号、generation、valid token count、是否共享、ref_count。不要只保存一层,因为某些实现按层分配,某层失败会让 logits 漂移但 table 顶层看起来正常。
首分叉归因最好做成固定矩阵,而不是人工翻日志。分叉点在 prefill 末尾,先查 prefix key 与 position id;分叉点在跨块后的第一个 token,先查块内 offset 和最后一块长度;分叉点紧跟抢占恢复,先查恢复完成标志和 generation;分叉点只在量化 KV 路径出现,再查 scale 与 layout。每类归因都对应一组必要字段,缺字段就不能算验收通过。
基线也要定期重跑。模型权重、推理 kernel 或编译参数变更后,旧基线不再代表“正确答案”;若继续拿旧日志对照,容易把真实数值差异误判成缓存错页。基线版本应和优化路径一起进入报告。
9. 强制抢占测试要把时序钉死
等待线上自然抢占再排查,成本太高。测试环境应提供可控开关:在指定请求、指定 token 步之后强制抢占;在指定毫秒后恢复;限制 block pool 容量,让分配器必然回收刚释放的块;可选地把 swap 或重算路径都跑一遍。这样才能把“偶发”变成固定复现。
一个典型脚本是:启动三条请求,A 是长 prefill 后短 decode,B 共享 A 的前缀但很快结束,C 长 decode;当 C 生成第 8 个 token 时强制抢占 C,随后让 B 释放共享前缀外的私有块,再恢复 C。期望结果是 C 的逐 token 输出与无抢占基线一致,且 C 恢复后的 table generation 全部匹配。若恢复后第一步 logits 漂移,说明恢复可见性或块内容有问题;若过几步才漂移,常见原因是 valid length 或后续追加写覆盖了共享块。
强制抢占还要验证失败路径。比如 swap 写入失败、重算被取消、客户端断开、请求超时清理。这些路径常绕过正常 release,留下 ref_count 不归零或 prefix cache entry 不删除。验收时把异常路径当作一等公民:每次取消后 block pool 的总块数、空闲块数、引用计数和 prefix 索引大小应回到预期值。
10. 观测字段决定能否止血
线上一旦出现疑似错页复用,最怕日志只剩请求 ID 和最终输出。最低限度的观测字段包括:cache namespace、prefix key digest、命中块数、block table 版本、物理块 generation、抢占事件、释放事件、恢复事件、每个请求的逻辑长度和最后一块 valid length。敏感 prompt 可以不落全文,但 token digest 与长度必须保留,否则无法判断“应不应该命中”。
指标也要按风险拆分。Prefix cache hit rate 升高不一定是好事,若同时伴随逐 token 对照失败或 canary 污染,就是错误复用。Block pool 的 free/alloc 速率、同一物理块 generation 递增频率、抢占次数、恢复延迟、copy-on-write 次数,都应进入压测面板。出现异常时先按 namespace 关闭 prefix caching,再按调度策略关闭抢占,最后才回退整个推理引擎;分层开关能缩短止血窗口。
发布门禁可以很朴素:确定性前缀集全通过;强制抢占集全通过;线上影子流量逐 token 抽样无首分叉;prefix key 字段变更有 namespace bump;block table 调试校验在预发无 owner/generation 错误。吞吐指标只在这些门禁之后讨论。否则 tokens/s 越高,错误传播越快。
11. 版本化与并发控制要放在热路径旁边
KV cache 的一致性问题常被误放到“外围缓存”层处理,实际它紧贴 attention 热路径。Prefix cache 命中、block table 发布、抢占恢复、copy-on-write 都会改变下一步 kernel 读到的地址集合,因此这些操作必须有清晰的版本边界。一个请求在某一步 decode 开始后,它看到的 table snapshot 应保持不变;调度器可以准备下一版 table,但不能在 kernel 读取过程中原地改写同一片元数据。否则即使每个单独函数都加锁,跨 CUDA stream 的可见性仍可能让半张旧表和半张新表混在一起。
常见工程做法是给序列状态维护 table_epoch。调度线程生成新 table 后一次性发布 epoch,kernel launch 记录自己消费的 epoch;释放或抢占只能作用于不再被任何 in-flight kernel 引用的 epoch。这样做会多一点元数据开销,却能把“谁还在读这张表”从口头约定变成可检查状态。若使用 CUDA graph 或 persistent kernel,更要确认 graph 捕获的是 table 指针还是 table 内容;指针复用后内容被改写,是很难从 Python 层日志看出来的错误。
共享前缀块还需要写保护语义。满块被多个请求引用时,任何请求继续追加 token 都不应在原块上写;正确路径是为追加部分分配私有块,或者在部分块复用方案中触发 copy-on-write。引用计数只能说明“有多少人持有”,不能说明“是否允许写”。因此块元数据最好区分 shared_readonly、private_writable、evicting、restoring 等状态,非法状态转换直接失败,而不是回退成普通 miss。
Namespace bump 是另一个容易被低估的控制面。只要 key 语义改变,旧缓存就应整体隔离:tokenizer 升级、chat template 改动、RoPE 配置调整、KV layout 变更、量化尺度改变,都应生成新 namespace。不要依赖逐条 cache entry 自然过期,因为长生命周期服务里总会有旧块被慢请求或低频前缀拖住。灰度发布时新旧 namespace 并存,命中率会短暂下降,但这是换正确性的成本。
最后,把这些版本字段带入逐 token 对照。首分叉报告里如果能看到 reference 使用 epoch 42、candidate 使用 epoch 43,或者某个共享块从 readonly 变成 writable,定位时间会从几天降到几分钟。缓存系统最怕“快但不可解释”;版本化的目的不是把实现写复杂,而是让每一次复用都有归属、边界和发布时间。
12. 落地清单
实现 PagedAttention 与 prefix caching 时,把“页表正确”写进设计文档,而不是只写显存节省。Block table 的 owner、generation、valid length 可以只在调试和日志路径保留,但生命周期必须由代码显式维护。前缀缓存只复用满块,非满块默认私有;若为了更激进的复用支持部分块,必须有长度校验和 copy-on-write。抢占释放要先让 table entry 不可见,再递增 generation 并清理索引;恢复要在 KV 内容可读后再发布 table。
验收顺序建议固定:先用单请求验证分页读取与连续 KV 等价;再用确定性前缀集验证 prefix key 和满块边界;然后限制 block pool 容量跑强制抢占;最后接入混合长短 prompt 的影子流量做逐 token 抽样。每一步都记录第一处分叉,而不是只给最终通过率。若某个优化无法解释自己的缓存命中、块归属和恢复时序,它还没有资格进入高并发服务。
KV cache 优化的价值很大,但它把模型正确性的一部分交给了系统页表。模型本身没有能力分辨某个 key/value 来自谁;它只会认真地对错误上下文做注意力。吞吐上去之后,真正的验收不是“请求没崩”,而是每个 token 在每一层读到的历史都确实属于它。把这个不变量守住,PagedAttention 和前缀复用才是工程收益;守不住,它们只是更快地制造偶发胡话。
相关
也可以看看
- ·18 分钟阅读
混合精度下的梯度累积:loss 缩放、裁剪与 step 顺序
沿 autocast、GradScaler、unscale_、裁剪与 optimizer.step 的数据流说明静默错误;区分 micro-batch 平均与求和,并用等价性测试验收。
- ·9 分钟阅读
torch.compile 图断裂:先定位重编译,再谈模式选择
从 Dynamo guard、graph break 与动态形状入手,建立可复现的编译诊断和冷启动、稳态验收方法。
- ·3 分钟阅读
PyTorch SDPA 的两个静默坑:布尔 mask 与 eval dropout
拆清 scaled_dot_product_attention 的 keep-mask 语义、广播维度和 dropout_p 行为,用最小张量测试阻止注意力泄漏。
johan's blog