
开头做系统架构的人都知道真正拉开差距的往往不是算法层那点花活而是底层数据搬来搬去的那条链路。Mooncake 这个名字听起来像是某个甜点项目实际上它是一套面向大模型推理场景的分布式执行架构核心要解决的事情很直接当显存放不下时KV cache、临时激活值这些数据应该往哪儿放、怎么放、怎么在算力节点之间高效流转。这篇文章我会从工程架构、存储后端选型、以及数据访问链路三个角度把 Mooncake 的设计思路和落地细节掰开揉碎讲一遍适合正在做推理引擎、做过分布式训练、或者对存储和网络栈有好奇心的读者。先说结论Mooncake 不是把数据一股脑塞到某个统一存储里而是建立了一套分层的、可感知拓扑的存储视图配合 RDMA 网络和无共享的访问协议让数据既能就近命中也能在节点间低延迟流动。它更像一个调度器加移动器的组合体而不是传统意义上的存储服务。接下来我会按架构总览、存储后端、数据链路、实战问题排查四个部分来展开每部分都会结合我实际跑过、调过的场景来聊。1. Mooncake 工程架构先分层再调度1.1 整体拓扑从“推数据”到“拉数据”的思路转变Mooncake 的工程架构设计上有一个很关键的思想转变传统训练或推理框架里数据搬运一般由发起方主动推送源端把数据写到目标端而 Mooncake 更像是建立了一套分布式的数据平面各节点把数据视为远端可定位、可通过 RDMA 直接读取的资源使用方按需拉取。这一点在跨节点传输时体现得尤其明显。以我们部署过的 8 节点、每节点 8 卡的环境为例如果不做任何优化使用方需要等待发送方把数据准备好并主动推送而发送方又不知道接收方什么时候真正消费。Mooncake 的做法是给每个数据块打上全局可路由的标识一段 KVCache 或者一段临时激活值接收方在真正需要这份数据时才发起读取请求读操作直接落在对方显存或内存地址上。这个模式有点类似于现代 CPU 的 NUMA 访问——数据就近放一份但远端也有副本由协议层决定何时走远端。整个过程由 KVCache 管理器、Transfer Engine、远端内存注册表、任务调度器几个核心模块协同完成。调度器不关心具体字节怎么走它只维护“哪个 Block 在哪个节点、什么状态”的元数据Transfer Engine 才是真正干活的它负责 RDMA 连接的建立、队列对的管理、分块传输和乱序重组。1.2 核心模块拆解每个组件在链路中扮演什么角色拆分 Mooncake 的工程架构有几个模块是必须理解的。第一是KVCache Manager。它以 Block 为粒度管理显存一个 Block 通常是 64MB 或 128MB可以配置每块都有全局 Block ID。这个管理器不只管本节点的显存还会记录本节点哪些块是脏的、哪些是干净可释放的以及远端哪些块是可读的。它的存在让“显存池”变成了分布式资源而不再是单机概念。第二是Transfer Engine。它负责 RDMA 的读写语义抽象但是有一点很值得注意它不能无脑用 RDMA Write。因为 RDMA Write 是单向操作发送端写完就返回成功了接收端不一定已经把数据放进了它该在的位置。在某些场景下接收端要先把目标内存区域准备好如果发送端贸然 Write 到一个还没准备好的 Buffer数据会直接丢掉或者覆盖掉别的东西。Mooncake 在传输语义上做了读写混用的设计小块控制数据走 RDMA Send/Receive大块数据走 RDMA Read 或 Write具体走哪个由数据大小和是否已经完成内存注册决定。第三是远端内存注册表Remote Memory Registry。做 RDMA 的人都清楚没有注册的内存是不能被远端访问的。Mooncake 在启动阶段会把本节点可用于远端访问的内存区域统一注册到 RDMA NIC 上然后把注册后的地址信息L_Key/R_Key 虚拟地址广播给集群其他节点。每个节点维护一张全局的映射表类似一个简化版的分布式内存数据库。后续的远端读写都基于这张表寻址不需要再做控制面交互。第四是调度器Scheduler。调度器并不直接参与数据传输而是负责任务切分与依赖管理。比如一个推理请求来了它会把 Prefill 阶段和 Decode 阶段切分到不同节点同时根据 KVCache Manager 报告的空闲块分布决定 KVCache 放在哪里然后生成一个“数据放置计划”下发给各节点的 Transfer Engine。这个计划本质上是一系列异步任务各自都有完成回调调度器通过回调感知链路状态。1.3 为什么选择“存储与计算分离但感知拓扑”的架构市面上有两种路线一种是完全中心化存储所有 KVCache 和激活值都写到一个中心存储服务里计算节点通过网络读写另一种是完全本地化所有数据必须留在本节点。Mooncake 走的是中间路线逻辑上是统一的数据平面物理上每个节点都有本地存储节点间通过 RDMA 互联。它不追求“所有数据全局可见”而是追求“能本地就在本地不能本地就以最少跳数拿到”。这个架构选择背后有非常现实的考量。推理场景的 KVCache 访问模式跟训练不一样训练里数据访问是有序的、批量的推理里 KV Cache 的访问延迟直接决定了首字延迟和 Token 生成速率。如果把 KVCache 全放到远端中心存储每一次 Decode 都要走一次完整网络往返不仅延迟翻倍还会把网络打满。而 Mooncake 的分层调度能把热点数据尽量留在本地或近端只有真正必要的跨节点访问才走 RDMA 链路同时通过预取机制把即将用到的数据提前搬到本地减少同步等待。2. 存储后端选型分层池化与“内存-快存-慢存”三级结构2.1 存储分层的动机显存永远不够用但又不能全部靠远端做推理引擎的人都有同感显存永远比需求小一截。模型权重吃掉一部分剩下给 KVCache 的空间往往算下来只够支持几十个并发请求。如果只把 KVCache 放显存并发一高就 OOM。如果把 KVCache 全部放到远端延迟又无法承受。所以存储后端必须分层——让最热的数据享受最快的介质让冷数据待在大容量介质里。Mooncake 的存储后端是一个 Pool 的概念按访问速度和容量自然形成三级结构第一级是本地显存这是速度最快的池子用于放置当前正在计算的 Block第二级是本地 DRAM容量比显存大得多用于放置活跃但暂时未参与计算的 KVCache 副本第三级是慢速存储池可以是远端内存节点也可以是本地 NVMe SSD主要存放长尾但可能再被访问的历史数据。注意这里的慢速池不是传统意义上的分布式文件系统它还是通过 RDMA 访问的远端内存池只是访问延迟比本地内存高一个量级。2.2 存储抽象接口设计统一视图下的读写语义存储后端对外暴露的接口相当收敛——本质上就是一个分成两层语义的接口一层是 Block 粒度的读写另一层是 Block 粒度的复制与迁移。读写是给计算引擎用的复制与迁移是给调度器用的两边互不干扰。从接口设计上看Block 粒度设得很大是有讲究的。如果块太小比如 1MB元数据数量会爆炸调度器和注册表都扛不住如果块太大比如 1GB迁移和复制时的网络浪费会很高而且显存碎片化严重。我实测下来 64MB 到 128MB 是一个比较舒服的区间元数据量和内存碎片概率都比较可控。接口层面还做了一个很实用的设计读写操作本身不区分“本地还是远端”上层只提供 Block ID 和偏移量底层根据 Block 所在位置自动决定走本地显存拷贝、本地 DRAM 拷贝还是 RDMA 远端访问。这样设计的好处是上层逻辑完全不需要感知物理拓扑调度器和计算引擎解耦得很干净。2.3 存储后端与训练框架的关系左手 KVCache右手临时激活值提到存储后端很多人会先入为主地认为只服务于 KVCache。实际上 Mooncake 的设计里存储后端同时承载另一类数据——临时激活值。在大模型推理的 Prefill 阶段激活值处理往往会产生大量中间结果这些中间结果不需要写入全局权重文件但生命周期极短、访问频率极高如果全塞显存会造成显存峰值过高。Mooncake 的处理方式是把这类临时激活值按阶段切成小块调度器一旦发现显存压力过高就会把已经算完的、短期内不会再重用的激活值块挪到 DRAM 或远端池里需要时再拉回。这个策略训练的直觉是激活值不像 KVCache 会被反复读取往往只被读一次或者两次所以它的生命周期很短放到哪里都不太会影响访问延迟。这个选择实际上也是调度器最关键的一个优化点——它会依据 Block 的“下一次使用距离”来决定数据应该留在哪一级存储池而不是一刀切地按照数据类型归类。3. 数据访问链路从本地命中到跨节点迁移的完整路径3.1 本地访问路径零拷贝语义和显存地址直通数据访问链路的第一层是本地命中路径也是最常见的访问路径。计算引擎在拿到 Block ID 后首先查询本地注册表发现 Block 就在本地显存上这时它不需要做任何数据搬运直接拿到显存虚拟地址用 CUDA Kernel 读取即可。这实际上是一种零拷贝语义数据没有挪动 Byte只是把地址和尺寸传给了计算线程。这个路径里有一个很关键的工程细节显存分配的固定性。Mooncake 在启动阶段就会预留一块连续显存池供 KVCache 使用后续所有 Block 的分配和释放都从这个池子里切不再和 CUDA 默认分配器交互。这个设计可以避免两个问题一个是 CUDA 默认分配器的分配延迟不稳定极端情况下会在毫秒级波动另一个是显存碎片长时运行后碎片会严重到有空间但分配不出连续的块。预留池的代价是灵活性降低但换来的是稳定性和可预测性在推理场景里这个取舍非常划算。本地命中还有一个容易被低估的细节PCIe 上的 P2P 访问。如果同一节点内有两块 GPU 互相访问 KVCache是否走 P2P 取决于芯片和拓扑。Mooncake 在初始化时会探测每对 GPU 之间的 P2P 能力如果支持且延迟优于 DMA 回环就建立跨卡直接访问映射如果不支持才退化为通过主机内存中转。这个小优化在 8 卡机内效果特别明显尤其在做张量并行或专家并行时跨卡 KVCache 读取频繁P2P 能省掉一整段主机内存拷贝。3.2 跨节点访问路径RDMA Read 为主Write 为辅的选择逻辑跨节点访问是 Mooncake 数据链路中最有含金量的部分。两个节点之间的数据传输主要依托 RDMA但具体使用 Read 还是 Write是整个系统性能差异的关键点之一。实践下来Mooncake 对大体量数据超过 128KB 的 Block 级传输几乎全部采用 RDMA Read而对小体量控制数据采用 Send/Receive。原因是RDMA Read 是接收端主动发起读天然带有流量控制接收端说读多少就读多少发送端不需要维护发送缓冲而 Write 是发送端主动推送需要接收端预留同样大小的接收缓冲区否则可能触发接收端内存溢出。在推理这种接收端消费节奏不固定的场景Write 很容易造成接收缓冲反复扩缩导致延迟抖动。另外 RDMA Read 的语义更贴合 Mooncake 的拉取模型——调度器决定读哪块数据Transfer Engine 再执行读操作整个过程和“请求 → 响应”的控制流天然对齐。当然RDMA Read 也不是没有代价。它要求接收端的 RDMA 网卡在发起读请求前持有发送端已注册内存的远端地址和 R_Key。这些信息来自全局注册表如果注册表里的地址信息过期例如对端重新启动、内存重新注册读请求会直接返回错误。这里就体现出了注册表一致性维护的重要性——网络分区、节点重启、NIC 重置都会导致远端注册信息失效Mooncake 必须能感知这些失效并触发重新注册流程。3.3 跨节点中止传输的机制为什么“提前取消”比“等它完成”更重要实际链路里有一个细节非常影响整体体验——任务取消机制。分布式推理里经常遇到这种情况请求在 Decode 阶段生成了太久已经落后于客户端超时时间调度器判断这个请求没必要继续了此时如果还有大量 KVCache 正在从远端往本节点搬运最稳妥的做法似乎是等传输完成再释放资源。但 Mooncake 不是这么做的。它的做法是支持对传输任务的显式中止Transfer Engine 为每个传输任务维护一个状态机中止操作会直接将任务标记为 Canceled对应的内存区域会被立刻释放回池中即使底层 RDMA 操作尚未返回完成事件上层也不再等待它。这里的技术难点是RDMA 的操作一旦发出就不可逆转——中止必须通过“忽略完成事件”的方式实现而不是真的阻止网卡动作。换句话说数据可能仍然在物理链路上流动但上层已经不再关心它了内存也已经重新分配给了别的任务。这样做的好处非常直观避免了“为了完成一个已经没有价值的任务而阻塞资源”。我见过不少系统在超时场景下资源被无效传输占用大半新请求却挤不进来。Mooncake 用这种取消语义把无效传输的占用时间压到最低同时配合传输层的垃圾回收机制定期清理那些被标记为 Canceled 但底层还挂着的 RDMA 操作的完成队列避免完成队列被无效事件塞满。3.4 数据一致性语义谁负责“读到最新数据”跨节点数据访问有一个绕不开的问题接收方读到的数据是不是发送方最新写入的版本Mooncake 的做法在一致性上做了取舍——它不提供类似分布式文件系统的强一致语义而是采用了“白板语义”每个 Block 有明确的版本号一旦某节点完成了对该 Block 的写入该 Block 的版本号递增并广播给其他节点。其他节点在读这个 Block 之前必须检查版本号是否等于自己期望的版本不一致就重新拉取或者报错。这个语义的适用场景非常契合 KVCacheKVCache 一旦由 Prefill 阶段写入基本不会被修改Decode 阶段只是不断追加新的 Block而不是修改已有 Block。因此弱一致其实够用——只要保证“读到的 Block 要么是当前版本要么明确感知到版本变化”就不会出现数据错乱。真正需要强一致的场景反而是 KV Cache 淘汰后重新分配一个 Block 被释放后又被分配给新的请求此时旧数据还在远端内存里如果远端节点不知道这个 Block 已经被重新分配就会读到“陈旧但合法”的数据。Mooncake 的应对手段是维护一个全局的 Block 生命周期状态机Allocated → Written → Evicted → Free。远端节点在读取前都会检查状态只有状态为 Written 的 Block 可读其他状态都视为不可读。这个状态检查依赖元数据通道不需要搬数据字节所以成本极低。这也是为什么在设计时没有直接套用分布式缓存系统那套基于租约的锁机制——那对推理场景来说太重了。4. 常见问题与排查技巧实录4.1 问题一跨节点读取性能忽高忽低先查拓扑感知有没有生效在实际部署中最容易踩的坑是跨节点读取性能不稳定。表面上看是网络抖动但排查之后发现其实是拓扑感知失效导致的。Mooncake 依赖节点间的距离矩阵来做路径规划如果这个矩阵没有及时更新比如节点挂了又起来网络拓扑发生了变化调度器可能还会按老拓扑选择一条绕路的路径延迟自然上去了。排查手段很简单观察传输延迟分布对比同一数据块在不同节点之间的读取延迟。如果某个节点组合的延迟明显高于同层级的其他组合十有八九是拓扑矩阵漏更新了。我们踩过坑后的解决办法是在每次传输完成后由 Transfer Engine 上报端到端延迟汇总到调度器侧形成一个随时的真实拓扑矩阵不再完全依赖静态配置。4.2 问题二远端注册表信息过期引发的连接错误RDMA 连接错误是跨节点访问链路里最常见的问题之一。现象是某节点重启后其他节点仍然按照旧地址访问它的内存区域导致 RDMA 返回错误码。原因就是全局注册表没有及时感知到重启事件。排查时优先确认注册表的心跳周期。如果某个节点的心跳停止超过阈值系统需要立刻将该节点的所有 Block 标记为不可读并触发重新注册流程。这里我个人的实操建议是心跳周期不要设太短否则轻微网络抖动就会导致节点被误判离线触发大规模重迁移也不要设太长否则真的宕机了要等很久才能感知。在我们 8 节点集群上1 秒心跳、3 次丢包判离线是比较稳的组合。4.3 问题三显存池碎片化导致大块分配失败即使采用了预留显存池长时间运行后依然可能出现碎片化问题。根因在于不同请求的 KVCache Block 大小不一致释放顺序不一致导致池中出现了大量难以合并的小空洞。虽然 Block 是固定大小但跨层存储需要连续地址小空洞会浪费空间。我们的应对方案是定期做池内整理把活跃 Block 用 CUDA 的cudaMemcpyAsync从碎片化区域搬到连续空闲区然后更新注册表里的地址映射。注意这个操作必须在没有计算 Kernel 正在读写这些 Block 时进行否则会导致数据错乱。实际中我们一般把这个整理动作挂在调度器的空闲窗口里运行频率很低但每次整理完都能释放出很大一块连续空间。4.4 问题四RDMA 完成队列被无效事件塞满当传输任务频繁被中止场景 3.3 里的取消机制RDMA 完成队列必然会堆积大量已完成但无人消费的事件。Rocket 一点完成队列的空间是有限的塞满之后新的完成事件无法入队会导致后续所有传输任务阻塞。处理这个问题不能靠增大队列长度——那只是拖延问题。更合理的做法是定期轮询完成队列并丢弃已经属于 Canceled 任务的事件。我们实现过一个后台清理线程每秒扫描一次完成队列把匹配取消任务 ID 的事件直接弹出丢弃实测下来队列占用稳定在容量的 30% 以下。这里也有一个坑清理线程和正常的事件消费线程不能同时操作一个队列必须加一个轻量锁否则会出现事件丢失或重复消费。关于 Mooncake 数据访问的一些个人体会做这套数据链路调优的过程中最有价值的一个认知是存储后端和数据访问链路不是孤立的两个组件它们的边界应该在设计时就画清楚。存储后端只负责提供可访问的数据块和一致性的状态信息而数据访问链路负责决定怎么高效地使用这些数据块。一旦边界模糊就会出现“存储层在做调度”或者“链路层在做一致性管理”这种混乱导致排查问题时分不清是哪一层的锅。另外一个很有价值的体会是做分布式推理架构技术上要有“减法”意识。比如读取数据时不要总想着把所有情况都做原子化和强一致KV Cache 这种生命周期清晰的数据弱加版本控制就完全够了。实现上果断做减法遇到复杂场景先想“是不是可以不要这一层”反而能让系统跑得更轻快、更稳。如果时间回到重新设计的时候我会再把 RDMA 传输的取消机制进一步做成传入式回调机制让上层对中止语义的感知更及时而不是让清理线程去轮询——这是当前系统里我最想再迭代的一个点。