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 的讲解方式保持一致。

重点源码包括:

先区分三层“NVMe-oF”

源码中的 nvmeof 同时出现在系统、元数据和 C++ 类名中,但三者不是同一件事。

  1. Linux 存储层:操作员通过 nvme discover/connect 连接远端 target,再把块设备或其文件系统挂载到本机。Mooncake 不实现 NVMe-oF wire protocol。
  2. Mooncake 元数据层:etcd 中的 Segment 使用 protocol: "nvmeof",并为每个逻辑 backing buffer 保存 length 与各机器的 local_path_map
  3. cuFile 执行层NVMeoFTransportlocal_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 下创建 NVMeoFTransportMultiTransport::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-95105-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-203src/CMakeLists.txt:88-100

1
2
3
4
5
6
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、块设备与文件系统而变化,下面只表示顺序:

1
2
3
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 在逻辑地址空间中首尾相接。

1
2
3
4
5
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

脚本实际写入:

1
2
3
4
5
6
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 把同一文件挂载到了不同路径,需要在已经完成真实挂载后补充路径映射:

1
2
3
4
5
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

可运行的最小调用骨架如下。它保持专用测试的关键顺序,但省略日志和测试数据填充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 运行前保持 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 名称:

1
2
3
4
5
6
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。

Mooncake 经典 NVMeoFTransport SOP 与断路分支

图:根据本文材料整理的自绘示意图。上半部分是构建、挂载和元数据准备;左下是 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
2
3
4
逻辑 Segment

[0 GiB, 1 GiB) → segment-0.bin,文件偏移从 0 开始
[1 GiB, 2 GiB) → segment-1.bin,文件偏移从 0 开始

如果请求覆盖 [0.75 GiB, 1.25 GiB)submitTransfer 会产生两片:

  1. slice 0:本地 buffer [0, 0.25 GiB) 对应 segment-0.bin[0.75, 1 GiB)
  2. slice 1:本地 buffer [0.25, 0.5 GiB) 对应 segment-1.bin[0, 0.25 GiB)

一个逻辑 TransferRequest 因此对应一个 TransferTask,而一个 task 可以对应多个 CUfileIOParams_ttask_to_slices 保存 {first_slice_id, slice_count};完成查询再用这个区间把底层事件聚合回公共 task。

旧实现没有显式验证整个请求区间都被 nvmeof_buffers 覆盖。如果只覆盖了一部分,已生成的 slice 仍可能完成,而 transferred_bytes 小于原始 request.length;因此实机调用必须检查完成字节数,不能只检查状态枚举。

容量限制按 task,不完全按 slice

公共 batch_size 限制的是 TransferRequest 数量,CUFileDescPoolmax_batch_size_ 限制的是物理 cuFile 参数数量。一个跨越很多 backing file 的请求可能只占一个 task,却消耗多个 slice。当前 pushParams 失败没有被 submitTransfer 检查,这也是旧路径不宜直接复制的边界。

Batch 分配逐句读

allocateBatchID 只有几行,但它决定了后续路径能否运行。下面按原语句顺序增加解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 又做了两层资源管理:

  1. 从最多 256 个 descriptor 槽位中找空位;
  2. 从 handle pool 取一个 BatchHandle,没有可复用对象时才调用昂贵的 cuFileBatchIOSetUp
  3. 为参数、稳定事件与本轮轮询事件准备彼此独立的 vector;
  4. 把 descriptor 放入 descs_[idx],后续只通过整数 desc_idx_ 访问。

分配失败没有闭环

allocCUfileDesc 可能返回 -1,但 NVMeoFTransport::allocateBatchID 没有检查,仍会返回一个看似有效的 BatchID。调用者不能仅以 batch_id != 0 推导 cuFile descriptor 已成功建立。

提交接口逐句读

submitTransfer 的核心不是固定大小切片,而是逻辑 Segment 范围与多个文件 buffer 的求交。下面保留执行顺序,用等价注释版展开每个关键语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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;
}

addSliceToCUFileBatchREAD/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
2
3
4
5
local_path_map[local_server_name]
→ open(path, O_RDWR | O_DIRECT)
→ CUfileDescr_t { type = OPAQUE_FD, fd }
→ cuFileHandleRegister
→ 缓存 CUfileHandle_t

transport 析构时,CuFileContext 再执行 cuFileHandleDeregisterclose(fd)。这使多个 Batch 可以复用文件 handle,但也带来几个旧实现边界:

  • open 的返回值没有在 cuFileHandleRegister 前显式检查;
  • 即使只读请求也以 O_RDWR 打开,需要写权限;
  • 缺失 hostname 映射时,operator[] 会得到空路径,错误最终表现为构造 context 异常;
  • fd == 0 时析构条件不会执行 close

这些问题不改变主控制流,却说明它更适合源码考古和专用测试,而不是直接成为新后端模板。

状态接口逐句读

getTransferStatus 要把“某次轮询返回的 slice completion”重新聚合成“调用者看到的 task 状态”。等价注释版如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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 的先后顺序:

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
2
3
4
5
6
7
8
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 体现了资源回收顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 的完整主线可以压缩为:

1
2
3
4
5
6
7
8
操作员挂载 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

它最重要的反面结论同样清楚:

1
2
3
4
engine 分配通用 Batch
→ MultiTransport 选择 nvmeof
→ submitTransferTask 无法附加 NVMe descriptor
→ NotImplemented

现在可以确认的是:这条路径适合回答“经典 Mooncake 怎样把逻辑文件 Segment 翻译成 cuFile Batch”,也适合借鉴 handle、cookie、range 与 quarantine 的语义。仅凭固定提交源码还不能确认具体机器上的 direct path 与性能表现,这部分必须留给 Linux GDS 实机验证。

如果下一步是让新 NDS 被当前运行时自动选择,行动边界也很明确:实现 TENT 的 SubBatch 与 transport 生命周期,复用旧路径的底层资源语义,而不是复制这套专用指针调用法。

参考文献

Author

Shaojie Tan

Posted on

2026-08-28

Updated on

2026-08-28

Licensed under