LMCache 源码解析:LMCacheEngine 如何用分块 KV 缓存复用压低 LLM 首字延迟
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
LMCache 是架在推理引擎之上的 KV 缓存复用层。本文解析 lmcache/v1/cache_engine.py,讲清 LMCacheEngine 如何把 token 序列变成可复用的 KV 缓存块,以及缓存引擎的分块前缀哈希、L1 内存分配和 layerwise 流水线是怎么配合的。
30 秒速览(TL;DR)
LMCacheEngine是 KV 缓存的搬运调度中枢:store 把 GPU 上的 KV 转成 CPU 侧MemoryObj,retrieve 再把它写回 GPU- 缓存键 =
chunk_size(默认 256)切块的链式前缀哈希,块间像链子:改一个 token,后续块的键全部变化 - 分配失败不抛错,而是少存/少命中几个块,直接降级
- layerwise 模式用生成器流水线,让第 L 层的 GPU 拷贝与第 L-1 层的落盘重叠执行
它要解决什么问题
长上下文推理的大头开销在 prefill:同样的系统提示词、文档、多轮对话前缀,每个请求都重算一遍 attention 并生成 KV。LMCache 把推理引擎里的 GPU KV 缓存搬到 CPU/磁盘/P2P 后端复用,下次命中前缀直接回载,跳过重复 prefill。
cache_engine.py在整体架构里居中:上层LMCacheManager和 vLLM/SGLang 适配器通过它进出,它左手按 token 分块算键,右手调StorageManager落盘,同时用GPUConnector做 GPU 与 L1(CPU 固定内存)之间的批量搬运。vLLM 的请求路径是:调度前lookup问命中多长前缀,prefill 结束store,命中时retrieve。
跟着一条数据走完全程
以一次 1024-token 的请求为例,走retrieve主链路(chunk_size=256,共 4 块):
1) 算键。retrievecache_engine.py L780-L854 先健康检查、开统计计时,再调token_database.process_tokens。分块在前 token_database.py L334-L356,哈希在 L358-L365:每块产出(start, end, CacheEngineKey),yield 出去,不落地成列表。
2) 定位块。_process_tokens_internalL1708-L1759 拿全部键调storage_manager.get_block_mapping按后端位置分组,再对每个位置batched_get批量取回MemoryObj(KV 张量在 L1 内存里的句柄)。
3) 写回 GPU。L877-L912gpu_connector.batched_to_gpu把整批数据按starts/ends一次性写进引擎的 paged KV buffer,全程不经过 Python 张量拼接。
4) 收尾。L925-L943 逐块ref_count_down释放 L1 占用,返回布尔掩码ret_mask:哪些位置命中,调度器按它裁剪 prefill。
store 侧反向走同一条路 L388-L569:process_tokens生成键 → 逐块storage_manager.allocate→gpu_connector.batched_from_gpu一次拷贝 →storage_manager.batched_put异步分发到各后端。
这条链的关键:KV 张量从头到尾不以整体出现,全部以 256-token 的块为寻址、分配、转移的最小单位。
3 个硬设计决策
① 链式前缀哈希键:一致性靠"链"换命中检查的 O(1)
它解决什么:跨请求、跨进程、跨节点找到同一份 KV。怎么实现:每块哈希输入是(上一块哈希, 本块 token 序列):
def _prefix_hash(self, token_chunks): prefix_hash = self._get_init_hash() for token_chunk in token_chunks: prefix_hash = self._hash_tokens(token_chunk, prefix_hash) yield prefix_hash改一个 token,后面每块键全变,等价于"链子动一环,后面全变"。代价是跨进程共享缓存时哈希必须逐位一致,所以项目专门做了 vLLM 多版本哈希函数适配(token_database.py L123-L148),并在未设PYTHONHASHSEED时打 warning/error(L310-L326)。换掉这种设计的替代方案是每块独立哈希 + 显式前缀元数据:键稳定了,但"命中多长前缀"就得额外比对链结构,batched_contains的顺序前缀语义也没法直接保留。
② 逐块分配 MemoryObj,内存压力就截断降级
它解决什么:GB 级 KV 不可能整体torch.empty,CPU 内存又随时可能被别的请求占满。怎么实现:store 循环里每块单独allocate,返回None就 break,只保留已分配的部分:
for start, end, key in self.token_database.process_tokens(...): memory_obj = self.storage_manager.allocate( kv_shapes, kv_dtypes, fmt=self.fmt) if memory_obj is None: logger.warning("Local cpu memory under pressure so " "choosing to store only %d total chunks of KV cache.", len(memory_objs)) break付出的是"半截前缀"——尾部块丢了;换来的是永不 OOM、永不阻塞请求。retrieve 侧对称处理:某个块batched_get返回None就break并回滚 L1762-L1790,失败点之后的ret_mask清零,避免引擎按掩码读到脏位置。如果换"全量分配或整体失败"的做法,长序列请求会把整段缓存一起丢掉。
③ layerwise 生成器:拷贝与落盘重叠
它解决什么:非 layerwise 路径要等整个序列的 KV 从 GPU 拷完才开始batched_put,PCIe 传输和存储 IO 串行。怎么实现:store_layerL593-L776 是生成器,第一次next启动第 0 层拷贝,之后每层"拷 L 层 → put L-1 层"交错执行:
for layer_id in range(self.num_layers): yield next(mem_obj_generator) # 拷贝第 layer_id 层 self.storage_manager.batched_put( keys[layer_id], memory_objs[layer_id], ...) # 落盘第 layer_id-1 层代价是键要拆成每层一个split_layers,且层式检索要求所有块来自同一位置(L1050-L1054 直接 assert)。如果换"每层独立任务 + 线程池"的实现,能去掉生成器这种手工状态机,但每层的同步点和 ref 计数都要自己兜,复杂度并不更低。
容易忽略的工程细节
- 健康降级三件套:store 直接 return(L416)、retrieve 返回全 False 掩码(L808-L810)、
mark_init_failed让is_healthy()恒为 False,整体退回"全重算"而不会崩 - PD 分离的 sync backend 的
remove不自动减引用,要手动ref_count_down,否则 PD buffer pool 泄漏(L929-L933) - 异步加载:
async_lookup_and_prefetchL1322-L1378 在调度阶段就发起预取,retrieve 时从EventManager的 future 直接拿 L1 数据,把 IO 延迟藏在排队里 - MLA 的
save_only_first_rank:passive rank 跳过 store/retrieve,由 leader 广播 KV;广播时顺手保留 GPU 副本供batched_to_gpu从 HBM 读,省掉二次走 PCIe(L1816-L1915) - 埋点:
@_lmcache_nvtx_annotate+store_stats/retrieve_stats按 process_tokens / from_gpu / put 三段计时,日志直接给 GB 与 GB/s(L577-L589)
想上手用/扩展
入口是LMCacheEngineBuilder.get_or_createL2092-L2143,按instance_id建单例并校验 config/metadata 一致性;销毁走destroy。配置面在 lmcache/v1/config.py:chunk_size、max_local_cpu_size、enable_blending、save_only_first_rank是主旋钮。
扩展点按策略接口挂钩:推理引擎适配实现GPUConnectorInterface(batched_from_gpu/batched_to_gpu);新增存储介质实现 StorageBackend 并注册给StorageManager;改分块方式替换TokenDatabase(L2083-L2090 是工厂,blend 场景换成SegmentTokenDatabase)。跑通示例可看 examples/kv_cache_reuse/,压缩链路参考 docs/source/kv_cache_optimizations/。
数据佐证
- 注释里写了实测:MLA 场景 leader rank 的
batched_to_gpu走 PCIe 约 9ms,passive rank 从 HBM 读约 0.5ms,这个 18 倍差就是 GPU 副本替换优化的动机(L886-L888) - TTFT 估算工具可复现"命中前缀省多少延迟":
benchmarks/ttft-estimator/ttft-estimator.py,LLaMA/H100 示例输出:
总结
LMCacheEngine的三条主线:前缀哈希链解决键一致性,分块分配解决内存压力下的降级,layerwise 生成器解决传输与落盘的重叠。落地可做的优化:batched_contains的 layerwise 分支仍是逐块循环(L1191 有 TODO),批量化后 lookup 延迟能再降;多位置检索(L1049 的 TODO)打通后,热块在 L1 与远端并存时不必强选一个位置;store 侧"内存压力丢尾块"策略,可以演进成按访问频次保留热点前缀块,提高降级后的有效命中率。
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考