LMCache源码解析:KV缓存层如何让长上下文推理跳过Prefill(完整指南)
2026/9/14 2:50:47 网站建设 项目流程

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 从模型的"临时副产品"变成可命名、可落盘、可检索的资产。这立刻引出四个工程问题:

  1. 命名:一段 token 序列如何生成稳定、唯一的缓存键?
  2. 写入:如何把 KV 从 GPU 搬出去,又不拖慢正在跑的推理?
  3. 检索:如何快速回答"这个请求最长能复用多长的前缀",且只取真正能用的部分?
  4. 降级:缓存层故障时绝不能把推理主链路带崩。

下面按"存什么 → 怎么存 → 怎么找 → 怎么省 → 怎么测"的顺序逐一作答。

先理清 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_hashCacheStoreEvent(代码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_gpuMemoryObj写回引擎,代码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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询