LMCache源码解析:KV缓存层如何让长上下文推理跳过Prefill(完整指南)
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
LMCache 是一个面向 LLM 推理的KV 缓存层:它把推理引擎(如 vLLM)在 prefill 阶段产出的 KV cache 按分块切分,存到 CPU、本地磁盘或远端存储;当新请求带有相同前缀时,直接复用已有 KV,跳过最耗算力的重计算环节。本文以当前源码中 LMCacheEngine 的写入与检索路径为主线,把缓存键怎么算、连续前缀命中怎么校验、层间流水线怎么跑,逐一走查一遍,带你完整理解这套 KV 缓存复用机制的内部实现。
为什么 prefill 重算是长上下文推理的最大浪费
多轮对话、RAG、长文档问答有个共同特征:每次请求都背着一大段历史前缀,而 GPU 默认会对这段前缀的 KV 重新算一遍。序列越长,prefill 的 FLOPs 和显存带宽开销越大——但这段前缀在很多请求之间是完全相同的,重算纯属浪费。
LMCache 的思路很直接:把 KV cache 从模型的"临时副产品"变成可命名、可落盘、可检索的资产。这立刻引出四个工程问题:
- 命名:一段 token 序列如何生成稳定、唯一的缓存键?
- 写入:如何把 KV 从 GPU 搬出去,又不拖慢正在跑的推理?
- 检索:如何快速回答"这个请求最长能复用多长的前缀",且只取真正能用的部分?
- 降级:缓存层故障时绝不能把推理主链路带崩。
下面按"存什么 → 怎么存 → 怎么找 → 怎么省 → 怎么测"的顺序逐一作答。
先理清 KV 缓存数据流:一块数据如何从 GPU 走到存储再走回来
先建立全局观。LMCacheEngine 的类注释(代码83-98行)把职责边界说得很清楚:store 时把 GPU 上的 KV 转成驻留 CPU 的MemoryObj再异步落入后端;retrieve 时反向操作,并通过针对具体引擎定制的 GPUConnector 写回 GPU。也就是说,引擎本体不关心数据最终躺在哪里,它只做编排。
真正干活的是三个角色:
- TokenDatabase(lmcache/v1/token_database.py):把 token 序列切成 chunk,逐个产出
(start, end, key)三元组,是键计算的唯一入口; - GPUConnector(lmcache/v1/gpu_connector/):
batched_from_gpu/batched_to_gpu,封装各引擎的 KV 布局差异(vLLM 的 paged KV buffer、slot_mapping 等); - StorageManager(lmcache/v1/storage_backend/storage_manager.py):统一管理 LocalCPU、LocalDisk、P2P、NIXL 等存储后端,对外只暴露
allocate / batched_put / batched_get / batched_contains等批接口。
store/retrieve/lookup三个入口方法,就是引擎对外仅有的三条主路径,后面分节展开。
引擎实例由谁创建:LMCacheEngineBuilder 的注册表模式
引擎不是随处new出来的。LMCacheEngineBuilder(代码1999-2003行)在类级别维护_instances / _cfgs / _metadatas三个字典,按"配置+元数据"组合返回同一实例。这是一个注册表式的工厂:在多进程、多方调用的服务进程里,同一套参数反复请求引擎时不会重复初始化存储池,也保证了监控状态和内存池全局唯一。
缓存键是怎么算出来的:分块切分与前缀链式哈希
CacheEngineKey:五个字段各自负责什么
检索的前提是命名。当前版本的缓存键定义在 lmcache/utils.py(代码389-443行),序列化形态一目了然:
def to_string(self): return ( f"{self.model_name}@{self.world_size}" f"@{self.worker_id}@{self.chunk_hash_hex}@{self._dtype_str}" )五个字段的分工:model_name隔离不同模型;world_size / worker_id隔离不同张量并行拓扑下的分片(同一模型、不同并行度,KV 切片内容不同,绝不能互串);chunk_hash标识块内容;dtype防止精度升级后误用旧缓存。层间流水线场景下还有多一个layer_id字段的 LayerCacheEngineKey(代码562-581行),把"一个块"细化到"一个块的一层"。
链式哈希:每个块都背着前缀的指纹
真正有意思的是 chunk_hash 怎么算。TokenDatabase(代码250-296行)支持builtin或 vLLM 的sha256_cbor等算法,_hash_tokens会先把输入规范化(空的前缀哈希统一替换为NONE_HASH),再进_prefix_hash:
prefix_hash = self._get_init_hash() for token_chunk in token_chunks: prefix_hash = self._hash_tokens(token_chunk, prefix_hash) yield prefix_hash见 代码358-365行。每个块的哈希 = f(前缀所有块的哈希, 本块 token),像书籍目录里的"累计页码"而非"单页页码"——同一段文字出现在不同位置,键必然不同。这带来两个直接收益:一是前缀匹配天然正确,"第 100 块"永远只能命中真正以相同前缀开头的序列;二是增量复用,对同一条长序列做 store/lookup 时,已算过的前缀哈希无需重算,块数越多省得越多。yield的生成器写法还让切分过程零额外内存,边算边用。
写入路径怎么走:从 allocate 到 batched_put
store()(代码386-589行)一共五步,值得逐步拆开看。
第一道闸是降级开关。入口处先查is_healthy()(代码260-278行),初始化不可恢复失败或健康检查不通过时直接跳过写入;freeze冻结模式(代码335-349行)则更精细:停止一切写入、只允许从 LocalCPU 读取,保护热缓存不被污染。缓存层是旁路,推理是主链路——旁路必须先学会"闭嘴"。
然后是切分、分配、搬运。核心循环长这样(节选自 代码484-514行):
for start, end, key in self.token_database.process_tokens( tokens, hashes, offsets, mask, request_configs=request_configs, ): kv_shapes = self.metadata.get_shapes(end - start) memory_obj = self.storage_manager.allocate( kv_shapes, kv_dtypes, fmt=self.fmt, ) if memory_obj is None: break # CPU 内存吃紧,只存已分到的部分这里藏着一条重要的防御策略:process_tokens是生成器,块是逐个流出来的;一旦allocate返回None(CPU 内存池告急),循环立刻 break,把已经分到的块存进去,剩下的放弃。宁可少存,不做 OOM 冒险。分配成功后经batched_from_gpu(代码557-558行,引擎相关的 GPU→CPU 批量拷贝)落到MemoryObj,再走batched_put落入后端(代码560-569行),落盘位置由store_location决定,可以配置成与读取位置不同。
另一个细节:开启 KV 事件后,每个块会生成一条带parent_block_hash的CacheStoreEvent(代码524-551行),块与块串成链,供 vLLM 等外部系统的 KV 事件接口观测缓存拓扑。
层间流水线:让拷贝和落盘互相盖住对方的延迟
非层间模式下,一次 store 要等"全部 KV 从 GPU 搬完"才能开始落盘。store_layer()(代码591-776行)把它改造成生产者-消费者流水线:
for layer_id in range(self.num_layers): yield next(mem_obj_generator) self.storage_manager.batched_put( keys[layer_id], memory_objs[layer_id], location=self.store_location )见 代码751-756行。yield把控制权交回推理引擎去算下一层,next(...)再推进第 i 层的 GPU→CPU 拷贝,同时把第 i-1 层塞进存储——搬运与落盘在时间上重叠,端到端延迟被压向两者中的较大值而非两者之和。此外,写入前它会用contains检查每个块首层的键,已存在就整块跳过(代码674-679行),重复前缀不产生重复写。
数据怎么找回来:前缀命中的检索路径与连续校验
lookup 与 retrieve 分工:先查再取,别白搬
lookup()(代码1129-1250行)像图书店的"有货查询":只回答"前缀命中到第几个 token",不移动任何数据。它把 TokenDatabase 产出的键列表交给storage_manager.batched_contains批量查询(代码1228-1240行),从第 0 块开始累加res = end,一旦某块未命中立即返回——答案永远是"连续命中前缀的长度",而不是散落的块数。
调度器拿到这个长度后会做两件事之一:直接走retrieve()取数;或者用async_lookup_and_prefetch(代码1321-1378行)把"查询+预取"丢进后端的异步事件循环,与请求的关键路径解耦,等真正需要数据时往往已经在 CPU 侧就位了。pin=True的 lookup 还会把命中的块登记进lookup_pins,防止预取途中被逐出。
连续校验:中间断一块,后面全部作废
retrieve()的主逻辑在_process_tokens_internal(代码1708-1814行):先get_block_mapping按存储位置分组,再对每个位置batched_get。关键规则是前缀必须连续,看这段核心判断(节选自 代码1762-1777行):
for (key, start, end), memory_obj in zip(blocks, memory_objs, strict=False): if memory_obj is None: if last_failed_block_start is None or last_failed_block_start > start: last_failed_block_start = start break reordered_chunks.append((key, memory_obj, start, end)) ret_mask[start:end] = True任何一个块"键存在却取不出来"(后端压力、网络抖动),就记录断点并break;随后 代码1789-1790行把ret_mask从断点起全部重置为 False,并对已取来但注定用不上的块执行ref_count_down释放。为什么这么苛刻?因为 KV 存在自回归依赖,中间断一块,后面的 KV 就失去了意义——宁短勿错。
还有个容易忽略的边界:chunk_size(默认 256)和引擎的 page size 不一定对齐,retrieve 返回的 token 数可能多于请求数。源码注释(代码945-956行)给了例子:引擎已算 288 token,缓存命中 512,多出的 32 token 与已有 KV 重叠,覆盖是幂等无害的。
最后batched_to_gpu把MemoryObj写回引擎,代码925-937行逐个unpin+ref_count_down收尾。MLA 模型下还有条save_only_first_rank快路径:只有 rank 0 真正读存储,其余 rank 通过 broadcast 接收(代码856-871行、代码1816-1915行),把 (world_size-1) 份重复 IO 直接省掉——广播时顺便在 GPU 上留一份副本当 NCCL 发送缓冲,让 leader 的batched_to_gpu读 HBM 而不是回 PCIe 重读 L1,注释里标了这条路径约 9ms 的差距(代码880-905行)。
怎么测缓存是否真的有用:监控钩子与 TTFT 估算
监控采用单例LMCStatsMonitor.GetOrCreate()(代码222行),钩子嵌在每条主路径上:
store_stats = self.stats_monitor.on_store_request(num_to_store_tokens) # ... process_tokens / from_gpu / put 三段各自被 profile_* 包裹 ... self.stats_monitor.on_store_finished(store_stats, tot_token_num)见 代码469行 与 代码571-574行。on_*_request入口返回 stats 对象,中间阶段用profile_process_tokens / profile_from_gpu / profile_put分段计时,on_*_finished结算。这套钩子让 store/retrieve 的每条日志都带结构化结论——"存了 X token,Y GB,耗时 Z ms,吞吐 W GB/s"(代码577-589行、代码958-969行),调优时一眼能看出时间花在了切分、搬运还是落盘。
部署前想评估收益,仓库还带了个 TTFT 估算器 benchmarks/ttft-estimator/:给定模型、硬件与上下文长度,画出首 token 延迟随序列增长的曲线,不用起真机就能回答"这个场景下缓存值不值":
端到端基准则集中在 benchmarks/(RAG、多文档问答、多轮问答等场景),可直接复现对比。
还能往哪扩展:从单机到 PD 分离与多节点
单引擎只是起点,cache_engine.py里已经埋好了几条扩展线索:
- 存储后端是策略:StorageManager 背后是 LocalCPU(热层)、LocalDisk(冷层)、P2P、NIXL 等多种实现,
store_location/retrieve_locations允许写读不对称——这正是 PD(prefill-decode)分离的基础; - PD 场景特化:
remove_after_retrieve(代码194行)让接收方取完即删,缓存退化为"传输通道"而非复用池; - 运行时管控:freeze / hot cache 可动态开关(代码335-384行),
compress/decompress支持 cachegen 有损压缩(代码1422-1480行),move支持跨节点迁移(代码1251-1318行); - 多进程架构:LMCache 还有完整的 MP 模式(coordinator + worker),见 docs/source/mp/ 与下图,适合把缓存池从单节点扩到整个集群。
延伸阅读建议按这个顺序:docs/source/kv_cache/(缓存机制文档)→ examples/kv_cache_reuse/(各类后端复用配置样例)→ tests/v1/test_cache_engine.py(引擎行为的测试即文档)。
小结
回头看这套"又快又省"的实现,可以浓缩成三条主线:键设计用前缀链式哈希把"内容+位置"编进一个可增量计算的整数,检索因此天然支持前缀语义;路径编排把切分、搬运、落盘拆成生成器和流水线,用重叠和背压(内存不足即 break)换取吞吐与稳定性的双赢;可观测则把每条路径包进统一的 stats 钩子,让"缓存到底省了多少"成为可量化、可回归的问题。掌握这三条线,基本就掌握了在 vLLM 生态里做 KV 缓存复用与推理加速的方法论。
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考