Mooncake File vs Block Device
导言
我最开始想确认的是一个很具体的问题:Mooncake Classic TE 和 TENT 打开的究竟都是文件系统普通文件,还是也能把 /dev/nvme0n1 这样的 SSD 裸块设备交给 GDS?源码里两边都会调用 open(path, O_DIRECT),NVIDIA cuFile 又确实接受 device fd,看起来答案应该是“可以”。继续向上追到 Segment 层后,结论却发生了分叉。
这篇文章把这条调用链从头串起来:先解释普通文件、块设备、S_ISREG 与 S_ISBLK,再看 GDS 提供了什么 API、Mooncake 实际用了什么,最后落到 cuFileBatchIOSubmit 的同一组参数为什么在两个场景里具有不同含义。核心判断是:GDS 统一了 I/O 接口,却没有替 Mooncake 补上块设备的容量、对齐、越界和独占语义。
本文分别核对 Classic TE 提交 468fbf63、TENT 提交 89da2c3a 和 2026-08-28 的 main 提交 0518784d。下述关键判断在三个相应代码截面中保持一致。TENT 的全称是 Transfer Engine NEXT,是 Classic TE 的后继运行时,见官方概览。
两条路径不是同一种对象¶
最容易误判的地方,是 /mnt/nvme/segment.bin 和 /dev/nvme0n1 看起来都只是一个字符串,也都能传给 open(2)。但 pathname 只是门牌号,门后面的对象并不相同。
可以先用图书馆建立直觉:
- 普通文件像借阅系统里的一本书。应用按文件名、文件内页码读写,文件系统负责把“第几页”映射到 SSD 上真正的数据块。
- 裸块设备像绕过借阅系统,直接进入仓库按货架编号取放纸张。没有文件名、目录、inode,也没有文件系统替应用保护元数据。
- 文件系统所在 SSD只是介质相同。打开
/mnt/nvme/segment.bin,并不等于直接打开承载它的/dev/nvme0n1。
更准确地说:
| 对象 | 内核类型 | 容量来源 | 偏移含义 | 写入风险 |
|---|---|---|---|---|
/mnt/nvme/segment.bin |
普通文件,S_ISREG |
st_size |
从文件开头计算 | 文件系统管理文件映射与空间 |
/dev/nvme0n1 |
块特殊文件,S_ISBLK |
BLKGETSIZE64 |
从整块设备开头计算 | 可直接覆盖分区表、文件系统和已有数据 |
/dev/nvme0n1p1 |
块特殊文件,S_ISBLK |
BLKGETSIZE64 |
从该分区开头计算 | 可直接破坏该分区内容 |
S_ISREG 和 S_ISBLK 不是文件,也不是 GDS API。它们是检查 stat 结果中 st_mode 类型位的宏:
struct stat st;
stat(path.c_str(), &st);
if (S_ISREG(st.st_mode)) {
// 普通文件
} else if (S_ISBLK(st.st_mode)) {
// 块设备节点
}
因此,“传入的是一个路径”不等于“只支持 filesystem file”,而“底层 cuFile 接受 device file”也不等于 Mooncake 的上层 Segment API 已经接通块设备。
结论矩阵¶
| 路径 | 普通文件 | /dev/nvme* 裸块设备 |
判断 |
|---|---|---|---|
| Classic TE 官方 NVMeoF SOP | 支持 | 未形成一等支持 | 文档要求先挂载,注册工具按普通文件读取长度 |
Classic NVMeoFTransport 数据面 |
支持 | 代码上没有主动排除 | 直接 open(O_DIRECT) 后注册 cuFile handle,但缺少块设备容量、校验和专门测试 |
TENT file:// Segment |
支持 | 明确拒绝 | stat 后强制 S_ISREG |
TENT GdsTransport 类内部 |
支持 | cuFile 层理论可接收 device fd | 正常调用在进入该类之前已经被 Segment 校验拦截 |
TENT IOUringTransport 类内部 |
支持 | Linux I/O 层理论可打开 | 同样被 file:// Segment 校验拦截,且没有块容量与对齐建模 |
| NVIDIA cuFile API | 支持 | API 契约允许 device file/raw device file | 实际 direct path 仍取决于 CUDA/GDS、驱动、设备、权限和配置 |
这里的“代码上没有主动排除”不应表述成“Mooncake 已正式支持”。正式支持至少还需要公开建模、容量发现、边界校验、对齐处理、错误语义和硬件测试。
Classic TE 的真实边界¶
Classic 的 NVMeoFTransport 从 Segment 元数据的 local_path_map 取得当前机器上的路径,然后创建 CuFileContext。构造函数只做三件事:
int fd = open(filename, O_RDWR | O_DIRECT);
desc.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD;
desc.handle.fd = fd;
cuFileHandleRegister(&handle, &desc);
这段代码见 cufile_context.h:67-74。它没有执行 stat,也没有检查 S_ISREG,所以数据面本身不会因为路径是块设备节点而主动拒绝它。
但是,Classic 的公开使用方式明显围绕普通文件设计:
- 官方设计文档写的是先把远端存储挂载到本机,再传入文件路径,示例也是
/mnt/.../nvme0,见transfer-engine/index.md:29-30与221-245。 register.py使用os.path.getsize(file)生成buffer.length,随后把同一路径写入file_path与local_path_map,见register.py:41-49。脚本没有使用块设备容量查询接口。NVMeoFBufferDesc.length完全来自元数据,传输时靠它把逻辑 Segment 切成文件区间;底层 transport 不会为块设备重新发现容量,见transfer_metadata.cpp:979-989。
所以 Classic 应分成两句话描述:
- 官方和开箱即用路径:打开已挂载文件系统中的普通文件;
- 核心数据面潜力:如果人工构造正确的 Segment 长度与
/dev/nvme*路径,并且当前 cuFile 环境接受该设备文件,现有代码没有S_ISREG阻止它;但这属于未完整建模、未见专门测试覆盖的路径。
Classic 的绕过路径不是安全承诺
对整盘或分区设备执行 WRITE 会直接覆盖 LBA,可能破坏分区表、文件系统或已有数据。没有独占、容量和边界保护的情况下,不应仅因为 open 与 cuFileHandleRegister 成功就把它用于生产。
TENT 在 transport 前拒绝块设备¶
TENT 对本地文件使用 file:// Segment。真正解析 Segment 时,SegmentManager::makeFileRemote 会去掉前缀、执行 stat,随后要求路径必须满足 S_ISREG:
struct stat st;
if (stat(path.c_str(), &st) || !S_ISREG(st.st_mode))
return Status::InvalidArgument("Invalid path: " + path);
buffer.path = path;
buffer.length = st.st_size;
见 segment_manager.cpp:192-217。因此:
openSegment("file:///dev/nvme0n1")创建 handle 时可能暂时不报错,因为openRemote先只登记名字;- 第一次解析或提交该 Segment 时会进入
makeFileRemote; - 块设备满足
S_ISBLK而不是S_ISREG,所以返回InvalidArgument; - GDS 与 io_uring selector 都拿不到一个有效的
FileSegmentDesc。
这是一条确定的源码结论:当前 TENT 公开 File Segment 不支持 SSD 裸块设备。
TENT GDS 为什么看起来又能打开¶
如果只阅读 GdsFileContext,很容易得出相反结论。它与 Classic 几乎相同:
int fd = open(path.c_str(), O_RDWR | O_DIRECT);
desc_.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD;
desc_.handle.fd = fd;
cuFileHandleRegister(&handle_, &desc_);
见 gds_transport.cpp:32-49。该类没有再次执行 S_ISREG,所以单独看它,普通文件与设备节点都可能产生 fd。
问题在于 GdsTransport::findFileContext 的路径只能来自 SegmentType::File 的 FileSegmentDesc,见 gds_transport.cpp:404-435。正常 file:// 入口创建这个 descriptor 时已经通过 S_ISREG 筛选。
因此更准确的回答是:
NVIDIA cuFile 和 TENT 的
GdsFileContext具备接收 device fd 的底层形态,但 Mooncake TENT 的公开 Segment 层没有把 SSD 块设备放行,所以当前 TENT GDS 不支持直接打开裸 SSD 块设备。
TENT 的 IOUringTransport 也一样。它先尝试 open(path, O_RDWR | O_DIRECT),失败后才回退到普通 O_RDWR,见 io_uring_transport.cpp:33-50;但路径同样只能来自已经通过普通文件检查的 FileSegmentDesc。即便绕过元数据注入设备路径,当前实现也没有为块设备补充容量发现和完整的 offset/length 对齐校验,不能算现成支持。
cuFile 底层是否支持 device file¶
NVIDIA 当前 cuFile API 文档对 cuFileHandleRegister 的错误定义说明:路径既可以是 regular file,也可以是 symbolic link 或 device file;不属于这些类型时才返回 CU_FILE_INVALID_FILE_TYPE。同时,GDS Overview 在 cuFile Batch API 的 VFS 路径描述中明确包含 raw device files。
开源 gds-nvidia-fs 的实现进一步排除了“文档只是泛指设备文件”的疑问:
nvfs-core.h:255-263对S_ISBLK使用inode->i_rdev取得块设备 major/minor;nvfs-core.c:1663-1670在 I/O 初始化阶段显式识别 block device file;nvfs-core.c:1738-1744要求该数据路径上的 fd 带O_DIRECT;nvfs-core.c:2007-2012对 raw block device file 明确跳过普通文件的fallocate检查。
这意味着cuFile API 层并不天然排斥裸设备节点。不过,能注册 handle、能完成 I/O、能走真正的 GPU Direct Storage direct path 是三个不同层次:
- 设备与驱动必须被当前 CUDA/GDS 版本支持;
O_DIRECT、offset、长度和 buffer 需要满足实际路径的约束;- 容器需要暴露对应
/dev节点与权限; cufile.json的兼容模式、设备黑名单和挂载配置会改变路径选择;- 不支持 direct path 时可能失败,也可能进入 compatibility path,取决于配置与 API 场景。
因此,NVIDIA 的能力只能证明“Mooncake 可以设计这种后端”,不能替 Mooncake 当前的 Segment 校验补上公开支持。
GDS 基础 API 与 Mooncake 使用情况¶
GDS 没有单独的 cuFileOpenBlockDevice 或 cuFileReadBlock。它采用的是一套基于文件描述符的统一接口:应用先用 Linux open 打开普通文件或块设备节点,再把得到的 fd 包装成 CU_FILE_HANDLE_TYPE_OPAQUE_FD 并注册给 cuFile。此后的同步、批量或 CUDA Stream I/O 接口不因对象是普通文件还是块设备而改名。
最小同步读取流程可以概括为:
int fd = open("/dev/nvme0n1", O_RDWR | O_DIRECT); // Linux API
CUfileDescr_t desc{};
desc.type = CU_FILE_HANDLE_TYPE_OPAQUE_FD;
desc.handle.fd = fd;
CUfileHandle_t handle;
cuFileHandleRegister(&handle, &desc); // cuFile API
cuFileRead(handle, gpu_buffer, length, device_offset, 0);
cuFileHandleDeregister(handle);
close(fd);
其中,BLKGETSIZE64、BLKSSZGET 和 STATX_DIOALIGN 都属于 Linux 的设备发现与 direct-I/O 约束接口,不是 NVIDIA GDS API。它们负责回答“设备多大、怎样对齐”;cuFile 负责回答“怎样在 GPU buffer 与已经打开的 fd 之间搬数据”。
| 功能层 | 主要 API | Classic NVMeoFTransport |
TENT GdsTransport |
|---|---|---|---|
| 驱动会话 | cuFileDriverOpen、cuFileDriverClose |
调用 Open,未见 Close |
调用 Open,未见 Close |
| fd 注册 | cuFileHandleRegister、cuFileHandleDeregister |
已使用 | 已使用 |
| GPU buffer 注册 | cuFileBufRegister、cuFileBufDeregister |
已使用 | 已使用 |
| 同步 I/O | cuFileRead、cuFileWrite |
未使用 | 未使用 |
| 批量 I/O | cuFileBatchIOSetUp、Submit、GetStatus、Cancel、Destroy |
已使用 | 已使用 |
| CUDA Stream I/O | cuFileStreamRegister、cuFileReadAsync、cuFileWriteAsync、cuFileStreamDeregister |
未使用 | 未使用 |
| 能力与配置 | cuFileDriverGetProperties、P2P flag 等接口 |
未见使用 | 未见使用 |
| 块设备容量与对齐 | BLKGETSIZE64、BLKSSZGET、STATX_DIOALIGN |
未使用 | 未使用 |
Mooncake 实际选择的是 cuFile Batch API:先创建 batch handle,提交多条 I/O,再轮询完成状态,必要时取消,最后销毁。Classic 的调用分别位于 nvmeof_transport.cpp、cufile_context.h 与 cufile_desc_pool.cpp;TENT 的对应调用集中在 gds_transport.cpp。
底层 API 已有,上层块设备语义仍缺失
NVIDIA 已经提供让 raw block device fd 参与 GDS I/O 的基础能力,Mooncake 也已经使用了这套 fd 注册与批量 I/O API。但当前 Mooncake 没有使用块设备容量、对齐、越界和独占管理接口;TENT 还在进入 GDS transport 前要求 S_ISREG。所以“cuFile 基础 API 已有”与“Mooncake 已完整支持裸块设备”是两个不同结论。
同一个 Batch 参数为何含义不同¶
顺着 API 继续追,会遇到一个自然的问题:既然普通文件和块设备都调用 cuFileBatchIOSubmit,它们提交的参数到底有什么区别?
接口本身没有区别:
CUfileError_t cuFileBatchIOSubmit(
CUfileBatchHandle_t batch_id,
unsigned nr,
CUfileIOParams_t* iocbp,
unsigned flags);
batch_id 都表示批量队列,nr 都表示请求数,flags 当前都传 0。真正描述每条 I/O 的是 iocbp 指向的 CUfileIOParams_t 数组:
typedef struct CUfileIOParams {
CUfileBatchMode_t mode;
union {
struct {
void* devPtr_base;
off_t file_offset;
off_t devPtr_offset;
size_t size;
} batch;
} u;
CUfileHandle_t fh;
CUfileOpcode_t opcode;
void* cookie;
} CUfileIOParams_t;
逐项对齐后,差异其实很集中:
| 字段 | 普通文件 | 裸块设备 | 差异 |
|---|---|---|---|
mode |
CUFILE_BATCH |
CUFILE_BATCH |
无 |
opcode |
CUFILE_READ 或 CUFILE_WRITE |
相同 | 无 |
devPtr_base |
GPU buffer 地址 | GPU buffer 地址 | 无 |
devPtr_offset |
GPU buffer 内偏移 | GPU buffer 内偏移 | 无 |
size |
传输字节数 | 传输字节数 | 字段相同,合法约束可能不同 |
cookie |
请求标识 | 请求标识 | 无 |
fh |
普通文件 fd 注册出的 handle | 块设备 fd 注册出的 handle | 对象不同 |
file_offset |
从文件开头计算的字节偏移 | 从设备或分区开头计算的字节偏移 | 坐标系不同 |
例如,两边都提交 file_offset = 1 MiB、size = 4 KiB:
普通文件 /mnt/cache.bin
文件开头 + 1 MiB
↓
文件系统把文件偏移映射到 extent 和 SSD 数据块
裸设备 /dev/nvme0n1
整块设备开头 + 1 MiB
↓
Linux 块层访问对应设备区域
字段仍叫 file_offset,但更贴切的理解是“当前 handle 所代表对象内的字节偏移”。cuFile 不需要在 cuFileBatchIOSubmit 时再接收一个 is_block_device 参数,因为 fh 是由前面的 fd 注册而来,对象类型已经确定。
Mooncake TENT 当前组装参数的方式也印证了这一点:
params.mode = CUFILE_BATCH;
params.opcode =
request.opcode == Request::READ ? CUFILE_READ : CUFILE_WRITE;
params.u.batch.devPtr_base = request.source;
params.u.batch.devPtr_offset = offset;
params.u.batch.file_offset = request.target_offset + offset;
params.u.batch.size = length;
params.fh = context->getHandle();
cuFileBatchIOSubmit(batch_handle, num_params, params_array, 0);
见 gds_transport.cpp:459-484。如果未来增加 block://,这段提交代码可能几乎不用改:fh 换成块设备 fd 注册的 handle,target_offset 改为设备地址空间中的偏移即可。
真正不能省略的改动仍在提交之前。普通文件用 st_size 建立边界;块设备要查询容量和对齐,还要确保它没有被文件系统或其他写者同时使用。统一接口消除了数据搬运代码的分叉,却不会自动消除存储对象的语义差异。
真正接入块设备需要什么¶
不能只把 !S_ISREG 删除。Linux 把普通读写之外的设备控制操作统一放在 ioctl 一类接口中,可以把它理解成“拿着已经打开的 fd,向对应驱动询问或下达一条设备命令”。这次涉及的几个名字分别回答不同问题:
| 接口或检查 | 回答的问题 | Mooncake 为什么需要 |
|---|---|---|
BLKGETSIZE64 |
这个块设备总共有多少字节? | 建立 Segment 容量,不能沿用普通文件的 st_size |
BLKSSZGET |
这个设备的逻辑块大小是多少? | 建立 offset 与 length 的基本对齐要求 |
STATX_DIOALIGN |
direct I/O 的内存地址和文件偏移需要怎样对齐? | 避免把不合法请求提交后才收到 EINVAL |
offset + length <= capacity |
本次读写是否落在设备范围内? | 防止越界;加法本身也要防整数溢出 |
| 挂载与占用检查 | 是否有文件系统或其他写者正在使用这个设备? | 避免两套写入方同时修改同一批底层数据 |
有了这些基础信息,一个可维护的实现至少还需要:
- 独立命名:增加
block://或显式SegmentType::BlockDevice,避免把普通文件语义与破坏性更高的裸盘语义混在一起。 - 类型白名单:只接受
S_ISBLK,拒绝任意字符设备、目录和其他特殊文件。 - 容量发现:使用
BLKGETSIZE64取得设备总字节数,而不是沿用st_size;把容量写入 Segment 并在每次请求前验证offset + length。 - 对齐建模:使用
BLKSSZGET等接口发现逻辑块大小与 direct-I/O 约束,校验文件偏移、长度和本地 buffer;不能等到异步完成时才返回模糊的EINVAL。 - 独占与权限:确认设备没有被挂载或被其他写者使用,考虑独占打开策略,并明确容器设备映射与最小权限。
- cuFile 能力探测:区分 handle 注册成功、compatibility path 与真实 GDS direct path,暴露可诊断的错误和指标。
- 测试矩阵:覆盖整盘、分区、NVMe-oF namespace、只读、越界、非对齐、并发、取消、重启和故障恢复;写测试只能在专用空设备上运行。
最小修改位置
如果目标只是做实验,最小代码切口在 SegmentManager::makeFileRemote:为 S_ISBLK 增加独立分支,查询容量并构造 descriptor。若目标是生产支持,则应新增明确的块设备 Segment 语义,而不是让 file:// 同时代表普通文件和裸设备。
最终判断¶
Classic TE 与 TENT 默认、文档化的路径都是文件系统上的普通文件。 两者的数据面都最终把一个 fd 交给 cuFile,但上层约束不同:
- Classic 没有检查
S_ISREG,所以人工补齐元数据后存在直接传入块设备节点的技术可能;官方脚本、容量处理和测试没有把它做成完整能力。 - TENT 明确要求
S_ISREG,所以当前file://、GDS 与 io_uring 的正常调用链都不能打开/dev/nvme*。 - NVIDIA cuFile 本身允许 device file/raw device file,但这只是底层必要条件,不代表 Mooncake TENT 已经支持。
回到最开始的困惑:cuFileBatchIOSubmit 在两个场景中确实可以长得一模一样,差异藏在 fh 指向的对象和 file_offset 所使用的坐标系中。如果问题是“GDS 能不能搬”,答案是底层能力已经存在;如果问题是“Mooncake 能不能安全地把裸盘当作 Segment 使用”,当前答案仍然是否定的。下一步不是重写 Batch I/O,而是给块设备建立明确的 block:// 语义,并补齐提交前的容量、对齐、越界与独占检查。
参考文献¶
- Mooncake Classic
CuFileContext - Mooncake Classic NVMeoF registration script
- Mooncake TENT
SegmentManager - Mooncake TENT
GdsTransport - Mooncake TENT
IOUringTransport - NVIDIA cuFile API Reference
- NVIDIA GPUDirect Storage Overview
- NVIDIA GPUDirect Storage Troubleshooting Guide
- NVIDIA
gds-nvidia-fsraw block handling - Linux block-device ioctl definitions