UCM 演进之路:从 commit 史看一个 KV Cache 系统如何长出来
大多数开源项目的「架构介绍」都是回头写的:作者看着今天的代码,把结果讲成一个本来就想好的故事。这种叙述不算撒谎,但会丢掉最有信息量的部分——那些走错又走回来的路、那些当时只能这么写的妥协、那些后来被迫回头补的抽象。
这篇文章换个方向:不看文档,看 commit。我把 UCM(Unified Cache Manager)仓库的 775 条 develop 主线提交按时间摊开,重建每个阶段“当时在解决什么问题”,再用 --stat 去验证每一次架构跃迁到底动了多少文件。所有 hash、日期、文件数、行数都在本地 bare 镜像上现场跑过;凡是推断而不是记录的东西,我会明确标出来。
先说结论,三条:
- 这个系统不是设计出来的,是被需求逼出来的,而且逼它的不是“介质多”。 反直觉的一点:接 DRAM、NFS、Mooncake 三种介质的那六周里,抽象类一个方法都没加。真正让接口变长的是另外两类需求——算法特性(稀疏注意力要“按重要度筛选 KV”)和模型结构(混合模型的 Mamba 块要反向查找)。真正的接口契约(
StoreV1)诞生于 2025-12,距第一行代码五个月;而中间那五个月,仓库里一直有一个“够用”的 Python 抽象类在顶着。 - 它最强的设计习惯是“只增不改”。
StoreV1从 2025-12 定稿到 2026-10,抽象方法从 7 个长到 11 个,一个都没删。代价是主接口越来越胖,最终 v2 把它拆成了能力 facet——这是一次真正的范式更换,不是版本号加一。 - 它最弱的地方是适配层的忍让。 对 vLLM 的 monkey patch 从 1 个版本目录涨到 15 个,最后不得不在一次提交里补三个版本。这是把上游不稳定性直接吸收进自己仓库的典型代价。
统计口径与数据源
先把尺子交代清楚,否则后面的数字没法对齐。
- 仓库:主仓
ModelEngine-Group/unified-cache-management;本地只读 bare 镜像取自作者的mag1c-h/unified-cache-management。两者developHEAD 同一个提交(4e47254),主线历史完全一致;但feature/v2、dev-ucm-v2、dev-transport、dev-cache-mp-test、debug-perf-2609-mag1c这些分支只存在于作者镜像,所以文中涉及分支级的观察(v2 分叉、传输层划线、缓存多进程改造)要按镜像去看。 - 主线:
develop分支,git rev-list --count develop= 775;镜像的 10 个分支去重后 827,差额来自 9 个非主线分支上未合入的提交。 - 统计区间:2025-07-10(首提交)至 2026-10-22(
4e47254,主线末次提交)。 - 作者数按提交邮箱去重,
shortlog口径全期 79 人。 - 增删行数用
git log --shortstat累加,包含文档与测试,不做剔重。 - 版本节点来自 GitHub Releases API(26 条,含 draft 与 nightly),因为镜像里没有 tag——
git tag返回空。这是个值得记住的坑:本地镜像能还原代码史,但发布史必须另找证据。 - 截止日:2026-10-23。
主线里程碑(日期 / commit / 事件)
2025-07-10 f0f9455 initial commit —— readme.md 一行2025-07-24 94356b5 init repo and add intergration(17 文件 1394 行,vLLM connector + OceanStor)2025-09-05 f939165 change code directory(unifiedcache → ucm,110 文件)2025-09-25 35c81a1 redefine store interface and structure(118 文件 +4071/-1645)2025-11-20 3127481 ucmtrans:统一 Device-Host 传输2025-12-13 319421f StoreV1 诞生(ucmstore_v1.h,122 行)2025-12-19 d9dc59c CacheStore / PosixStore / PipelineStore 一起落地2026-01-16 cd4239f PipelineStore 架构刷新(加 LookupOnPrefix)2026-05-14 e418c03 dev-transport:14 个 0 字节文件,纯划线2026-08-26 f031c5a P2P transport 落地(21 文件 4124 行)2026-09-21 3282983 传输层连接与内存生命周期重构(+2307/-2820)2026-10-23 fa84383 UCM.v2 接口契约(64 文件 9825 行,仍未进 develop)提交量按阶段(每 █ ≈ 10 次提交)
P0 1P1 86 ████████▌P2 165 ████████████████▌P3 106 ██████████▌P4 210 █████████████████████P5 126 ████████████▌P6 81 ████████阶段划分:七段,每一段都有明确的“当时的问题”
| 阶段 | 时间范围 | 提交量 | 代表 commit | 这阶段解决的核心问题 |
|---|---|---|---|---|
| P0 起点 | 2025-07-10 ~ 07-23 | 1 | f0f9455 |
占住仓库名和方向,还没有代码 |
| P1 Connector 扩张 | 2025-07-24 ~ 09-04 | 86 | 94356b5 da7b0fd 385325b |
证明“KV Cache 可以卸载到多种介质”,用 Python 抽象类接住全部厂商实现 |
| P2 目录重构与第一版接口 | 2025-09-05 ~ 10-31 | 165 | f939165 35c81a1 d9b68aa |
多人并行时目录和接口必须先稳住,否则每次 PR 都撞车 |
| P3 公共底座与 StoreV1 | 2025-11-01 ~ 12-31 | 106 | 3127481 4efe8f5 319421f d9dc59c |
抽出设备-主机传输与基础设施,把“存储实现”变成“可组合的 Pipeline 层” |
| P4 Pipeline 化与 IO 引擎 | 2026-01-01 ~ 05-14 | 210 | cd4239f 5269d64 b580d2a e418c03 |
让存储层能按配置拼装、能压缩、能换 IO 引擎,并提前给传输层划线 |
| P5 平台化 | 2026-05-15 ~ 08-31 | 126 | 127647c b3b3f29 f031c5a c56c8f9 |
从“能跑”到“能运营”:指标、工具链、P2P 传输、委托层(Delegator) |
| P6 v2 分叉与收敛 | 2026-09-01 ~ 10-23 | 81 | 003afe7 fa84383 |
v1 主接口已经装不下新语义,另写一份契约而不是继续加方法 |
七段相加 1 + 86 + 165 + 106 + 210 + 126 + 81 = 775,与主线总数吻合。这个吻合本身是个检查点:如果哪段数字对不上,说明我用错了时间边界。
起点:一行 readme,和一个 Python 抽象类
第一次提交是 2025-07-10 的 f0f9455,作者 Mag1c.H,提交信息只有 initial commit.。git show --stat 的结果是:1 个文件,1 行内容——readme.md,正文是 # Unified Cache Manager。
$ git show --stat f0f9455 readme.md | 1 + 1 file changed, 1 insertion(+)两周后的 2025-07-24,94356b5「init repo and add intergration」一次性进了 17 个文件、1394 行,目录结构是这样的:
unifiedcache/├── integration/vllm/│ ├── uc_connector.py # 144 行,继承 vLLM 的 KVConnectorBase_V1│ └── uc_connector_impl.py├── ucm_connector/│ ├── base.py # UcmKVStoreBase,存储厂商实现这个│ ├── factory.py│ └── ucm_oceanstor.py # 第一个真实介质:OceanStor└── logger.pyexamples/vllm_kv_offload.pytest/test_uc_connector_impl.py第一天就分成两半:左边贴 vLLM,右边贴存储。这个划分后来一直没变,只是每半都膨胀了十倍。
当时想解决的问题写在 base.py 的 docstring 里,一句话:“Storage vendor implements this interface to support KV Cache centric inference system.” 接口有 7 个方法,我今天读还是觉得这版设计得相当克制:
class UcmKVStoreBase(ABC): def create(self, block_ids: List[str]) -> int: ... def lookup(self, block_ids: List[str]) -> List[bool]: ... def prefetch(self, block_ids: List[str]) -> None: ... def load(self, block_ids, offset, dst_tensor) -> Task: ... def dump(self, block_ids, offset, src_tensor) -> Task: ... def wait(self, task) -> int: ... def commit(self, block_ids: List[str], is_success: bool = True) -> None: ...注意最后那个 commit 的注释:“if False, we need release block”。这句话是整篇文章里最值得留意的一行——它说明两阶段准入(先占位、再确认)从第一版就被想到了。一年后 v2 的接口文档把同一件事正式命名为「两阶段准入与原子性保证」,但语义没变。有些设计决策并不产生于深思熟虑的架构评审,而是产生于“我这个 dump 可能失败,那占的块得能退”这种朴素的工程直觉。
P1:六周接三种介质,方法数没变
2025-07-24 到 09-04,86 次提交,主线从 1 个文件长到能跑起来的系统。这一段的主旋律是横向铺介质:
| 日期 | commit | 事件 | 规模 |
|---|---|---|---|
| 2025-07-26 | aa6cb83 |
adapt vllm v0.9.2,只改 1 个文件 8 增 7 删 | 微调 |
| 2025-07-28 | 95413be |
加 DRAM connector,313 行 | 第 2 种介质 |
| 2025-07-30 | da7b0fd |
nfsstore,50 个文件 3317 行,第一次引入真实 C++ 代码 | 第 3 种介质,且是重写 |
| 2025-07-31 | 01e8f66 |
合入 0.0.1-release 分支 |
内部版本号起步 |
| 2025-08-13 | 385325b |
合入 mooncake,493 行 | 第 4 种介质 |
| 2025-08-25 | 33b6fd9 |
合入 dev_ucm_sparse,1453 行,带来 ESA 稀疏注意力 |
第一次出现“上层特性” |
| 2025-09-04 | a2dce8a |
0.0.2 release merge develop (#158) |
第二个内部版本 |
有两件事需要点出来。
第一,da7b0fd 是个转折点。DRAM connector 是纯 Python,147 行;nfsstore 带了完整的 csrc/ucmnfsstore 目录,里面有 CMakeLists、aclrt_device.cc(昇腾)、cuda_device.cc(英伟达)、domain 分层。此前仓库里的 csrc/ 只有一个占位的 .gitkeep,这是第一行真正的 C++。同一个功能,四天之内从 Python 抽象类跳到 C++ 工程结构,说明作者们很快意识到:卸载的瓶颈在拷贝,而拷贝必须下沉到 C++ 才有机会跑满带宽。这个判断后来被 3127481 的 ucmtrans 验证了——它把 Device↔Host 传输统一成一套 C++ API,然后所有 store 都不再自己管拷贝。
第二,接口在这 86 次提交里没有长出新方法。四种介质、一个抽象类,方法数从第一天到 P1 结束一直是 7 个。base.py 本身被改过三次,但三次都不加能力:6aa8309(08-01)补 MIT 头,908a878(08-08)修风格与注释,a9ff92c(09-02)把 create 的返回类型从 int 改成 List[int]——唯一一处语义改动,含义是“创建”从整体成败变成逐块结果。除此之外,介质之间的差异全被藏在了 load/dump 的实现里,契约层面看不出来。
这种“接口稳定”是虚的——它在等第一个不属于“介质差异”的需求出现。
那个需求在 2025-08-25 出现了:33b6fd9 合入的 dev_ucm_sparse 不是介质,是算法。稀疏注意力要的不是“存取 KV”,而是“按重要度筛选 KV”。抽象类没为这件事准备任何位置。
P2:146 次提交的一个月,和第一次真正的接口
2025-09 是主线历史上最密集的一个月:146 次提交、26 位作者,而 8 月是 52 次 / 17 人、10 月只有 24 次 / 12 人。这个尖峰值得单独解释。
git log --since=2025-09-01 --until=2025-10-01 里能看到至少四条并行的工作流在同时推进:
- 目录重构。
f939165(2025-09-05)「change code directory (#161)」把unifiedcache/整体改名成ucm/,110 个文件受影响。这是一次纯机械操作,但它必须做——因为接下来要往里塞的东西太多了,unifiedcache这个名字已经开始误导人(里面不只有 cache)。 - 稀疏注意力开花。
406c548(09-18)「GSA (#190)」4100 行;2e95e46(09-28)「Support kvstar sparse attention algorithm (#193)」2626 行。同一个月里 ESA 之后又长了 GSA 和 kvstar 两个算法,说明“稀疏”不是一个特性,而是一个品类。 - 接口第一次重写。
35c81a1(09-25)「redefine store interface and structure (#189)」,118 个文件、+4071/-1645。这是整部历史里第一次真正意义上的架构动作。 - CI 与构建。这一块的提交占了相当比例,也是 9 月提交量虚高的原因之一——
df651a3/e3a377a/0816854连着三条都是dev_ci_928分支的 CI 修补。
35c81a1 干的事是:把 Python 的 UcmKVStoreBase 换成 C++ 的 CCStore 模板,并把文件从“按介质命名”改成“按角色命名”。
// ucm/store/connector/ucmstore.h —— 35c81a1 新增,85 行template <class T = Task>class CCStore { using BlockId = std::string; using TaskId = size_t;public: virtual int32_t Alloc(const BlockId& block) = 0; virtual bool Lookup(const BlockId& block) = 0; virtual void Commit(const BlockId& block, const bool success) = 0; virtual std::list<int32_t> Alloc(const std::list<BlockId>& blocks) = 0; virtual std::list<bool> Lookup(const std::list<BlockId>& blocks) = 0; virtual void Commit(const std::list<BlockId>& blocks, const bool success) = 0; virtual TaskId Submit(T&& task) = 0; virtual int32_t Wait(const TaskId task) = 0; virtual int32_t Check(const TaskId task, bool& finish) = 0;};和 Python 那版对比,三个变化很关键:批量重载(单块 / 列表各一套)、Task 变成 TaskId 句柄(原来是 Python 对象引用)、create 改名 Alloc、load/dump 合并成 Submit。
第三个变化后来被撤销了。2025-12 的 StoreV1 又把 Load 和 Dump 拆成两个方法——因为 Submit(T&&) 把方向藏进了 task 里,调用方和存储方都要多一层判断,反而更难读。这是一次真实的回退,git log -S"Submit" 能追到它消失的位置。我在“决策留痕”一节会再提。
同月还有一个小提交,信息量比它的大小高得多:b99bd34(09-27)「change store from singleton to object」,11 个文件、+289/-306。净删除。单例改对象是个老生常谈的重构,但在 KV 卸载系统里它意味着一个具体的能力:同一进程里可以同时存在两个不同配置的 store(比如一块 DRAM 池 + 一个 NFS 目录),Prompt 侧和 Decode 侧可以各自持有实例。没有这一步,后面 Pipeline 的“多层可组合”无从谈起。
P2 收尾的两个提交把“上层特性”正式接到了存储层:d9b68aa(10-20)「add store intf with tensor addr ptr (#288)」,ucm/store/ucmstore.py 增 44 行,给三种 store 都加了 tensor 地址指针参数;b01501a(10-31)「Toy proxy now supports PD-mixed round-robin scheduling (#316)」让 ucm/pd/toy_proxy_server.py 第一次有了调度逻辑。
值得一提的是 P2 阶段已经出现了同时并行的多条分支,从 merge commit 的 PR 来源就能读到:dev_ucm_sparse(8 月,33b6fd9)、dev_esa_v2(9 月,a902a01)、dev_store_object(9 月,bd82eb9)、dev_ci_928(9 月,df651a3/e3a377a/0816854 三条)。这一段仍然是 fork + merge commit 的工作流——git log --merges 显示最后一个 merge commit 停在 2025-09-30 的 7a82557(PR #269)。10 月之后,主线上的合并提交彻底消失,改成 squash 到一行的 (#1547) 风格。这个工作流切换没有对应的 commit,只能从日志形状里看出来,属于本文的推断。
P3:公共底座,和一份 122 行的接口
2025-11 到 12 月,106 次提交,主线干了三件事:抽公共层、定接口、发首个公开版本。
抽公共层。 3127481(11-20)「ucmtrans: Unify API for Device-Host Memory Transfers (#379)」,36 个文件 +1982 行,新建 ucm/shared/trans/,把 CUDA / 昇腾 / 仿真三种设备的拷贝统一成一套 Buffer + trans API。同一天 5c66fc8 补上 Ascend 实现(441 行)。四天后的 4efe8f5(11-24)「Share Infra implementation and unify status codes (#399)」更彻底:把原先散在 ucm/store/infra/ 下的 logger、template、thread 全部搬到 ucm/shared/infra/,并且统一了错误码(+123/-208,又是净删除)。
这两步的意义在于把“每个 store 自己实现一遍基础设施”变成“所有 store 共享一套基础设施”。4efe8f5 的文件路径里那一串 ucm/{store => shared}/infra/... 的重命名,就是这个转变最直白的证据。
定接口。 2025-12-13 的 319421f「merge Feature_store_next to develop (#518)」带来了 ucm/store/ucmstore_v1.h,122 行,一个叫 StoreV1 的抽象类。这是 UCM 真正意义上的第一份存储契约——在它之前只有 CCStore 模板和 Python 抽象类,都算不上契约。
StoreV1 首版(319421f, 2025-12-13) 后续只增不改的追加────────────────────────────────────────────────────────────────────Readme() + Setup(Dictionary) 5269d64 2026-01-28Lookup(blocks, num) + LookupOnPrefix cd4239f 2026-01-16Prefetch(blocks, num) + CheckHealth 17e3605 2026-07-23Load(TaskDesc) → TaskHandle + LookupOnReverse 46dc9c9 2026-08-17Dump(TaskDesc) → TaskHandleCheck(TaskHandle) → boolWait(TaskHandle) → Status──────────────────────────────────────────────────────────────────── 7 个抽象方法 → 11 个,删除 0 个注意 CheckHealth() 的写法:virtual Status CheckHealth() { return Status::OK(); }——带默认实现。这是“只增不改”能持续八个月的技术前提。新方法只要给一个空默认体,所有已有 store 就不用动。代价是接口语义会变模糊:一个“返回 OK 但其实什么都没检查”的健康检查,在监控面板上和“真的检查过”长得一模一样。
六天后的 d9dc59c(12-19)「New Store Impl: CacheStore - PosixStore - PipelineStore (#553)」是 P3 的高潮:63 个文件、+4988 行,一次性落下三个新 store 实现,同时新增 ucmstore_v1.h 并保留了 ucmstore.h。v1 / 非 v1 并存的局面从这里开始——直到 2026-10,ucm/store/ 目录下 factory.py 和 factory_v1.py、ucmstore.py 和 ucmstore_v1.py 依然成对存在。
发布节奏也对上了。GitHub Releases 显示首个公开版本 v0.1.0rc1 发布于 2025-11-17,比首提交晚 4 个月;随后是 v0.1.0(12-02)、v0.1.2(12-10)、v0.2.0rc1(12-13)。v0.2.0rc1 和 319421f 是同一天——接口落地就被打进了 RC。
P4:把存储变成可组合的层,同时给传输层画空框
2026-01 到 05-14,210 次提交,是主线第二密集的阶段。这一段的关键词是组合。
Pipeline 的引入分三步,每一步都在修上一步暴露的问题:
| 日期 | commit | 做了什么 | 规模 |
|---|---|---|---|
| 2026-01-07 | 269b8af |
加 3FS Store,19 个文件 | 新介质 |
| 2026-01-16 | cd4239f |
PipelineStore 架构刷新,加 LookupOnPrefix、新增 empty / fake store |
62 文件 +2631 |
| 2026-01-28 | 5269d64 |
「Refactor PipelineStore for Enhanced Scalability」,加 Setup(Dictionary) |
49 文件 +711/-1293 |
5269d64 的增删比最值得看:+711 行、-1293 行,在“增强可扩展性”的名义下删掉了将近两倍于新增的代码。这是 Pipeline 化第一次真正兑现红利——把重复的分支逻辑收进配置驱动。同一天还有 9434a12「Rename global rank and remove broadcast function (#685)」,8 个文件、+13/-64,把 connector 里的按 rank 广播彻底删掉。
9434a12 是整部历史里罕见的删接口。它删掉的东西是:ucm_connector.py 里那套“rank 0 拿到结果再广播给其他 rank”的逻辑。删掉的前提是前面已经有 d9b68aa 那样把 tensor 地址传进 store 的机制,每个 rank 可以各自寻址,不需要一个中心节点转发。先建立独立寻址能力,再删广播——顺序反了就删不掉。
同一阶段还落了压缩层:b580d2a(2026-05-07)「Add UCM store compression module」,33 个文件 +9507 行,ucm/store/compress/cc/compress_lib/ 下是一整套 bitstream 编解码器。压缩在 Pipeline 里的位置是 Cache 和 Posix 之间,热数据不压缩、冷数据才压——这个位置选择在 doc-store-architecture 分支的设计文档里写得很清楚。
然后是本文最想指出的一个细节。2026-05-14,dev-transport 分支上出现了一个提交 e418c03:
$ git show --stat e418c03[feat] New directories and files based on the 2026 H1 architecture ucm/connector/CMakeLists.txt | 0 ucm/store/asu/CMakeLists.txt | 0 ucm/store/dram/CMakeLists.txt | 0 ucm/transport/CMakeLists.txt | 0 ucm/transport/kv/CMakeLists.txt | 0 ucm/transport/kv/asu/client/.gitkeep | 0 ucm/transport/kv/asu/trans/.gitkeep | 0 ucm/transport/p2p/CMakeLists.txt | 0 ucm/transport/p2p/p2p_address.h | 0 ucm/transport/p2p/p2p_factory.h | 0 ucm/transport/p2p/p2p_protocol.h | 0 ucm/transport/p2p/p2p_registry.h | 0 ucm/transport/p2p/p2p_transport.h | 0 14 files changed, 0 insertions(+), 0 deletions(-)14 个 0 字节文件。 一次没有任何代码的提交,提交信息是“基于 2026 上半年架构的新目录与文件”。它属于 dev-transport 分支——那个分支至今只领先 develop 一个提交,就是这一个。
这是我在这份 commit 史里看到的最有性格的一笔:先按架构图把目录和头文件名字占住,让所有人都知道“传输层在这里、它会有这些文件”,然后回去填。104 天后的 f031c5a(2026-08-26)「add P2P transport module (#1277)」填进来 21 个文件 4124 行。
但对比两份文件清单,结论并不温柔:当初规划的 5 个 p2p_*.h 一个都没活下来。 实际落地的是 include/core/transport.h、include/core/transport_manager.h、include/protocols/tcp/tcp_transport.h、include/protocols/hixl/hixl_transport.h 这套按「core / protocols」重新分层的名字。更彻底的是 ucm/transport/kv/asu/client/ 和 ucm/transport/kv/asu/trans/ 这两条规划的目录——到 develop 头部直接不存在了。14 个空文件里,只有 ucm/transport/p2p/ 这一个路径活到了今天。
代价是那 104 天里 ucm/transport/ 一直是个空壳,任何按目录结构判断进度的人都会被误导。
P5:从“能跑”到“能运营”
2026-05-15 到 08-31,126 次提交。这一段没有大重构,全是横切面:可观测性、工具链、进程模型、第二种传输通道。
127647c(06-23)「Integrate ucm metrics into vLLM connector metrics (#995)」,17 个文件 +6307 行。之前 UCM 的指标是 UCM 自己看的,这次并进 vLLM connector 的指标体系,意味着 Grafana 面板上终于能同时看到“vLLM 的 TTFT”和“UCM 的命中率”。b3b3f29(07-07)「Add ucm-toolkit: unified CLI for dev-sandbox, posix-aio, nic-monitor, and metrics-view (#1012)」,+97855 行——全历史最大单提交。这是把一堆散落的运维脚本收成一个 CLI 工具。这个数字主要是 vendored 依赖和生成文件,但方向是对的:系统开始有人伺候了。f031c5a(08-26)P2P transport 落地,兑现 104 天前画下的空目录。5cc8fdf(08-27)「Add DramPool (#1284)」+9556 行,d828f89(08-28)「Add DramStore (#1296)」+7227 行。两天内 DRAM 从“一个 connector”变成“一个池 + 一个 store”。c56c8f9(08-31)「Add Delegator (#1300)」,16 个文件 +2768 行。Delegator 是 store 之上的委托层,可以在不改调用方的情况下换掉后端。46dc9c9(08-17)「Add LookupOnReverse interface for hybrid model mamba block lookup (#1219)」,给StoreV1加了第 11 个方法。注意这个提交的动机:混合模型(full attention + Mamba)的 KV 布局不一样,前缀查找得支持反向扫描。接口是被模型结构逼出来的,不是被存储介质逼出来的。
发布线索也在这段变密:v0.5.0(2026-05-13)、v0.6.0(07-10)、v0.7.0(09-11)。
P6:v2 不是“版本号加一”,是换契约
2026-09 到 10 月,81 次提交。这一段有两件事可以并列:
一是外部输入变大了。 003afe7(09-09)「KV Client merges into develop (#1344)」——160 个文件、+31689 行,在仓库顶层新增了 kv_semantics/ 目录。b3d10c2(09-04)「Feature/cicd merge into develop (#1324)」+41706 行,带来 charts/。1369063(09-17)「Connect UCM documentation with versioned releases (#1379)」+27016 行,把文档搬进 docs/docs-site/ 并做了 en/zh 双语。三个提交加起来 10 万行,全部是“把别处的东西并进来”。
二是 v2 分叉。 2026-09-02 到 09-10,feature/v2 分支上有 15 个增量提交,从 6899678「init v2 workspace」开始,到 a79feef「route Scheduler Prefetch to workers via per-rank shared rings」结束。下面节选其中 8 条,能看出它是一个从目录骨架开始、逐层往上搭的过程:
a79feef [feat] store: route Scheduler Prefetch to workers via per-rank shared ringsa4b067b [feat] store: add optional targetDeviceId param to Prefetch for device-affinity27135c4 [feat] store: add CacheBuffer89d4231 [feat] store: add CacheStore6fb8bfe [feat] v2/detail: add Stopwatch and Latch primitives68c38c8 [feat] store v2 interfacea86ac22 [feat] scaffold v2 subdirs and set install layout with global RPATH6899678 [feat] init v2 workspace然后 2026-10-23(本文写作当天),dev-ucm-v2 分支上出现了 fa84383「[Refactor] Interface design for UCM.v2」:64 个文件、+9825 行、单提交,ucm/v2/ 下 62 个文件,包含 store/store.h(253 行)、store/prefetchable.h(183 行)、store/token_addressed.h(153 行)、proxy/ 下一整套编排层(store_assembler / store_entry / store_peer / store_pipe / store_scheduler / store_worker),以及 1047 行的接口设计文档。
这里必须谨慎,因为很容易讲错:fa84383 不是 feature/v2 那 15 个提交的 squash。 我对比过两个分支的 ucm/v2 目录,git diff --stat a79feef:ucm/v2 fa84383:ucm/v2 显示 93 个文件、+9354/-4246 的差异。也就是说 v2 在 9 月原型之后又重写了一遍,最后以单提交形式落在一个只领先 develop 一个提交的分支上,没有进主线。
这和我之前在 UCM v2 接口设计文档 里搬运的那份文档能对上:文档自己写明基线是 commit 737a17d、规模“生产代码 33 个文件 / 约 4656 行;测试代码约 3280 行;153 个用例全部通过”,并把自己定位成“接口设计仓(intf)”。它不是一份增量改造方案,是一份从零重写的契约。
v2 到底改了什么是下一节的事。这里先记一个结构事实:v2 选择了一条“不进主线也能被评审”的路径——独立分支、独立目录、独立文档、独立单测。对接 develop 时它要么整体合入,要么整体放弃,没有中间态。
决策留痕:五个能追到底的设计选择
这一节是全文的技术核心。所谓“决策留痕”,指的是一个设计选择在后续 commit 里留下的、可验证的修补与扩展轨迹。
一、StoreV1 只增不改,八个月长胖 4 个方法
前面列过表,这里补上“为什么”。看 CheckHealth 和 LookupOnReverse 的默认实现就知道策略:
virtual Status CheckHealth() { return Status::OK(); }virtual Expected<ssize_t> LookupOnReverse(const Detail::BlockId* blocks, size_t num) = 0;CheckHealth 有空默认体,所以 2026-07-23 加它的时候,所有 store 一行都没改——符合“只增不改”。
LookupOnReverse 是纯虚,所以 2026-08-17 加它的时候,所有 store 实现必须补一遍——46dc9c9 一共动了 28 个文件,从 ucmstore_v1.h 到 posix_store.cc、mooncake_store.cc、ds3fs_store.cc 挨个补实现,连测试用的 mock_store.h 和 fake_store_impl_test.cc 都要改。
同一个接口,两个方法,两种兼容策略。这不是不一致,而是能力分层:健康检查是横切关注点,可以有“我不检查”的默认答案;反向查找是功能,不支持就是不支持,不能假装。这个区分在 v2 里被显式化了——AsPrefetchable() / AsTokenAddressed() 返回 nullptr 表示不支持该能力,取代了 v1 里“给个默认实现”的做法。
判断(非事实):如果 2026-01 就引入 facet 模式,v1 主接口不会长到 11 个方法。但反过来说,2026-01 的团队还不知道“混合模型需要反向查找”这件事——facet 划分的依据只能来自 v1 踩过的坑。这层抽象早不得。
二、Prefetch 的形状改了三次:从空函数到三段作业
这条线最完整,四个节点全部可查:
| 阶段 | 日期 | 形态 | 出处 |
|---|---|---|---|
| v0 | 2025-07-24 | def prefetch(block_ids) -> None,纯 hint |
94356b5 base.py |
| v1 | 2025-12-13 | virtual void Prefetch(const BlockId* blocks, size_t num),docstring 明写 “non-blocking and fire-and-forget” |
319421f |
| v2 原型 | 2026-09-10 | 先加参数:a4b067b「add optional targetDeviceId param to Prefetch for device-affinity」 |
feature/v2 |
| v2 原型 | 2026-09-10 | 再改路由:a79feef「route Scheduler Prefetch to workers via per-rank shared rings」 |
feature/v2 |
| v2 契约 | 2026-10-23 | 拆成三段作业:PreparePrefetch / CompletePrefetch / Prefetch(jobId, plan) |
fa84383 prefetchable.h |
这就是那条典型的弧线:先加参数、再加语义、最后拆分层。
为什么会走到拆层?因为“预取”这件事本身包含三个必须分开的时刻:什么时候占住源和目标(准入)、什么时候真正搬(传输)、什么时候确认成败(提交)。void Prefetch() 把三者糊在一起,调用方拿不到任何回执,失败也无法回滚。到了要多 rank 协同、要指定目标设备的时候,一个 void 函数就再也装不下了。
顺带说一句:v2 的 Prefetchable facet 里连 Lookup 都重写了一遍,签名叫 Expected<std::optional<std::vector<std::uint8_t>>> Lookup(...),比 Store::Lookup 多一层 optional。optional 的含义大概是“这次查询本身可能无意义”(比如该 store 不支持预取场景的查询)。这种靠返回类型表达语义的做法,是 v2 比 v1 更严格的地方,代价是签名变难读。
三、monkey patch 按 vLLM 版本复制:15 个目录,219 个文件
这是这批提交里最能说明代价趋势的一条。ucm/integration/vllm/patch/ 下按上游版本分目录,目录的首次提交时间如下:
| 目录 | 首次提交 | 日期 | 触发原因 |
|---|---|---|---|
0.9.2/ |
a17df9f |
2025-09-25 | 最早的版本目录,从 ucm/patch/ 迁过来,仍是 diff 式 |
0.11.0/ |
f5261e9 |
2026-01-22 | 第二个版本,仍是 diff |
v0110/ |
a1e0e67 |
2026-03-02 | 改用 vXXXX 命名 + 目录式 patch |
v0180/ |
100a788 |
2026-04-17 | layerwise KV load patch |
v0191/ |
d3fdeba |
2026-06-12 | 新 connector 接口 |
v0202/ |
dd56f7b |
2026-06-15 | KV load 失败恢复 |
v0210/ |
8e3a29e |
2026-07-01 | disable migratepage |
v0221/ |
22f0145 |
2026-07-09 | CPU 亲和性对齐 |
v0230/ |
f43d716 |
2026-07-21 | 又一个 CPU 亲和性 |
v0240/ |
bd627bb |
2026-08-19 | 一次提交建三个目录 |
v0251/ |
bd627bb |
2026-08-19 | 同上 |
v0260/ |
bd627bb |
2026-08-19 | 同上 |
v0270/ |
e354ce5 |
2026-08-20 | MLA + 线性注意力混合 |
v0271/ |
d6cdeb3 |
2026-09-17 | MiniMax M3 KV 传输 |
v0300/ |
124e0d1 |
2026-10-15 | GLM-5.3-Flash 混合 KV |
到 develop 头部,这些目录下共有 219 个 .py 文件,而早期的 .patch 文件只剩 12 个(其中 6 个在 2025-09 就已存在)。这个对比说明了一切:从“打 diff”改成了“复制并改写上游模块”。
bd627bb(2026-08-19)一次提交补 v0240/v0251/v0260 三个版本目录,是这条路线走到极限的信号——当适配工作变成“每个版本复制一份”,人就会开始批量复制。判断(非事实):质量差异大概率也会随批量复制一起出现,但 commit 史证明不了这一点,只能说三个目录的补丁内容在全量文件里高度重叠。
四、传输层:空目录先占位,104 天后填实
前面已经引过 e418c03 的 14 个 0 字节文件。这里补完后续:
| 日期 | commit | 事件 | 规模 |
|---|---|---|---|
| 2026-05-14 | e418c03 |
dev-transport 分支:14 个空文件、0 行代码 |
+0/-0 |
| 2026-08-26 | f031c5a |
P2P transport 模块落地,含 tcp/hixl 协议、控制通道 | 21 文件 +4124 |
| 2026-09-21 | 3282983 |
集中化连接与内存生命周期管理 | 46 文件 +2307/-2820 |
3282983 又是净删除。新模块落地不到一个月就被重构掉 2820 行——这不是返工浪费,而是“先让它能跑,再让它能维护”的常规节奏。三个提交连起来看:划线(0 行)→ 实现(4124 行)→ 收敛(净 -513 行),前后 130 天。
五、被推翻的:Submit(T&&)、broadcast、单例
只增不改不等于不会推翻。三个明确被推翻的设计:
Submit(T&&)合并 load/dump(35c81a1,2025-09)→StoreV1拆回Load/Dump两个方法(319421f,2025-12)。合并的代价是方向信息藏在 task 里,两侧都要解包。- 按 rank 广播(v0 就有)→
9434a12(2026-01-28)「remove broadcast function (#685)」彻底删除,+13/-64。 - store 单例(v0 就有)→
b99bd34(2025-09-27)「change store from singleton to object」,+289/-306。
三个都是“先能跑、后能组合”的同类修正。它们的共同点:在被推翻之前,都先有替代能力被建起来(独立寻址、多实例),否则删不掉。
数据观察:节奏、分支与发布
月度提交量与作者数
| 月份 | 提交数 | 作者数 | 新增行 |
|---|---|---|---|
| 2025-07 | 30 | 6 | +6522 |
| 2025-08 | 52 | 17 | +9169 |
| 2025-09 | 146 | 26 | +32075 |
| 2025-10 | 24 | 12 | +4759 |
| 2025-11 | 48 | 16 | +18251 |
| 2025-12 | 58 | 15 | +28458 |
| 2026-01 | 52 | 17 | +24492 |
| 2026-02 | 34 | 16 | +25353 |
| 2026-03 | 85 | 22 | +20785 |
| 2026-04 | 32 | 12 | +7200 |
| 2026-05 | 19 | 10 | +17778 |
| 2026-06 | 29 | 12 | +30978 |
| 2026-07 | 45 | 13 | +120272 |
| 2026-08 | 40 | 17 | +51942 |
| 2026-09 | 53 | 24 | +128990 |
| 2026-10 | 28 | 16 | +28288 |
这张表能读出三件事:
- 提交量在 2025-09 见顶,之后再没回去过。 146 次是团队规模第一次扩张(26 位作者)的产物,也是目录和接口最混乱的时刻。之后提交量下降但新增行数上升——从“多人小改”转向“少数人做大块”。
- 2026-07 和 2026-09 的行数尖峰全部来自单笔巨型提交(
b3b3f29+97855、b3d10c2+41706、003afe7+31689、1369063+27016)。这两个月不是开发加速,是资产并入。看提交数完全看不出来(45 和 53),必须看行数。 - 2026-04 到 05 是低谷(32、19 次,12、10 人)。这两个月最有代表性的提交不是代码,而是两次“划线”:
4403ad1(05-11,doc-store-architecture分支,377 行设计文档)和e418c03(05-14,14 个空文件)。这两件事都是判断而非承诺,所以提交数低。这个解释属于推断,commit 史只能证明时间重合,证明不了因果关系。
分支:长跑型 vs 发布型
| 分支 | 提交数 | 领先 develop | 末次提交 | 类型 |
|---|---|---|---|---|
develop |
775 | 0 | 2026-10-22 | 主线 |
dev-ucm-v2 |
776 | 1 | 2026-10-23 | 单提交交付型 |
feature/v2 |
718 | 15 | 2026-09-10 | 原型长跑型 |
feature_a5 |
749 | 24 | 2026-09-28 | 特性长跑型 |
doc-store-architecture |
565 | 1 | 2026-05-11 | 文档单提交型 |
dev-transport |
569 | 1 | 2026-05-14 | 划线单提交型 |
dev-cache-mp-test |
521 | 2 | 2026-03-28 | 实验型 |
debug-perf-2609 |
708 | 2 | 2026-09-05 | 短期 debug 型 |
dev-v2 |
1 | 1 | 2026-08-25 | 废弃草稿 |
这张表的读法:“领先 develop 的提交数”比“分支总提交数”有用得多。 一个 749 次提交的分支只领先 24 个提交,说明它一直在跟主线同步;一个 776 次提交的分支领先 1 个,说明它是“基于最新主线的一次性交付”。真正的工作量藏在差值里。
dev-v2 只有 1 个提交(831ed2a「init」,2026-08-25),是一次没走通的尝试。它在 8 月出现,9 月就被 feature/v2 取代了。这类草稿分支通常不会出现在任何汇报里,但它是理解“v2 做了两遍”的关键证据。
发布节奏
从 GitHub Releases 取到的 26 条记录里,去掉 1 条 draft 与 6 条 nightly 后,是 19 个正式/RC 版本。下表只列节点版本,省略了 v0.1.0rc2、v0.1.0rc4、v0.5.0rc1 三个中间 RC:
| 版本 | 日期 | 间隔 | 备注 |
|---|---|---|---|
| v0.1.0rc1 | 2025-11-17 | — | 首个公开版本,距首提交 4 个月 |
| v0.1.0 | 2025-12-02 | 15 天 | 与 783ea0f「Dev-ucm-v1 rebase」同日 |
| v0.1.2 | 2025-12-10 | 8 天 | |
| v0.2.0 | 2026-01-05 | 26 天 | StoreV1 之后的第一次大版本 |
| v0.3.0 | 2026-01-30 | 25 天 | PipelineStore 重构之后 |
| v0.4.0 | 2026-03-20 | 49 天 | 间隔开始拉长 |
| v0.5.0 | 2026-05-13 | 54 天 | 次日即 dev-transport 的 e418c03 |
| v0.6.0 | 2026-07-10 | 58 天 | |
| v0.7.0 | 2026-09-11 | 63 天 | |
| v0.8.0rc1 | 2026-09-20 | 9 天 | 转入 RC |
| v0.8.0 | 2026-09-30 | 10 天 | |
| v0.9.0rc1 | 2026-09-30 | 同日 | 两条线并行 |
| v0.9.0rc2 | 2026-10-19 | 19 天 | |
| v0.8.1 | 2026-10-20 | 1 天 | 回头给 0.8 打补丁 |
| nightly | 2026-10-16 起 | 每日 | 开始出每夜构建 |
两个模式很清楚:2025 年是发布型节奏(826 天一个版本,边发边改),2026 年转向长跑型(间隔拉到 5063 天)。2026-09 之后同时跑 0.8.x 和 0.9.0rc 两条线,10-16 起加 nightly,这是因为上游 vLLM 0.29 改了 KVCache 布局——这层因果关系不是我猜的,仓库里的 version.ini 自己写着:
# vLLM 0.29 changed the KVCache layout, which is incompatible with the layout# used by earlier vLLM/vllm-ascend releases. develop and 0.9.0rc2 therefore# support vLLM/vllm-ascend 0.30 and newer only; earlier releases stay on 0.8.x.UCM_VERSION=0.9.0UCM_SUPPORTED_VLLM_VERSIONS=0.30,0.31UCM_SUPPORTED_VLLM_ASCEND_VERSIONS=0.30UCM_SUPPORTED_SGLANG_VERSIONS=0.5.18,0.5.19,0.5.20一个 KV Cache 系统的版本号,最终被上游的 KV Cache 布局绑死了。这句话几乎概括了这个项目的全部处境。
留下的与被推翻的
留下长期影响的:
- 两阶段准入(Alloc → Commit)。从 2025-07-24
base.py的commit(..., is_success)到 2026-10-23 v2 的TokenAddressed::Alloc/Commit,15 个月一字未改地活下来了。这是整份 commit 史里最稳定的一个语义。 - 块级批量化。v0 的
create(block_ids: List[str])一开始就是批量语义;35c81a1把它写成单块 / 列表双签名,v2 里换成std::vector<BlockId>。形式变了,接口一直按“块”而不是“张量”计量。 - 设备-主机传输与存储解耦。
3127481的 ucmtrans 划分出ucm/shared/trans/之后,存储实现再也不用管拷贝,这个边界一直保留到现在。 - Configuration as Dictionary。
5269d64(2026-01-28)给StoreV1加了Setup(const Detail::Dictionary&),v2 里变成Setup(const Dictionary&),Pipeline靠它递归组装层级。
被推翻或边缘化的:
Submit(T&&)的统一提交(拆回Load/Dump)。- 按 rank 广播(
9434a12删除)。 - store 单例(
b99bd34改为对象)。 - diff 式 patch(
a17df9f的.patch文件 → 目录式 monkey patch,219 个.py)。 ucmstore.h的CCStore模板。它没有被删,但被ucmstore_v1.h架空:两个文件在ucm/store/下并排躺了 10 个月,factory.py和factory_v1.py也是。这是一种比删除更糟的状态——它没死,但也没人敢动。
还有一类东西我不打算评价,但得指出来:doc-store-architecture 分支上那份 377 行的设计文档(4403ad1,2026-05-11)里有大量性能倍数(“80-95% cache hit rate”、“10-15x throughput”、“3-5x performance improvement”)。这些数字没有对应的 benchmark 提交支撑,我倾向于把它们读作“作者的设计意图表述”而不是“实测结论”,所以在正文里一个也没有引用。
如果重来:三个可以更早抽象的地方(判断)
这一节全部是个人判断,不是 commit 能证明的事。我把依据一起写出来,方便反驳。
一、StoreV1 应该一开始就分核心与能力。 依据:CheckHealth 和 LookupOnReverse 两次追加用了两种兼容策略(默认实现 vs 纯虚),说明团队在加方法时就已经在权衡“这是不是所有 store 都该有的”。这种权衡如果在 2025-12 就固化成 facet,v2 的 AsPrefetchable() 会早 10 个月出现。反方观点我上面也写了:能力划分的依据来自踩坑,早不了。所以我的实际主张是“核心接口应该更小”——把“必须每层都实现”的方法压到最少(Setup、Lookup、Load、Dump、Wait、GetFinished),而不是靠默认实现把可选能力一项项塞进主接口。
二、patch 目录应该在第一份 diff 之后就改成“按语义分模块”而不是“按上游版本分”。 依据:15 个版本目录里,大量内容是重复的(cpu_binding 出现在 v0191、v0202、v0210、v0221、v0230、v0240、v0251 七个目录里,platform 补丁在七个目录里重复出现)。如果 2026-01 就按“CPU 亲和性 / 混合缓存 / 失败恢复”分成模块,再用一张版本→模块的映射表组装,219 个文件大概能压到 60 个以内。这是全文里我最确定的一条。
三、传输层不该先画 0 字节空目录。 依据:e418c03 的 14 个空文件从 2026-05-14 躺到 08-26,期间任何“按目录看架构”的判断都会失真;而真正落地时(f031c5a)的 21 个文件里,5 个规划的头文件名一个都没用上,ucm/transport/kv/asu/ 两条目录被整体放弃。划线的收益(沟通)是短的,成本(误导 + 事后还得改一次名)是长的。更好的做法是写一份 20 行的 README.md 说明边界,而不是提交 14 个空文件。
反过来说,有一件事我做不出来更早的方案:v2 的整体重写。看 dev-v2(1 个提交后废弃)→ feature/v2(15 个提交)→ fa84383(重写后单提交)这条路径,v2 至少做了三遍。如果没有前两遍,第三遍不会在一个提交里同时给出接口、facet、插件加载、Proxy 编排和 153 个用例。这类“重写型抽象”没法用增量方式抵达,这是我读完 commit 史后最反直觉的一个结论。
小结
七个阶段,775 次提交,79 位作者,19 个正式/RC 版本,一条主线加九条分支。把这条曲线拉直了看,UCM 的演进有一个很稳定的形状:
接介质(2025-08)→ 稳目录与接口(2025-09)→ 抽公共层(2025-11)→ 可组合(2026-01)→ 可运营(2026-06)→ 换契约(2026-09)。
每一次抽象都不是被设计愿景驱动的,是被上一阶段的具体失败推出来的:35c81a1 被多人并行逼出来,319421f 被介质差异逼出来,cd4239f 被实现重复逼出来,fa84383 被 StoreV1 太胖逼出来。这个项目最值得学的地方不是它的架构,而是它容许自己被推翻的频率——三次明确的推翻(Submit、broadcast、单例)都发生在被推翻的东西有了替代品之后。
最该警惕的地方也很清楚:适配层的 219 个文件,和 ucm/store/ 下那两套并排躺了 10 个月的新旧接口。前者是持续失血的成本,后者是持续降低可读性的债。如果 v2 真的合入主线,这两笔债才有可能一起清掉。
至于其他几个维度——Pipeline 内部怎么调度、多 rank 加载怎么保证一致性、缓存并发怎么处理——站内另外几篇讲得更细,我这里只提供时间轴的骨架:Pipeline Store 的实现、缓存并发、多 rank 加载、v2 接口设计、混合缓存设计。
参考来源
- 仓库:ModelEngine-Group/unified-cache-management(上游,本次未直接访问)
- 本地数据源:bare 镜像
repo.git,只读,未做任何 fetch / pull / 改 ref - 分析分支:
develop(主线,775 次提交);辅助分支dev-ucm-v2、feature/v2、feature_a5、dev-transport、dev-cache-mp-test、doc-store-architecture、debug-perf-2609、dev-v2 - 版本信息:GitHub Releases API,26 条记录(含 draft 与 nightly),取数日 2026-10-23
- 统计口径:
git rev-list --count、git log --shortstat、git log --date=format:"%Y-%m"、git show --stat、git log -S<symbol>;作者数按提交邮箱去重 - 统计区间:2025-07-10(
f0f9455)至 2026-10-22(4e47254) - 截止日期:2026-10-23
- 文中所有引用 commit 均在写入前用
git show --stat或git log现场校验;镜像内无 tag,故版本节点单独取自 Releases API