Mooncake TENT GDS
本文以 Mooncake 提交 89da2c3a 为代码截面,重点阅读:
tent/include/tent/runtime/transport.h:所有 transport 共同遵守的接口;tent/src/runtime/transfer_engine_impl.cpp:请求准备、分组、提交与轮询;tent/src/transport/gds/gds_transport.cpp:TENT 到 cuFile 的具体翻译。
三个问题其实是一条链
如果只盯着 cuFileBatchIOSubmit,TENT 看起来像一个多余的中间层。但把对象关系展开后,边界很清楚:
- 应用描述意图:本地 buffer、远端 Segment、偏移、长度和读写方向。
- TENT 解释意图:解析 Segment,检查本地内存,选择 transport,把公共 Batch 拆成 transport 私有 SubBatch。
- 后端执行意图:GDS 把一个逻辑
Request切成 cuFile slice,提交到底层设备,并把完成事件重新聚合成 TENT 状态。
下图把控制面、统一运行时和 GDS 数据面放在一起。阅读时先抓住中间那条竖向边界:**Transport 接口以上属于 TENT,接口以下才属于 GDS。**

图:TENT 统一管理 Segment、buffer、selector 与 Batch;GdsTransport 管理文件 handle、GPU buffer 注册、cuFile batch handle、slice 参数和完成事件。
这也解释了为什么实现一个新后端不能只提供 send/receive:底层数据搬运 API 只覆盖“执行”,而 TENT 还需要知道它能搬什么、怎样分配批次、怎样查询进度、何时可以释放资源。
TENT 怎样跑一次请求
TENT 的运行逻辑可以分为启动、资源登记、请求提交和状态推进四段。它们不是四条独立路径,而是前一段为后一段准备可验证的对象。
启动与后端选择
TransferEngineImpl 启动时创建配置、拓扑、控制服务、Segment Manager 和 Transport Selector,然后装载编译期存在、运行时启用的 transport。GDS 同时受两道门控制:
1 |
|
第一道门是构建时的 USE_GDS,第二道门是配置项 transports/gds/enable。对应源码在 transport_loader.cpp:87-90。
装载不等于选中。对 File Segment,当前默认 selector 顺序是:
1 | GDS → IOURING |
这来自 transport_selector.cpp:84-102。某些设计文档会把 RDMA 也画在文件回退链上,但在这个固定提交中,默认代码就是 {GDS, IOURING};讨论真实行为时应以代码截面为准。
GdsTransport::install 读取 io_batch_depth,默认值是 32,并声明 dram_to_file、gpu_to_file 两项 capability。Selector 正是用 Segment 类型、本地内存类型和 capability 判断该 transport 是否匹配。
Segment 与内存登记
文件通过 file://path 进入 TENT。openSegment 让 SegmentManager 创建 FileSegmentDesc,但这一步只把路径和元数据变成 Segment ID,并没有立即执行 POSIX open 或 cuFileHandleRegister。
GDS 第一次看到这个 target_id 时,findFileContext 才会延迟创建 GdsFileContext:
1 | file://path |
这一区分很重要:Segment 是 TENT 的寻址对象,CUfileHandle_t 是 GDS 的执行对象。 新后端可以复用相同的 file:// Segment,但在自己的 context 中创建完全不同的文件句柄。
本地内存走另一条登记链。registerLocalMemory 生成 BufferDesc,再调用各 transport 的 addMemoryBuffer。GDS 只对 location.type() == "cuda" 的 buffer 调用 cuFileBufRegister,成功后把 GDS 加入 desc.transports。这样 selector 不只知道“系统装了 GDS”,还知道“这块 buffer 已具备 GDS 能力”。
提交与轮询
一次请求的完整时序如下。图比较长,因为它刻意保留了正常完成、部分失败、取消和资源隔离四条分支;第一次阅读可以只跟中间的蓝色主线。

图:公共 Request 先被 TENT 解析、合并和分组,再进入 GDS SubBatch;cuFile 返回的 slice 事件经过 cookie 缓存和 range 聚合,最后才变成公共 task 状态。
从调用关系看,关键链路是:
1 | TransferEngine::submitTransfer |
这里存在两层容量,不要混为一个概念:
- 公共 Batch 容量:限制调用者能放入多少个逻辑 task;
- **GDS
io_batch_depth**:限制一个 GDS SubBatch 能容纳多少个物理 slice。
当前 GDS 将每个请求切成不超过 16 MiB 的 slice。默认 depth 为 32 时,一个 SubBatch 最多容纳 32 个 slice,粗略对应 512 MiB 的满尺寸数据;多个 Request 会共享这 32 个槽位。16 MiB 的选择理由没有写在源码中,因此它是当前实现策略,不是所有新后端都应该复制的 GDS 标准常数。
Mooncake 用了哪些 GDS API
NVIDIA GDS 对应用主要暴露的是 cuFile API。按功能理解,比背一串函数名更有用:它既包含驱动与资源登记,也包含同步 I/O、Batch I/O、stream 异步 I/O 和属性/统计接口。Mooncake TENT 的 GdsTransport 只使用其中一部分。
| API 族 | 代表接口 | Mooncake 是否使用 | 在 TENT 中的职责 |
|---|---|---|---|
| 驱动生命周期 | cuFileDriverOpen、cuFileDriverClose |
只使用 Open |
GdsTransport 构造时通过 std::call_once 初始化一次 |
| 文件句柄 | cuFileHandleRegister、cuFileHandleDeregister |
是 | 把 POSIX fd 登记为 CUfileHandle_t |
| GPU buffer | cuFileBufRegister、cuFileBufDeregister |
是 | 登记和注销 CUDA buffer |
| 同步 I/O | cuFileRead、cuFileWrite |
否 | TENT GDS 不走逐次同步读写 |
| Batch I/O | cuFileBatchIOSetUp、Submit、GetStatus、Cancel、Destroy |
是 | SubBatch 的创建、提交、轮询、取消与销毁 |
| stream 异步 I/O | cuFileReadAsync、cuFileWriteAsync |
否 | 当前实现不以 CUDA stream 作为完成模型 |
| 属性与观测 | properties、stats、version 相关接口 | 否 | 当前实现没有用它们确认实际 direct path 或导出 cuFile 统计 |
把调用放回生命周期,可以得到更直观的映射:
1 | 进程/对象初始化 |
cuFileDriverOpen 位于 gds_transport.cpp:242-246;文件 handle 位于 gds_transport.cpp:32-68;buffer 登记位于 gds_transport.cpp:564-585。
新后端要实现什么
TENT 的 Transport 基类没有用纯虚函数强迫子类实现所有能力,很多默认实现只是返回 NotImplemented。这意味着“类能编译”不代表“后端已接通”。
对一个类似 GDS 的文件传输后端,最小可运行契约如下:
| TENT 接口 | 后端必须回答的问题 | GDS 的答案 |
|---|---|---|
install/uninstall |
怎样初始化配置、依赖和全局资源;怎样停止并清理 | 保存 runtime 对象,设置 depth/caps;清理 batch、pool 与元数据 |
capabilities |
能处理哪些本地内存与 Segment 组合 | dram_to_file=true、gpu_to_file=true |
allocateSubBatch |
一组请求需要哪些稳定存储、队列槽位和底层 handle | 分配 GdsSubBatch,复用或创建 CUfileBatchHandle_t |
freeSubBatch |
何时能安全复用或销毁底层资源 | 正常批次回池;仍可能被 cuFile 引用的失败批次进入 quarantine |
submitTransferTasks |
怎样把 TENT Request 翻译、切片并提交 | 构造 CUfileIOParams_t,调用 cuFileBatchIOSubmit |
getTransferStatus |
怎样轮询、聚合进度和错误 | GetStatus 得到 slice event,再按 IOParamRange 聚合 |
add/removeMemoryBuffer |
是否需要预注册本地内存,怎样管理生命周期 | CUDA buffer 调用 cuFileBufRegister/Deregister |
getName |
配置、日志和诊断中怎样标识后端 | 返回 "gds" |
| cancellation | 已提交工作能否取消,何时可释放用户 buffer | GDS 未公开覆盖 task cancel;内部 batch cancel 只用于失败清理 |
sendNotification/receiveNotification 属于可选能力,文件后端通常不需要。supportsCancellation 默认返回 false;如果新后端要向公共 API 声明可取消,就必须同时实现 cancelTransferTask,并遵守“取消是 best effort,调用者仍要轮询到终态”的契约。
类本身之外还要接通四个位置:
- 类型与名字:在
TransportType、字符串解析以及 C/Python 绑定中加入新类型; - 构建与装载:CMake 找到 SDK 后定义构建宏,
TransportLoader根据配置创建实例; - 选择策略:为正确的 Segment、本地内存类型和 capability 配置优先级与回退;
- Segment/Buffer 语义:决定能否复用
file://和现有内存注册;如果底层需要额外 endpoint 或专用 handle,应通过 transport 私有 context 或属性承载。
提交代码逐行读
在进入 submitTransferTasks 前,allocateSubBatch 已经准备好三类关键存储:
io_params:交给 cuFile 的 slice 参数数组,预留到io_batch_depth,避免构造期间反复扩容;io_events:cuFileBatchIOGetStatus写入的临时事件数组;cached_events:按 slice 长期保存的稳定状态,因为一次轮询不保证返回所有事件。
cuFileBatchIOSetUp 被源码明确标为耗时操作,因此 BatchHandle 会进入对象池复用。准备好这些对象后,才执行下面的 GdsTransport::submitTransferTasks。
下面保留原函数的所有语句,只增加中文注释。原仓库已有的英文注释也原样保留。
1 | // 实现 TENT Transport 契约:把一组逻辑 Request 追加到 GDS SubBatch 并一次提交。 |
这段代码最值得带走的不是某个 cuFile 字段,而是三层映射:
1 | TENT Request |
IOParamRange 解决“一个 task 被切成多个 slice”,cookie 解决“完成事件可能乱序或稀疏返回”。少了任何一层,公共 task_id 都无法稳定对应底层完成事件。
状态代码逐行读
cuFileBatchIOGetStatus 不在 getTransferStatus 中直接裸调,而是被封装在 updateBatchStatus 中。原因是 GetStatus 写入的是一次轮询的临时结果,而 TENT 需要跨多次轮询保留每个 slice 的稳定状态。
下面同样保留原函数语句,只添加中文注释。
1 | // 从 cuFile 取回当前可见的完成事件,并合并到 per-slice 稳定缓存。 |
GetStatus 返回后,getTransferStatus 还要完成三件事:
- 按 range 聚合:读取
[range.base, range.base + range.count)中的所有 cached event,累计完成字节,并把 cuFile 状态映射为 TENT 状态; - 保留首个失败:某个 slice 先失败、其他 slice 仍 pending 时,把失败记入
known_failure,避免后续取消状态掩盖原始错误; - 等待物理终态:可以请求
cuFileBatchIOCancel,但在所有 slice 都进入终态前,公共 task 仍保持PENDING,因为 cuFile 可能还在访问用户 buffer。
失败优先级按 FAILED > TIMEOUT > CANCELED > INVALID 聚合。已完成字节数使用 max 保证多次轮询单调不减。只有整个底层 batch 都不再被 cuFile 引用,GdsSubBatch 才能把 handle 放回池中;否则它进入 quarantine,后续清理线程继续轮询到安全终态。
TENT 还有一个容易忽略的回退边界:提交阶段 submitTransferTasks 直接返回错误时,当前运行时把 task 标成 UNSPEC;轮询阶段则只在状态为 FAILED 时触发自动跨 transport failover。对应代码分别在 transfer_engine_impl.cpp:1859-1876 和 transfer_engine_impl.cpp:2398-2415。因此,新后端不能假设“任何错误都会自动尝试下一个 transport”。
边界与验证
沿源码可以确认 TENT 与 GDS 的软件契约,但还不能仅凭代码确认机器上的实际数据路径。
- GDS 不等于必然直通:cuFile 可能根据平台、文件系统和配置进入 compatibility mode,经 POSIX 与 CPU bounce buffer 完成 I/O。Mooncake 当前没有查询 properties 或 stats 来证明某次请求实际走了 direct path。
- 文件以读写方式打开:
GdsFileContext固定使用O_RDWR | O_DIRECT,即使上层只想读文件,也需要对应的打开权限。 - 注册基址需要实机校验:buffer 登记使用
desc.addr,提交时却直接把request.source填入devPtr_base。如果后者是注册区间内部指针而非原始注册基址,需要结合实际 cuFile 版本验证是否仍命中 registered-buffer fast path。 - 16 MiB 不是外部规范:源码没有解释该值的性能依据;新后端应根据自己的最大请求、对齐、队列深度和真实 KV block 分布测量。
- 本文没有替代硬件测试:结论来自固定提交源码与 NVIDIA API 语义,没有在特定 GPU、NVMe 和文件系统组合上跑端到端带宽、CPU 占用或兼容模式验证。
实机验证至少应同时记录:nvidia-fs/cuFile 环境检查、目标文件系统支持情况、registered 与 unregistered buffer、不同 slice/depth、CPU 占用、吞吐与 P99,以及失败后 buffer 何时可安全复用。
最终判断
现在可以把开头的三个问题收束成一句话:TENT 用统一对象和生命周期定义“传输应该怎样被管理”,GDS 用 cuFile 定义“文件与 GPU 数据具体怎样被搬运”。
TENT 的主线是 Segment 与 buffer 登记、capability 选择、公共 Batch 到 SubBatch 的拆分、状态推进与有限回退;GDS 的主线是 fd/handle 和 GPU buffer 登记、Batch handle 复用、16 MiB 切片、cookie 关联、完成事件缓存,以及失败后的取消与隔离。
因此,接入新后端时可以直接复用 TENT 的上半层,但不能只替换 cuFileBatchIOSubmit。真正要实现的是从 install 到 freeSubBatch 的完整异步契约,尤其是:底层何时不再引用参数和用户内存。 只要这个终态边界讲清楚,提交、轮询、取消和资源回收才会同时正确。