LMCache:架构与源码主线
白话导读:沿源码走一遍 LMCache MP 模式:引擎怎样通过 Connector 查找、加载、保存 KV,L1 和 L2 各管什么。
- lookup 的回执只说明“任务已登记”,最终命中数要等结果出来,多个 server 取共同最短前缀。
- L1 写入先进入对外不可见的 staging,数据填完再发布,读者永远看不到半成品。
- L2 adapter 是“提交 → 通知 → 取结果”的异步接口,接口叫 lock,不代表后端一定提供了强锁。
先读LMCache 概览和时序图,再带着“谁持有数据、谁宣布完成、谁负责释放”读本篇。
1. 读之前先定好三个问题
本篇只讲学习版本里的 MP 模式(LMCache 作为独立进程运行)。读每一条代码路径时,都只追问三件事:
- 数据现在归谁管?(谁持有这块内存)
- 谁宣布“完成了”?(什么事件之后别人才能用)
- 谁负责释放?(什么时候能回收)
答得上这三个问题,才算读懂了一段路径。读法建议:先看 docs/design/ 下与模块同路径的设计文档,再看实现,最后看测试。图形化的流程见知识地图与时序图;LMCache 接 Mooncake 的完整链路见联合链路。
下面这张表是源码入口,路径相对 LMCache 仓库根目录:
| 层 | 源码入口 | 读它要回答什么 |
|---|---|---|
| 引擎集成 | lmcache/integration/vllm/lmcache_mp_connector.py | vLLM 的回调怎样转交给 adapter |
| 调度与 worker | lmcache/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.py | lookup 怎样触发预取,结果为什么只能取一次 |
| 设备传输 | lmcache/v1/multiprocess/modules/lmcache_driven_transfer.py | STORE/RETRIEVE 怎样等待设备事件、管理对象寿命 |
| 存储协议 | lmcache/v1/distributed/api.py | ObjectKey、分组 key、内存布局和预取类型的定义 |
| L1 | lmcache/v1/distributed/l1_manager.py | staging、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。顺序是:
- 发送 LOOKUP,server 回 ack,表示查找/预取任务登记好了。
- 收到这个 server 的 ack 之后才去查状态。否则可能查询比登记先到,被当成“没有这个任务”。
- 查询返回
None表示还没完成;返回整数表示最终命中的 chunk 数。 - 有多个 server 时(学习版本的做法),取大家都命中的最短前缀;其他 server 多命中的尾部用不上,要把它们的锁释放掉。
- 结果一旦确定,就不能被迟到的响应改掉;超时和请求结束都要有清理路径。
实现细节上:用 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 区,写完才发布。读者永远看不到“地址分配了、内容还没搬完”的对象。
reserve_write(key, tag)预留 staging;数据填好后调用 finish 接口,才进入 admission(变成可读)。- 同一个 key 可以有多个写者(用不同 writer tag 区分)各自 staging。第一个发布的成为 resident,后完成的发现已经有了,就丢弃自己的 staging。同一个 tag 的写入仍然互斥。
finish_write_and_reserve_read在同一个临界区里“发布 + 加读保留”,防止刚发布就被淘汰。reserve_read保证读的期间对象有效,读完必须调用finish_read。正在被读、正在传输或被保留的对象,普通淘汰不能动它。- 写锁过期后,被放弃的 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 配置):
- 多个 L2 后端都有某个对象时,默认选第一个 adapter,并不会自动测延迟挑最快的。
- 从 L2 加载上来的对象默认是临时的,读者用完就删;配置
retain策略才会保留。 - 保存时默认写入所有配置的 L2 并保留 L1 副本;配置
skip_l1则写完 L2 后删掉 L1 副本。
准入按内存算,不按任务数算:控制器同时统计 staging 已占用的字节,以及已经查完、还没分配空间的请求最坏要加载的字节。这样能避免一批请求在“内存看起来还很空”时同时涌入。只限制任务个数不行,因为一个 128K 请求和一个 1K 请求的占用差了上百倍。
每个 adapter 完成后可以单独发布,不用等最慢的后端。最后只对“成功且受保护”的数据折叠出可用前缀;失败、取消和关闭时,按记录下来的锁和预留集合逐一清理。某个故障能不能重试、能不能只用一部分,要看调用方的约定。
7. L2 adapter 的接口约定
结论:adapter 是“提交 → 等通知 → 取结果”的异步接口。接口里叫 lock,不代表每个后端都实现了强锁。
- 保存、查找、加载各有自己的完成通知 fd,事件不会串。
- “还没完成”和“完成了但全部没命中或失败”是两种不同的结果。
- 查找结果表示“找到了并且已经锁住”;加载完仍要检查每一项是否成功。
- 部分查询结果只能取一次;保存完成队列用 pop 方式取空。
submit_unlock要求 adapter 自己保证最终解锁成功,控制器不会替它重试。- 在 adapter 完成之前,控制器一直持有调用方 buffer 的保留,不能提前释放。
这些是接口层面的要求。具体后端可能用租约模拟锁,或者只做弱一些的保证,出故障时要核对 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. 源码阅读练习
- 从
get_num_new_matched_tokens一路追到最终的命中整数,标出每一个异步边界。 - 从
store追到 L1 admission,找到确认“设备数据已经写完”的位置。 - 找到预取失败时释放 L1 staging、L1 读锁和 L2 锁的代码。
- 读
tests/v1/distributed/test_l1_manager.py中关于 staging 的测试,用公开接口解释“两个写者、一个 resident”应有的结果。 - 读
tests/v1/test_mp_lookup_state.py,解释 ack 顺序为什么重要、重复查询有什么风险。
如果只记住了函数名,却说不出“谁持有、谁释放”,就继续跟踪同一个请求,不必扩大阅读范围。