Mooncake Classic NVMeoF Transport
导言
在分析 Mooncake 的 NDS 接入位置时,经典 NVMeoFTransport 很容易被误认为 TENT GdsTransport 的前一版:二者都注册 buffer 和文件 handle,都使用 cuFile Batch API,也都维护异步完成事件。
但这条旧路径真正特殊的地方不在 cuFile,而在 Batch 所有权。可运行的调用必须直接持有 Transport*,并始终走 xport->allocateBatchID → xport->submitTransfer → xport->getTransferStatus → xport->freeBatchID。一旦换成 engine->allocateBatchID → engine->submitTransfer,批次便由 MultiTransport 创建,NVMeoFTransport 无法附加私有 descriptor,最终返回 NotImplemented。
本文把这条旧路径单独展开:先给出从 NVMe-oF 挂载到专用测试的 SOP,再画出可运行路径与断路分支,最后按执行顺序逐句解释 Batch 分配、请求切片、cuFile 提交、完成事件聚合与资源回收。
本文以 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 请求。
名字不等于数据路径证明
Segment 的协议名是 nvmeof,不代表 NVMeoFTransport 自己建立了 NVMe-oF 网络连接;调用了 cuFile,也不代表每次传输必然走 GPU 到存储的 direct path。文件系统、驱动、内存类型、对齐和 cuFile 配置都可能影响实际路径,最终仍要结合 GDS 环境检查与实机指标确认。
两种 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打开。
macOS 不能直接执行
这条 SOP 依赖 Linux NVMe、CUDA、cuFile 与 GDS,不能在当前 macOS 博客仓库中完成真实 I/O 验证。本文对控制流的结论来自固定提交源码;带宽、CPU 占用和 direct/compatibility path 必须在目标 Linux 机器上测量。
开启构建¶
在现有 Mooncake 构建命令中加入 -DUSE_NVMEOF=ON。该选项会同时开启 CUDA、定义 USE_NVMEOF,把 nvmeof_transport 对象加入 transport 集合,并为 transfer_engine 链接 cufile,见 common.cmake:90,199-203 与 src/CMakeLists.txt:88-100。
cmake -S . -B build \
-DUSE_NVMEOF=ON \
-DUSE_ETCD=ON \
-DBUILD_UNIT_TESTS=ON
cmake --build build --target nvmeof_transport_test -j
nvmeof_transport_test 会被编译,但它的 add_test 在 CMake 中被注释,因此不会随普通 ctest 自动运行,见 tests/CMakeLists.txt:143-153。这是硬件相关的手工测试,不是默认 CI 覆盖。
挂载 NVMe-oF¶
先在操作系统层完成 discover、connect、文件系统挂载和权限准备。命令随 target transport、NQN、块设备与文件系统而变化,下面只表示顺序:
sudo nvme discover -t tcp -a <target-ip> -s 4420
sudo nvme connect -t tcp -n <target-nqn> -a <target-ip> -s 4420
sudo mount /dev/<nvme-device> /mnt/mooncake-nvme
仓库中的 mount.py 虽然定义了 discover、connect 和 mount 函数,但主程序没有调用它们;文件末尾也明确保留了“用户手工挂载”的 TODO。因此,不能把运行脚本等同于存储已经挂载。
注册 Segment 元数据¶
register.py 把一个或多个文件串成逻辑 Segment。每个文件对应一个 NVMeoFBufferDesc;多个 buffer 在逻辑地址空间中首尾相接。
python mooncake-transfer-engine/scripts/register.py \
127.0.0.1 \
test_nvmeof \
/mnt/mooncake-nvme/segment-0.bin \
/mnt/mooncake-nvme/segment-1.bin
脚本实际写入:
key: mooncake/nvmeof/test_nvmeof
value.protocol: nvmeof
value.buffers[i].length: 文件长度
value.buffers[i].file_path: 注册机器上的原始路径
value.buffers[i].local_path_map[hostname]: 当前机器可访问的路径
如果另一台 initiator 把同一文件挂载到了不同路径,需要在已经完成真实挂载后补充路径映射:
python mooncake-transfer-engine/scripts/mount.py \
127.0.0.1 \
mooncake/nvmeof/test_nvmeof \
/mnt/original/segment-0.bin \
/mnt/local-view/segment-0.bin
mount.py 的职责只是修改 etcd 中的 local_path_map。它要求传入完整 etcd key,而且当前实现会重复 put 一次;这些都说明它更像早期辅助脚本,而不是成熟的存储编排器。
直接持有 Transport¶
可运行的最小调用骨架如下。它保持专用测试的关键顺序,但省略日志和测试数据填充:
// 运行前保持 MC_USE_TENT 与 MC_USE_TEV1 未设置,确保走经典 TE。
TransferEngine engine(false); // false 只关闭拓扑自动发现。
engine.init(etcd_addr, local_server_name, local_ip, rpc_port);
void* args[] = {nullptr, nullptr};
Transport* xport = engine.installTransport("nvmeof", args);
engine.registerLocalMemory(buffer, buffer_size, "cpu:0");
auto segment_id = engine.openSegment("nvmeof/test_nvmeof");
auto batch_id = xport->allocateBatchID(1); // 必须由 xport 分配。
TransferRequest request{
.opcode = TransferRequest::READ,
.source = buffer,
.target_id = segment_id,
.target_offset = 0,
.length = request_size,
};
xport->submitTransfer(batch_id, {request}); // 必须由同一个 xport 提交。
TransferStatus status;
do {
xport->getTransferStatus(batch_id, 0, status);
} while (status.s == WAITING || status.s == PENDING);
xport->freeBatchID(batch_id); // 仍由同一个 xport 回收私有上下文。
engine.unregisterLocalMemory(buffer);
同一个专用 Batch 最好只调用一次 submitTransfer。当前 descriptor 会累计 io_params,而 submitBatch 每次提交整个参数 vector;对同一 Batch 再次追加并提交,可能把之前的 slice 一起重新提交。
不要混用释放接口
Batch 由 xport->allocateBatchID 分配后,应由 xport->freeBatchID 释放。测试的 MultipleRead 第一段曾用 engine->freeBatchID 释放专用 Batch;通用释放只能删除基础 BatchDesc,不会执行 NVMe 私有 descriptor 的回收逻辑,不能把这一处测试写法当成正确 SOP。
手工运行测试¶
从 build 目录找到生成的 nvmeof_transport_test,显式传入元数据地址、hostname 与 Segment 名称:
unset MC_USE_TENT MC_USE_TEV1
./nvmeof_transport_test \
--metadata_server=127.0.0.1:2379 \
--local_server_name="$(hostname)" \
--segment_id=nvmeof/test_nvmeof
测试包含重复写与“先写后读再 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:
如果请求覆盖 [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;因此实机调用必须检查完成字节数,不能只检查状态枚举。
容量限制按 task,不完全按 slice
公共 batch_size 限制的是 TransferRequest 数量,CUFileDescPool 的 max_batch_size_ 限制的是物理 cuFile 参数数量。一个跨越很多 backing file 的请求可能只占一个 task,却消耗多个 slice。当前 pushParams 失败没有被 submitTransfer 检查,这也是旧路径不宜直接复制的边界。
Batch 分配逐句读¶
allocateBatchID 只有几行,但它决定了后续路径能否运行。下面按原语句顺序增加解释:
BatchID allocateBatchID(size_t batch_size) {
// 创建 NVMe 私有 Batch 上下文;通用 MultiTransport 不会创建它。
auto* nvme_batch = new NVMeoFBatchDesc();
// 让基类创建公共 BatchDesc;返回值实质上承载 BatchDesc 指针。
auto batch_id = Transport::allocateBatchID(batch_size);
// 从整数句柄取回公共 BatchDesc,准备挂接私有对象。
auto& batch = *reinterpret_cast<BatchDesc*>(batch_id);
// 从 CUFileDescPool 分配一个 descriptor 槽位,并取得可复用 BatchHandle。
nvme_batch->desc_idx_ = desc_pool_->allocCUfileDesc(batch_size);
// 预留每个逻辑 task 的最终状态,避免提交阶段频繁扩容。
nvme_batch->transfer_status.reserve(batch_size);
// 预留 task → [first slice, count] 映射。
nvme_batch->task_to_slices.reserve(batch_size);
// 关键一步:公共 Batch 从此带有 NVMe 专用上下文。
batch.context = nvme_batch;
// 调用者必须把这个句柄继续交回同一个 xport。
return batch_id;
}
allocCUfileDesc 又做了两层资源管理:
- 从最多 256 个 descriptor 槽位中找空位;
- 从 handle pool 取一个
BatchHandle,没有可复用对象时才调用昂贵的cuFileBatchIOSetUp; - 为参数、稳定事件与本轮轮询事件准备彼此独立的 vector;
- 把 descriptor 放入
descs_[idx],后续只通过整数desc_idx_访问。
分配失败没有闭环
allocCUfileDesc 可能返回 -1,但 NVMeoFTransport::allocateBatchID 没有检查,仍会返回一个看似有效的 BatchID。调用者不能仅以 batch_id != 0 推导 cuFile descriptor 已成功建立。
提交接口逐句读¶
submitTransfer 的核心不是固定大小切片,而是逻辑 Segment 范围与多个文件 buffer 的求交。下面保留执行顺序,用等价注释版展开每个关键语句:
Status submitTransfer(BatchID id, const vector<TransferRequest>& requests) {
// 还原公共 Batch;这里假设 id 来自本 transport,没有先校验 0 或类型。
auto& batch = *reinterpret_cast<BatchDesc*>(id);
// 还原 allocateBatchID 填入的 NVMe 私有上下文。
auto& nvme_batch = *reinterpret_cast<NVMeoFBatchDesc*>(batch.context);
// 公共容量按逻辑 request/task 数检查。
if (batch.task_list.size() + requests.size() > batch.batch_size)
return InvalidArgument;
// 新 task 从现有 task_list 尾部继续追加。
size_t task_id = batch.task_list.size();
// 新 slice 从 cuFile descriptor 已有参数数量之后继续编号。
size_t slice_id = desc_pool_->getSliceNum(nvme_batch.desc_idx_);
// 一次性扩展 task 存储;每个 request 对应一个 TransferTask。
batch.task_list.resize(task_id + requests.size());
// 同一 target_id 的 SegmentDesc 在本次提交中只查询一次。
unordered_map<SegmentID, shared_ptr<SegmentDesc>> segment_cache;
for (const auto& request : requests) {
// 当前逻辑请求对应当前 task。
auto& task = batch.task_list[task_id];
// 首次遇到 target_id 时从 TransferMetadata 读取 Segment。
auto segment = get_or_cache_segment(request.target_id);
// 旧实现用 assert 要求协议必须是 nvmeof,不返回可恢复 Status。
assert(segment->protocol == "nvmeof");
// 请求区间使用 Segment 逻辑偏移,而不是某个文件的直接偏移。
uint64_t request_begin = request.target_offset;
uint64_t request_end = request.target_offset + request.length;
// current_offset 表示当前 backing file 在逻辑 Segment 中的起点。
uint64_t current_offset = 0;
uint32_t buffer_id = 0;
for (const auto& file_buffer : segment->nvmeof_buffers) {
// 只处理与请求逻辑区间相交的 backing file。
if (overlap(request_begin, request.length,
current_offset, file_buffer.length)) {
// 求交集,得到该 slice 在逻辑 Segment 中的起止位置。
uint64_t slice_begin = max(request_begin, current_offset);
uint64_t slice_end =
min(request_end, current_offset + file_buffer.length);
// 路径必须来自当前 local_server_name 的本地挂载映射。
const char* path =
file_buffer.local_path_map[local_server_name_].c_str();
// 本地 buffer 地址随 slice 在请求中的偏移前移。
void* local_ptr = byte_add(request.source,
slice_begin - request_begin);
// 文件偏移相对于当前 backing file 的起点重新归零。
uint64_t file_offset = slice_begin - current_offset;
uint64_t slice_length = slice_end - slice_begin;
// 建立经典 Transport 的 task/slice 账本。
addSliceToTask(local_ptr, slice_length, file_offset,
request.opcode, task, path);
// 每个 (Segment, backing file) 只创建一个 CuFileContext。
auto key = pair{request.target_id, buffer_id};
auto fh = get_or_create_context(key, path)->getHandle();
// 构造 CUfileIOParams_t 并追加到当前 cuFile descriptor。
addSliceToCUFileBatch(local_ptr, file_offset, slice_length,
nvme_batch.desc_idx_, request.opcode, fh);
}
// 下一个 backing file 在逻辑地址空间中紧跟当前文件。
++buffer_id;
current_offset += file_buffer.length;
}
// task 初始状态记为 PENDING,真正状态来自后续 completion。
nvme_batch.transfer_status.push_back({PENDING, 0});
// 保存该 task 对应的连续 slice 区间。
nvme_batch.task_to_slices.push_back({slice_id, task.slice_count});
// 下一个 task 与 slice 都从当前尾部继续。
++task_id;
slice_id += task.slice_count;
}
// 一次提交 descriptor 中当前保存的全部 CUfileIOParams_t。
desc_pool_->submitBatch(nvme_batch.desc_idx_);
// OK 只表示 BatchIOSubmit 成功,I/O 仍在异步执行。
return OK;
}
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:
local_path_map[local_server_name]
→ open(path, O_RDWR | O_DIRECT)
→ CUfileDescr_t { type = OPAQUE_FD, fd }
→ cuFileHandleRegister
→ 缓存 CUfileHandle_t
transport 析构时,CuFileContext 再执行 cuFileHandleDeregister 与 close(fd)。这使多个 Batch 可以复用文件 handle,但也带来几个旧实现边界:
open的返回值没有在cuFileHandleRegister前显式检查;- 即使只读请求也以
O_RDWR打开,需要写权限; - 缺失 hostname 映射时,
operator[]会得到空路径,错误最终表现为构造 context 异常; fd == 0时析构条件不会执行close。
这些问题不改变主控制流,却说明它更适合源码考古和专用测试,而不是直接成为新后端模板。
状态接口逐句读¶
getTransferStatus 要把“某次轮询返回的 slice completion”重新聚合成“调用者看到的 task 状态”。等价注释版如下:
Status getTransferStatus(BatchID id, size_t task_id, TransferStatus& out) {
// 先拒绝空 Batch 句柄。
if (id == 0) return InvalidArgument;
// 还原公共 Batch,并检查 task 下标。
auto& batch = *reinterpret_cast<BatchDesc*>(id);
if (task_id >= batch.task_list.size()) return InvalidArgument;
// context 为空说明 Batch 不是由这个 NVMeoFTransport 分配。
if (batch.context == nullptr) return InvalidArgument;
auto& task = batch.task_list[task_id];
auto& nvme_batch = *reinterpret_cast<NVMeoFBatchDesc*>(batch.context);
// task_to_slices 缺项意味着该 task 没有形成可查询的提交范围。
if (task_id >= nvme_batch.task_to_slices.size()) return InvalidArgument;
// 已经观察到终态后直接返回缓存,不再重复查询 cuFile。
if (task.is_finished) {
out = nvme_batch.transfer_status[task_id];
return OK;
}
// 取回该 task 对应的 [first slice, slice count]。
auto [first_slice, slice_count] = nvme_batch.task_to_slices[task_id];
// 每个线程复用临时 vector,先轮询一次整个 cuFile batch,
// 再读取这个 task 范围内的稳定事件缓存。
thread_local vector<TransferStatus> slice_statuses;
collectSliceStatuses(nvme_batch.desc_idx_, first_slice, slice_count,
slice_statuses);
// 聚合完成字节、等待态和固定优先级的失败态。
bool all_terminal = false;
out = aggregateTransferStatus(slice_statuses, all_terminal);
// 已出现终态失败、但仍有兄弟 slice 在运行时,先请求整批取消。
if (!all_terminal && isTerminalFailure(out.s)) {
desc_pool_->cancelBatch(nvme_batch.desc_idx_);
// 取消是 best effort,需要再次读取真实 completion。
collectSliceStatuses(nvme_batch.desc_idx_, first_slice, slice_count,
slice_statuses);
out = aggregateTransferStatus(slice_statuses, all_terminal);
// 仍未全部终态时禁止复用 handle;descriptor 后续进入 quarantine。
if (!all_terminal && isTerminalFailure(out.s)) {
desc_pool_->markUnreusable(nvme_batch.desc_idx_);
all_terminal = true;
}
}
// 只有决定向上公布终态时,才缓存状态并允许基础 Batch 被释放。
if (all_terminal) {
nvme_batch.transfer_status[task_id] = out;
task.is_finished = true;
}
return OK;
}
aggregateTransferStatus 使用固定失败优先级,避免结果依赖 cuFile 报告 completion 的先后顺序:
只有 COMPLETED slice 的 event.ret 会计入 transferred_bytes。只要还有 PENDING,且没有终态失败,task 就报告 PENDING;只有 WAITING 时报告 WAITING;空 slice 集合被判为 INVALID。
为什么需要两组事件数组¶
CUFileBatchDesc 同时保存:
polled_events:本次cuFileBatchIOGetStatus的临时输出;io_events:按提交 slice 下标保存、跨多次轮询稳定存在的状态缓存。
状态更新过程如下:
unsigned nr = submitted_slice_count;
cuFileBatchIOGetStatus(handle, 0, &nr, polled_events.data(), nullptr);
for (size_t i = 0; i < nr; ++i) {
auto cookie = as_integer(polled_events[i].cookie);
if (cookie >= 1 && cookie <= io_events.size())
io_events[cookie - 1] = polled_events[i];
}
关键点是:本轮输出数组位置不等于原始 slice 位置。 GetStatus 返回多少个事件由 nr 给出,事件必须通过 cookie 找回提交下标。直接把本轮输出当成完整状态快照,会让乱序或稀疏 completion 错配到其他 task。
回收接口逐句读¶
freeBatchID 体现了资源回收顺序:
Status freeBatchID(BatchID id) {
// 先保存 NVMe 私有指针和 cuFile descriptor 下标。
auto& batch = *reinterpret_cast<BatchDesc*>(id);
auto* nvme_batch = reinterpret_cast<NVMeoFBatchDesc*>(batch.context);
int desc_idx = nvme_batch->desc_idx_;
// 基类检查所有 task 都 finished;未完成时返回 BatchBusy。
auto status = Transport::freeBatchID(id);
if (!status.ok()) return status;
// 公共 Batch 删除成功后,再释放 NVMe 私有上下文。
delete nvme_batch;
// 最后处理 cuFile descriptor 与 BatchHandle。
desc_pool_->freeCUfileDesc(desc_idx);
return OK;
}
正常 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 的完整主线可以压缩为:
操作员挂载 NVMe-oF 文件系统
→ register.py / mount.py 发布本机路径元数据
→ installTransport("nvmeof") 取得专用 xport
→ xport 分配带 NVMe 私有 context 的 Batch
→ 逻辑 Segment 范围按 backing file 边界切片
→ 延迟注册文件 handle,构造并提交 cuFile Batch
→ cookie 缓存 completion,按 task slice range 聚合
→ 终态后回收;不安全失败进入 quarantine
它最重要的反面结论同样清楚:
engine 分配通用 Batch
→ MultiTransport 选择 nvmeof
→ submitTransferTask 无法附加 NVMe descriptor
→ NotImplemented
现在可以确认的是:这条路径适合回答“经典 Mooncake 怎样把逻辑文件 Segment 翻译成 cuFile Batch”,也适合借鉴 handle、cookie、range 与 quarantine 的语义。仅凭固定提交源码还不能确认具体机器上的 direct path 与性能表现,这部分必须留给 Linux GDS 实机验证。
如果下一步是让新 NDS 被当前运行时自动选择,行动边界也很明确:实现 TENT 的 SubBatch 与 transport 生命周期,复用旧路径的底层资源语义,而不是复制这套专用指针调用法。
参考文献¶
- Mooncake classic NVMeoF transport
- Mooncake CUFile descriptor pool
- Mooncake CUFile context
- Mooncake MultiTransport
- Mooncake NVMeoF transport test
- Mooncake NVMeoF metadata design
- NVIDIA GPUDirect Storage Documentation
- NVIDIA GPUDirect Storage Overview
- NVIDIA GPUDirect Storage cuFile API Reference
