跳转至

Mooncake File vs Block Device

导言

我最开始想确认的是一个很具体的问题:Mooncake Classic TE 和 TENT 打开的究竟都是文件系统普通文件,还是也能把 /dev/nvme0n1 这样的 SSD 裸块设备交给 GDS?源码里两边都会调用 open(path, O_DIRECT),NVIDIA cuFile 又确实接受 device fd,看起来答案应该是“可以”。继续向上追到 Segment 层后,结论却发生了分叉。

这篇文章把这条调用链从头串起来:先解释普通文件、块设备、S_ISREGS_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_ISREGS_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 的公开使用方式明显围绕普通文件设计:

  1. 官方设计文档写的是先把远端存储挂载到本机,再传入文件路径,示例也是 /mnt/.../nvme0,见 transfer-engine/index.md:29-30221-245
  2. register.py 使用 os.path.getsize(file) 生成 buffer.length,随后把同一路径写入 file_pathlocal_path_map,见 register.py:41-49。脚本没有使用块设备容量查询接口。
  3. NVMeoFBufferDesc.length 完全来自元数据,传输时靠它把逻辑 Segment 切成文件区间;底层 transport 不会为块设备重新发现容量,见 transfer_metadata.cpp:979-989

所以 Classic 应分成两句话描述:

  • 官方和开箱即用路径:打开已挂载文件系统中的普通文件;
  • 核心数据面潜力:如果人工构造正确的 Segment 长度与 /dev/nvme* 路径,并且当前 cuFile 环境接受该设备文件,现有代码没有 S_ISREG 阻止它;但这属于未完整建模、未见专门测试覆盖的路径。

Classic 的绕过路径不是安全承诺

对整盘或分区设备执行 WRITE 会直接覆盖 LBA,可能破坏分区表、文件系统或已有数据。没有独占、容量和边界保护的情况下,不应仅因为 opencuFileHandleRegister 成功就把它用于生产。

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。因此:

  1. openSegment("file:///dev/nvme0n1") 创建 handle 时可能暂时不报错,因为 openRemote 先只登记名字;
  2. 第一次解析或提交该 Segment 时会进入 makeFileRemote
  3. 块设备满足 S_ISBLK 而不是 S_ISREG,所以返回 InvalidArgument
  4. 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::FileFileSegmentDesc,见 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 的实现进一步排除了“文档只是泛指设备文件”的疑问:

这意味着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 没有单独的 cuFileOpenBlockDevicecuFileReadBlock。它采用的是一套基于文件描述符的统一接口:应用先用 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);

其中,BLKGETSIZE64BLKSSZGETSTATX_DIOALIGN 都属于 Linux 的设备发现与 direct-I/O 约束接口,不是 NVIDIA GDS API。它们负责回答“设备多大、怎样对齐”;cuFile 负责回答“怎样在 GPU buffer 与已经打开的 fd 之间搬数据”。

功能层 主要 API Classic NVMeoFTransport TENT GdsTransport
驱动会话 cuFileDriverOpencuFileDriverClose 调用 Open,未见 Close 调用 Open,未见 Close
fd 注册 cuFileHandleRegistercuFileHandleDeregister 已使用 已使用
GPU buffer 注册 cuFileBufRegistercuFileBufDeregister 已使用 已使用
同步 I/O cuFileReadcuFileWrite 未使用 未使用
批量 I/O cuFileBatchIOSetUpSubmitGetStatusCancelDestroy 已使用 已使用
CUDA Stream I/O cuFileStreamRegistercuFileReadAsynccuFileWriteAsynccuFileStreamDeregister 未使用 未使用
能力与配置 cuFileDriverGetProperties、P2P flag 等接口 未见使用 未见使用
块设备容量与对齐 BLKGETSIZE64BLKSSZGETSTATX_DIOALIGN 未使用 未使用

Mooncake 实际选择的是 cuFile Batch API:先创建 batch handle,提交多条 I/O,再轮询完成状态,必要时取消,最后销毁。Classic 的调用分别位于 nvmeof_transport.cppcufile_context.hcufile_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_READCUFILE_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 MiBsize = 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 本次读写是否落在设备范围内? 防止越界;加法本身也要防整数溢出
挂载与占用检查 是否有文件系统或其他写者正在使用这个设备? 避免两套写入方同时修改同一批底层数据

有了这些基础信息,一个可维护的实现至少还需要:

  1. 独立命名:增加 block:// 或显式 SegmentType::BlockDevice,避免把普通文件语义与破坏性更高的裸盘语义混在一起。
  2. 类型白名单:只接受 S_ISBLK,拒绝任意字符设备、目录和其他特殊文件。
  3. 容量发现:使用 BLKGETSIZE64 取得设备总字节数,而不是沿用 st_size;把容量写入 Segment 并在每次请求前验证 offset + length
  4. 对齐建模:使用 BLKSSZGET 等接口发现逻辑块大小与 direct-I/O 约束,校验文件偏移、长度和本地 buffer;不能等到异步完成时才返回模糊的 EINVAL
  5. 独占与权限:确认设备没有被挂载或被其他写者使用,考虑独占打开策略,并明确容器设备映射与最小权限。
  6. cuFile 能力探测:区分 handle 注册成功、compatibility path 与真实 GDS direct path,暴露可诊断的错误和指标。
  7. 测试矩阵:覆盖整盘、分区、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:// 语义,并补齐提交前的容量、对齐、越界与独占检查。

参考文献

评论