LLM Infer Infra 学习资料库

LMCache:架构与源码主线

白话导读:沿源码走一遍 LMCache MP 模式:引擎怎样通过 Connector 查找、加载、保存 KV,L1 和 L2 各管什么。

  • lookup 的回执只说明“任务已登记”,最终命中数要等结果出来,多个 server 取共同最短前缀。
  • L1 写入先进入对外不可见的 staging,数据填完再发布,读者永远看不到半成品。
  • L2 adapter 是“提交 → 通知 → 取结果”的异步接口,接口叫 lock,不代表后端一定提供了强锁。

先读LMCache 概览和时序图,再带着“谁持有数据、谁宣布完成、谁负责释放”读本篇。

1. 读之前先定好三个问题

本篇只讲学习版本里的 MP 模式(LMCache 作为独立进程运行)。读每一条代码路径时,都只追问三件事:

  1. 数据现在归谁管?(谁持有这块内存)
  2. 谁宣布“完成了”?(什么事件之后别人才能用)
  3. 谁负责释放?(什么时候能回收)

答得上这三个问题,才算读懂了一段路径。读法建议:先看 docs/design/ 下与模块同路径的设计文档,再看实现,最后看测试。图形化的流程见知识地图与时序图;LMCache 接 Mooncake 的完整链路见联合链路。

下面这张表是源码入口,路径相对 LMCache 仓库根目录:

层源码入口读它要回答什么
引擎集成lmcache/integration/vllm/lmcache_mp_connector.pyvLLM 的回调怎样转交给 adapter
调度与 workerlmcache/integration/vllm/vllm_multi_process_adapter.py命中计划、block 分配、加载与保存何时完成
查找状态lmcache/integration/vllm/mp_lookup_state.py回调、超时、多个 server 的结果怎样合并与清理
server 装配lmcache/v1/multiprocess/server.py各模块、服务和存储管理器怎样组装起来
查找与预取lmcache/v1/multiprocess/modules/lookup.pylookup 怎样触发预取,结果为什么只能取一次
设备传输lmcache/v1/multiprocess/modules/lmcache_driven_transfer.pySTORE/RETRIEVE 怎样等待设备事件、管理对象寿命
存储协议lmcache/v1/distributed/api.pyObjectKey、分组 key、内存布局和预取类型的定义
L1lmcache/v1/distributed/l1_manager.pystaging、resident、读写保留和淘汰限制
跨层控制lmcache/v1/distributed/storage_manager.py把 L1、各 controller 和 adapter 组合起来的统一入口
L2 扩展lmcache/v1/distributed/l2_adapters/base.py异步提交、完成通知、取结果和解锁的接口约定

2. Connector 不是简单的 put/get

结论:能正确存取字节的后端,不等于已经接好了推理引擎。中间的 Connector 负责把“引擎语义”翻译成“缓存请求”,这一层出错同样会导致模型输出错误。

原因是引擎的两部分需要不同的信息:调度器要知道“有多少输入可以不算”,worker 要知道“数据该搬进哪些物理 GPU block”。Connector 在两者之间传递这些信息,同时还要处理请求结束、抢占、引擎自带前缀缓存(APC)已经覆盖的 token,以及异步保存。

举三个即使后端完全正确也会出错的例子:Connector 把已计算 token 数标错;把数据搬进了已经被别的请求复用的旧 block;在保存完成前提前释放了 page。

怎么读:在 lmcache_mp_connector.py 里先找 get_num_new_matched_tokens、start_load_kv、wait_for_save、get_finished 这几个回调,再顺着调用进入 adapter。函数名会随 vLLM 版本变化,要抓住的是“调度决定”和“数据完成”之间的关系。

3. 一次 lookup 到底返回了什么

结论:lookup 的回执(ack)只表示“任务已经登记”,不是命中结果。最终命中多少 chunk,要单独查询。

LookupState 对每个 server 管理两类消息:LOOKUP 和 QUERY_PREFETCH_STATUS。顺序是:

  1. 发送 LOOKUP,server 回 ack,表示查找/预取任务登记好了。
  2. 收到这个 server 的 ack 之后才去查状态。否则可能查询比登记先到,被当成“没有这个任务”。
  3. 查询返回 None 表示还没完成;返回整数表示最终命中的 chunk 数。
  4. 有多个 server 时(学习版本的做法),取大家都命中的最短前缀;其他 server 多命中的尾部用不上,要把它们的锁释放掉。
  5. 结果一旦确定,就不能被迟到的响应改掉;超时和请求结束都要有清理路径。

实现细节上:用 future 回调减少调度器每一步之间的等待,同时限制查询频率,避免“返回 pending → 立刻再查”的空转。查询结果只能取一次,重复查询不保证结果相同;复用 request id 也要遵守会话生命周期。

设计依据是 docs/design/integration/vllm/mp_lookup_state.md。这份文档也写明了“查找途中取消”等已知边界,所以不要把设计目标说成每条路径都已完美处理。

4. key、chunk 和 group

结论:key 里有哪些字段,只说明“可以表达什么”;模型版本、dtype、LoRA 是否真的被区分开,要顺着调用链去确认。

学习版本的 ObjectKey 包含五个字段:

chunk_hash: bytes          内容和前缀身份
model_name: str            模型
kv_rank: int               第几个 KV 分片
object_group_id: int = 0   同一 chunk 的哪一组对象
cache_salt: str = ""       命名隔离

hash 由 lmcache/v1/multiprocess/token_hasher.py 中的 TokenHasher 计算,学习版本默认用 blake3,也提供与 vLLM hash 接口兼容的方式。跨语言、跨进程、跨部署共享缓存时,算法、seed、输入编码和命名空间必须完全一致。

混合模型的一个 chunk 会对应多个对象组和多个 KV rank。GroupedObjectKeys 把命中情况排成矩阵,每行是一个“组 × rank”,每列是一个 chunk:

                  chunk 0  chunk 1  chunk 2
full group/r0        1        1        1
full group/r1        1        0        1
another group/r0     1        1        1
another group/r1     1        1        1

按普通全注意力的规则,只有 chunk 0 能安全复用:chunk 1 有一格缺失,后面的 chunk 即使都在也不能用。不能把矩阵里的 1 加起来当作能跳过的 token。滑窗组按窗口规则判断,递归状态组需要对应边界的状态快照,都不能套用这个简单的“按列取与”。相关类型在 api.py,合并策略在 bitmap 和预取策略的实现里。可以在互动演示里点格子体会这个规则。

5. L1 写入:先 staging,再发布

结论:新数据先写进对外不可见的 staging 区,写完才发布。读者永远看不到“地址分配了、内容还没搬完”的对象。

三个名字容易混:

标识用来区分什么不是什么
writer tag同一个 key 的不同 staging 写者不是内容身份
object key缓存内容和分片不是内存地址
L1 owner id多个 L1 时,是哪个 manager 分配的对象;完成回调必须回到它不是内容 key,也不是跨机器的持久地址

设计依据:docs/design/v1/distributed/l1_manager.md 和 docs/design/v1/distributed/storage_manager.md。

6. L2 预取:一次规划,不是逐层扫描

结论:预取不是“先查 L1、没有再查 L2”这样串行扫描,而是先把各层的命中情况都拿到,再一次性决定每个 chunk 从哪里取。

PrefetchController 在 L1 用“读保留”观察对象,在 L2 用 lookup_and_lock 观察对象,把各层的 bitmap 合并后规划来源。规划时有两条规则:每个要加载的格子必须在它的来源层被保护住;同一个格子不能加载两次。从 L2 加载的数据先进入 L1 staging,发布后才能被 retrieve 使用。

几个默认行为(学习版本,详见 MP 配置):

准入按内存算,不按任务数算:控制器同时统计 staging 已占用的字节,以及已经查完、还没分配空间的请求最坏要加载的字节。这样能避免一批请求在“内存看起来还很空”时同时涌入。只限制任务个数不行,因为一个 128K 请求和一个 1K 请求的占用差了上百倍。

每个 adapter 完成后可以单独发布,不用等最慢的后端。最后只对“成功且受保护”的数据折叠出可用前缀;失败、取消和关闭时,按记录下来的锁和预留集合逐一清理。某个故障能不能重试、能不能只用一部分,要看调用方的约定。

7. L2 adapter 的接口约定

结论:adapter 是“提交 → 等通知 → 取结果”的异步接口。接口里叫 lock,不代表每个后端都实现了强锁。

这些是接口层面的要求。具体后端可能用租约模拟锁,或者只做弱一些的保证,出故障时要核对 adapter 是否真的满足约定。比如后端只提供 exists 查询,就不能认为它能原子地阻止下一刻被淘汰。学习版本中接 Mooncake 的 native adapter 就是在客户端本地维护锁计数,详见联合链路。设计依据:docs/design/v1/distributed/l2_adapters/overall.md。

8. 设备拷贝、布局与异步完成

结论:RPC 返回、Python 变量被删除,都不代表 GPU 上的拷贝已经完成。判断完成要靠设备事件。

GPU 里的 KV 是分页存放的,L1 里的 chunk 是连续存放的,两边布局通常不同,需要收集/散射(gather/scatter)或格式转换。学习版本把布局识别集中在 normalize_kv_and_discover_format 一处,其他模块通过 EngineKVFormat 和公开的辅助函数查询,不应该各自猜 tensor 维度。

LMCache 驱动的传输可以走“staging buffer + kernel”,条件满足时也可以走直接批量拷贝。用哪条取决于布局、驱动和内存类型。所以“所有拷贝都要占用 SM”和“所有配置都能直接拷贝”这两种说法都不对。

一条正确的依赖链是:

引擎写完 KV,记录完成事件
  → 传输 stream 等待这个事件
  → 执行 gather/拷贝 或 直接拷贝
  → 传输完成事件或回调
  → 发布到 L1 / 允许 GPU 使用 / 释放对应引用

由引擎发起传输的路径,发起位置不同,但这条依赖关系同样要满足。stream 和 event 的基础见 GPU 与 kernel 基础。

9. 抢占、APC 与混合模型

抢占:抢占会释放 GPU page 并交给别的请求。如果异步保存还在按旧的 block id 读数据,就会把别的请求的 KV 存到这个请求的 key 下——这是很隐蔽的错误。恢复时,“已计算 token 进度”和“可加载范围”也必须对得上。docs/design/integration/vllm/mp_preemption_correctness.md 讲了为什么要按阶梯验证:低并发参考 → 高并发无缓存 → 冷缓存 → 热缓存 → 有抢占的热缓存。

混合模型:不同注意力组的 block 大小和状态形式不同,chunk 大小协商和分组 bitmap 是保证正确性的机制,不只是性能优化。各类结构的规则见复杂模型与 KV 存储。

SGLang:SGLang 有自己的 radix 树生命周期,接入方式见 docs/design/integration/sglang/lmcache-unified-radix-cache.md,不要把 vLLM 调度器的细节直接套过去。

10. 源码阅读练习

  1. 从 get_num_new_matched_tokens 一路追到最终的命中整数,标出每一个异步边界。
  2. 从 store 追到 L1 admission,找到确认“设备数据已经写完”的位置。
  3. 找到预取失败时释放 L1 staging、L1 读锁和 L2 锁的代码。
  4. 读 tests/v1/distributed/test_l1_manager.py 中关于 staging 的测试,用公开接口解释“两个写者、一个 resident”应有的结果。
  5. 读 tests/v1/test_mp_lookup_state.py,解释 ack 顺序为什么重要、重复查询有什么风险。

如果只记住了函数名,却说不出“谁持有、谁释放”,就继续跟踪同一个请求,不必扩大阅读范围。

输入主题、术语或问题