Mooncake Classic NVMeoF Transport
本文以 Mooncake 提交 468fbf63 为代码截面,承接 Mooncake NDS Integration 中的旧路径判断,并与 Mooncake TENT GDS 的讲解方式保持一致。
重点源码包括:
nvmeof_transport.cpp:专用 Batch、请求切片、提交、轮询和回收;cufile_desc_pool.cpp:cuFile batch handle、参数、事件缓存和 quarantine;cufile_context.h:POSIX fd 与CUfileHandle_t的生命周期;multi_transport.cpp:协议装载、通用 Batch 和 transport 选择;nvmeof_transport_test.cpp:真正可运行的专用调用方式。
先区分三层“NVMe-oF”
源码中的 nvmeof 同时出现在系统、元数据和 C++ 类名中,但三者不是同一件事。
- Linux 存储层:操作员通过
nvme discover/connect连接远端 target,再把块设备或其文件系统挂载到本机。Mooncake 不实现 NVMe-oF wire protocol。 - Mooncake 元数据层:etcd 中的 Segment 使用
protocol: "nvmeof",并为每个逻辑 backing buffer 保存length与各机器的local_path_map。 - cuFile 执行层:
NVMeoFTransport从local_path_map取得本机文件路径,以O_RDWR | O_DIRECT打开,登记为CUfileHandle_t,再通过 cuFile Batch API 读写。
因此,NVMeoFTransport 更准确的定位是:在已经挂载并可用的文件路径之上,用经典 Transfer Engine 元数据完成逻辑寻址,再把本地 buffer 与文件之间的 I/O 翻译成 cuFile Batch 请求。
两种 Batch 只有一种能跑
最容易误读的地方是:installTransport("nvmeof") 的确会在 USE_NVMEOF 下创建 NVMeoFTransport,MultiTransport::selectTransport 也的确能根据 Segment 的协议名找到它。装载与选择都成功,不代表通用 Batch 已接通。
两条路径的差异如下:
| 调用方式 | Batch 创建者 | BatchDesc.context |
最终提交接口 | 结果 |
|---|---|---|---|---|
engine->allocateBatchID → engine->submitTransfer |
MultiTransport |
nullptr |
NVMeoFTransport::submitTransferTask |
NotImplemented |
xport->allocateBatchID → xport->submitTransfer |
NVMeoFTransport |
NVMeoFBatchDesc* |
NVMeoFTransport::submitTransfer |
调用 cuFile Batch API |
通用路径在 multi_transport.cpp:93-196 创建自己的 BatchDesc,再按 transport 分组调用 submitTransferTask。旧后端在 nvmeof_transport.cpp:131-140 明确拒绝这类 Batch:它无法在别的对象已经创建完毕后安全挂接和回收 cuFile descriptor。
专用测试则保存 installTransport 返回的 Transport* xport,随后所有 Batch 操作都由同一个对象完成,见 nvmeof_transport_test.cpp:74-95 与 105-135。
旧路径 SOP
下面的 SOP 用于理解和复现经典专用路径。它不是面向 TENT 的生产接入指南,也不建议作为 NDS transport 的代码模板。
准备环境
至少需要:
- Linux 与可用的 NVIDIA CUDA/cuFile 环境;
- 已连接并挂载到本机的 NVMe-oF 存储,或其他能够满足当前 cuFile 测试条件的文件路径;
- etcd,默认测试连接
127.0.0.1:2379; - 本机 hostname 与
local_path_map中的 key 一致; - 目标文件已存在,并允许
O_RDWR | O_DIRECT打开。
开启构建
在现有 Mooncake 构建命令中加入 -DUSE_NVMEOF=ON。该选项会同时开启 CUDA、定义 USE_NVMEOF,把 nvmeof_transport 对象加入 transport 集合,并为 transfer_engine 链接 cufile,见 common.cmake:90,199-203 与 src/CMakeLists.txt:88-100。
1 | cmake -S . -B build \ |
nvmeof_transport_test 会被编译,但它的 add_test 在 CMake 中被注释,因此不会随普通 ctest 自动运行,见 tests/CMakeLists.txt:143-153。这是硬件相关的手工测试,不是默认 CI 覆盖。
挂载 NVMe-oF
先在操作系统层完成 discover、connect、文件系统挂载和权限准备。命令随 target transport、NQN、块设备与文件系统而变化,下面只表示顺序:
1 | sudo nvme discover -t tcp -a <target-ip> -s 4420 |
仓库中的 mount.py 虽然定义了 discover、connect 和 mount 函数,但主程序没有调用它们;文件末尾也明确保留了“用户手工挂载”的 TODO。因此,不能把运行脚本等同于存储已经挂载。
注册 Segment 元数据
register.py 把一个或多个文件串成逻辑 Segment。每个文件对应一个 NVMeoFBufferDesc;多个 buffer 在逻辑地址空间中首尾相接。
1 | python mooncake-transfer-engine/scripts/register.py \ |
脚本实际写入:
1 | key: mooncake/nvmeof/test_nvmeof |
如果另一台 initiator 把同一文件挂载到了不同路径,需要在已经完成真实挂载后补充路径映射:
1 | python mooncake-transfer-engine/scripts/mount.py \ |
mount.py 的职责只是修改 etcd 中的 local_path_map。它要求传入完整 etcd key,而且当前实现会重复 put 一次;这些都说明它更像早期辅助脚本,而不是成熟的存储编排器。
直接持有 Transport
可运行的最小调用骨架如下。它保持专用测试的关键顺序,但省略日志和测试数据填充:
1 | // 运行前保持 MC_USE_TENT 与 MC_USE_TEV1 未设置,确保走经典 TE。 |
同一个专用 Batch 最好只调用一次 submitTransfer。当前 descriptor 会累计 io_params,而 submitBatch 每次提交整个参数 vector;对同一 Batch 再次追加并提交,可能把之前的 slice 一起重新提交。
手工运行测试
从 build 目录找到生成的 nvmeof_transport_test,显式传入元数据地址、hostname 与 Segment 名称:
1 | unset MC_USE_TENT MC_USE_TEV1 |
测试包含重复写与“先写后读再 memcmp”两类路径。测试能通过只证明专用调用链在该环境下完成数据往返;还应单独检查 cuFile/GDS 指标,确认是否发生 compatibility fallback。
完整流程图
这张图只回答一个问题:同样名为 NVMeoF Batch 的两条调用链,为什么一条能进入 cuFile,另一条停在 NotImplemented? 阅读时先从上半部完成构建、挂载与元数据准备,再沿左、右两条分支比较 Batch 的创建者和私有 context。

图:根据本文材料整理的自绘示意图。上半部分是构建、挂载和元数据准备;左下是 MultiTransport 通用 Batch 的断点;右下是专用 Transport* 从 descriptor 分配到完成回收的可运行路径。可编辑源图保存在 assets/mooncake-classic-nvmeof-tech-diagrams/classic-nvmeof-sop.drawio。
从对象关系看,右侧成功路径包含三层 Batch:
| 层次 | 对象 | 主要内容 | 所有者 |
|---|---|---|---|
| 公共句柄 | BatchID / BatchDesc |
Batch 容量、task 列表、context |
Transport 基类 |
| NVMe 私有层 | NVMeoFBatchDesc |
desc_idx_、task 终态缓存、task 到 slice range |
NVMeoFTransport |
| cuFile 执行层 | CUFileBatchDesc |
BatchHandle、I/O 参数、稳定事件缓存、轮询输出区 |
CUFileDescPool |
BatchID 本身只是把 BatchDesc* 重新解释成整数句柄。旧后端把 NVMeoFBatchDesc* 填入公共 context,再通过 desc_idx_ 找到真正的 cuFile descriptor。通用 Batch 缺失的正是这条私有对象链。
请求怎样映射到文件
经典路径没有 TENT GDS 的“每 16 MiB 切一片”规则。它按 Segment 中的 nvmeof_buffers 边界切片。
假设元数据中有两个连续 backing file:
1 | 逻辑 Segment |
如果请求覆盖 [0.75 GiB, 1.25 GiB),submitTransfer 会产生两片:
- slice 0:本地 buffer
[0, 0.25 GiB)对应segment-0.bin的[0.75, 1 GiB); - slice 1:本地 buffer
[0.25, 0.5 GiB)对应segment-1.bin的[0, 0.25 GiB)。
一个逻辑 TransferRequest 因此对应一个 TransferTask,而一个 task 可以对应多个 CUfileIOParams_t。task_to_slices 保存 {first_slice_id, slice_count};完成查询再用这个区间把底层事件聚合回公共 task。
旧实现没有显式验证整个请求区间都被 nvmeof_buffers 覆盖。如果只覆盖了一部分,已生成的 slice 仍可能完成,而 transferred_bytes 小于原始 request.length;因此实机调用必须检查完成字节数,不能只检查状态枚举。
Batch 分配逐句读
allocateBatchID 只有几行,但它决定了后续路径能否运行。下面按原语句顺序增加解释:
1 | BatchID allocateBatchID(size_t batch_size) { |
allocCUfileDesc 又做了两层资源管理:
- 从最多 256 个 descriptor 槽位中找空位;
- 从 handle pool 取一个
BatchHandle,没有可复用对象时才调用昂贵的cuFileBatchIOSetUp; - 为参数、稳定事件与本轮轮询事件准备彼此独立的 vector;
- 把 descriptor 放入
descs_[idx],后续只通过整数desc_idx_访问。
提交接口逐句读
submitTransfer 的核心不是固定大小切片,而是逻辑 Segment 范围与多个文件 buffer 的求交。下面保留执行顺序,用等价注释版展开每个关键语句:
1 | Status submitTransfer(BatchID id, const vector<TransferRequest>& requests) { |
addSliceToCUFileBatch 把 READ/WRITE 映射为 CUFILE_READ/CUFILE_WRITE,填入本地基址、文件偏移、长度与 handle。函数内一开始把 cookie 写成 0,但 CUFileDescPool::pushParams 会根据参数在 vector 中的下标覆盖为 slice_id + 1,同时创建初始 CUFILE_WAITING 事件。
这种 one-based cookie 有两个目的:
- 避免
cookie == nullptr与第 0 个 slice 混淆; - 允许
GetStatus返回稀疏或乱序完成事件时,稳定写回io_events[cookie - 1]。
文件 handle 的延迟创建
每个 (target_id, buffer_id) 第一次被访问时才创建 CuFileContext:
1 | local_path_map[local_server_name] |
transport 析构时,CuFileContext 再执行 cuFileHandleDeregister 与 close(fd)。这使多个 Batch 可以复用文件 handle,但也带来几个旧实现边界:
open的返回值没有在cuFileHandleRegister前显式检查;- 即使只读请求也以
O_RDWR打开,需要写权限; - 缺失 hostname 映射时,
operator[]会得到空路径,错误最终表现为构造 context 异常; fd == 0时析构条件不会执行close。
这些问题不改变主控制流,却说明它更适合源码考古和专用测试,而不是直接成为新后端模板。
状态接口逐句读
getTransferStatus 要把“某次轮询返回的 slice completion”重新聚合成“调用者看到的 task 状态”。等价注释版如下:
1 | Status getTransferStatus(BatchID id, size_t task_id, TransferStatus& out) { |
aggregateTransferStatus 使用固定失败优先级,避免结果依赖 cuFile 报告 completion 的先后顺序:
1 | FAILED > TIMEOUT > CANCELED > INVALID |
只有 COMPLETED slice 的 event.ret 会计入 transferred_bytes。只要还有 PENDING,且没有终态失败,task 就报告 PENDING;只有 WAITING 时报告 WAITING;空 slice 集合被判为 INVALID。
为什么需要两组事件数组
CUFileBatchDesc 同时保存:
polled_events:本次cuFileBatchIOGetStatus的临时输出;io_events:按提交 slice 下标保存、跨多次轮询稳定存在的状态缓存。
状态更新过程如下:
1 | unsigned nr = submitted_slice_count; |
关键点是:本轮输出数组位置不等于原始 slice 位置。 GetStatus 返回多少个事件由 nr 给出,事件必须通过 cookie 找回提交下标。直接把本轮输出当成完整状态快照,会让乱序或稀疏 completion 错配到其他 task。
回收接口逐句读
freeBatchID 体现了资源回收顺序:
1 | Status freeBatchID(BatchID id) { |
正常 descriptor 会删除参数与事件 vector,但把昂贵的 BatchHandle 放回对象池。被 markUnreusable 的 descriptor 则连同 handle 和参数一起进入 quarantine;cleanupQuarantinedDescs 持续轮询,直到所有 I/O 都不再是 WAITING/PENDING,才调用 cuFileBatchIODestroy。
这一点值得新后端继承:API 已经报告失败,不等于 DMA 已经停止引用用户 buffer 和参数内存。 真正的释放边界必须由底层终态保证。
哪些经验还能复用
旧路径仍然包含一组有价值的设计经验:
- 逻辑文件拼接:一个 Segment 可以由多个 backing file 组成,请求按逻辑范围与 buffer 求交;
- 本机路径映射:同一共享文件在不同节点可有不同挂载路径;
- 延迟文件登记:按
(Segment, buffer)缓存文件 context,避免每个 Batch 重复打开和注册; - task/slice 两层映射:公共 task 与物理 I/O 不是一一对应关系;
- cookie 关联 completion:异步事件不能依赖返回数组位置;
- BatchHandle 复用:把昂贵的
cuFileBatchIOSetUp从每次请求中移出; - 失败隔离:取消后仍未终态的参数和 handle 不能立即复用。
但下面这些接口形态不应复制到新 NDS:
- 专用 Batch 所有权:调用者必须绕过统一 Engine,直接持有 backend 指针;
- 未接通 selector 后的提交契约:协议能被选中,却只能返回
NotImplemented; - 错误处理依赖 assert/throw:缺失 Segment、协议错误、文件打开失败难以形成稳定 Status;
- 批量内存注册空实现:
registerLocalMemoryBatch/unregisterLocalMemoryBatch直接返回 0,没有实际登记; - 手工硬件测试:主要测试不进入默认 CTest/CI;
- 容量模型错位:逻辑 task 容量与物理 slice 容量没有在提交前统一验证。
与 TENT GDS 的本质差异
| 维度 | 经典 NVMeoFTransport |
TENT GdsTransport |
|---|---|---|
| Segment 表达 | etcd nvmeof Segment,多个文件 buffer 串接 |
file://path File Segment |
| 选择方式 | MultiTransport 按 protocol 找到类,但通用提交未接通 |
selector 按 Segment、内存与 capability 选择 |
| Batch 所有权 | 必须由专用 Transport* 从分配到释放全程持有 |
公共 Batch 拆为 transport 私有 SubBatch |
| 切片规则 | 按 backing file 边界 | 当前实现按最大 16 MiB |
| 完成关联 | task range + one-based cookie | IOParamRange + one-based cookie |
| 回退 | 无统一自动回退 | 可按策略尝试 GDS/IOUring,但仍有错误阶段边界 |
| 适合作为 NDS 模板 | 否,只借鉴底层资源语义 | 是,复用完整 transport 生命周期 |
这解释了一个看似矛盾的现象:旧路径已经拥有比“玩具 demo”更完整的 cuFile 资源管理,却仍不是合格的新后端模板。问题不在数据搬运能力,而在它没有进入当前运行时的统一对象与生命周期。
验证清单
在目标 Linux 环境复现时,至少检查:
- 构建日志显示
NVMe-oF support is enabled,且链接到libcufile; - NVMe-oF 设备已真实 connect 和 mount,不只运行了
mount.py; - etcd 中存在
mooncake/nvmeof/<segment>,protocol 为nvmeof; -
local_path_map包含local_server_name,对应路径存在且可O_RDWR | O_DIRECT打开; -
installTransport("nvmeof")返回非空,且后续 Batch API 全部调用同一个xport; - 每次
submitTransfer后轮询到终态,再调用xport->freeBatchID; - 跨 backing file 请求的完成字节数等于原始 request length;
- 注入文件权限、截断、partial completion 和 cancel 失败,确认 handle 不会被过早复用;
- 使用 GDS 工具和统计确认 direct path、compatibility fallback、吞吐、CPU 占用与 P99。
最终判断
经典 NVMeoFTransport 的完整主线可以压缩为:
1 | 操作员挂载 NVMe-oF 文件系统 |
它最重要的反面结论同样清楚:
1 | engine 分配通用 Batch |
现在可以确认的是:这条路径适合回答“经典 Mooncake 怎样把逻辑文件 Segment 翻译成 cuFile Batch”,也适合借鉴 handle、cookie、range 与 quarantine 的语义。仅凭固定提交源码还不能确认具体机器上的 direct path 与性能表现,这部分必须留给 Linux GDS 实机验证。
如果下一步是让新 NDS 被当前运行时自动选择,行动边界也很明确:实现 TENT 的 SubBatch 与 transport 生命周期,复用旧路径的底层资源语义,而不是复制这套专用指针调用法。
参考文献
Mooncake Classic NVMeoF Transport