跳转至

Mooncake Classic vs TENT Engine

导言

看到“应用必须持有 Transport*”时,我真正卡住的是:持有到底是什么意思?为什么直接保存一个后端指针就能工作,统一的 TransferEngine 反而走不通?

把这个词拆开后,Classic 与 TENT 的差异就不再只是两套接口。Classic 的公共 Batch 先于后端选择而创建,后端私有状态缺少稳定的安放位置;TENT 则在选定后端后,为每个 transport 创建独立 SubBatch,再由统一运行时协调提交、状态与回收。

因此,判断一个 Engine 是否容易扩展多个后端,不能只数有多少个 Transport 子类。真正要问的是:后端的私有状态放在哪里,由谁创建,又由谁保证它完成整个异步生命周期。

本文承接 Mooncake Classic NVMeoF TransportMooncake TENT GDS。前者展示旧后端怎样直接调用 cuFile,后者展示 TENT 怎样管理统一运行时;这里不再重复两篇文章的接口细节,只比较它们的对象边界。

Classic 不是 C++ class

本文的 Classic 指 Mooncake 经典 Transfer Engine 路径,不是 C++ 的 class 关键字。问题也不是“面向对象继承一定难扩展”,而是这套公共 Batch 与后端私有生命周期之间存在错位。

“持有”究竟是什么

这里的“持有 Transport*”不是说应用拥有或负责销毁 Transport 对象,而是说:应用保存 installTransport 返回的后端指针,并绕过统一 Engine,直接在这个对象上完成 Batch 的分配、提交、查询与释放。

Transport* xport = engine.installTransport("nvmeof", args);

auto batch_id = xport->allocateBatchID(1);
xport->submitTransfer(batch_id, requests);
xport->getTransferStatus(batch_id, 0, status);
xport->freeBatchID(batch_id);

“保存指针”本身不是关键。关键是四个操作始终落在同一个 NVMeoFTransport 对象上:

  1. 分配时创建 NVMeoF 私有 descriptor;
  2. 提交时向 descriptor 填入 cuFile 参数;
  3. 查询时用 descriptor 中的 slice 映射聚合事件;
  4. 释放时回收 descriptor、BatchHandle 与私有上下文。

因此,更准确的说法是:这个 Batch 从创建到回收都由 NVMeoF 后端直接管理。 “持有后端指针”只是调用者进入这条专用生命周期的手段。

Classic 为什么直接调用能跑

可以先把 Transfer Engine 想成物流平台,把不同 transport 想成不同承运商。

公共 BatchDesc 是平台统一的订单,记录容量和 task 列表。NVMeoF 后端还需要一只专用文件袋,保存 cuFile descriptor、task 到 slice 的映射以及异步完成事件。代码里的这只“文件袋”就是 NVMeoFBatchDesc

BatchDesc                         公共订单
└── context → NVMeoFBatchDesc     NVMeoF 专用文件袋
                └── desc_idx_
                    └── CUFileBatchDesc

直接调用 xport->allocateBatchID 时,执行的是 NVMeoFTransport::allocateBatchID。它先创建公共 Batch,再把私有对象放进 context

auto* nvme_batch = new NVMeoFBatchDesc();
auto batch_id = Transport::allocateBatchID(batch_size);

auto& batch = *reinterpret_cast<BatchDesc*>(batch_id);
nvme_batch->desc_idx_ = desc_pool_->allocCUfileDesc(batch_size);
batch.context = nvme_batch;

后面的接口都能沿 batch.context 找回同一个 NVMeoFBatchDesc。cuFile handle、切片区间、事件缓存和资源回收因此连成了闭环。

这条专用路径能够工作,但代价也很直接:调用者必须知道自己正在使用 NVMeoF,并牢记这个 Batch 只能继续交给同一个后端。

统一 Engine 为什么反而断了

如果 Batch 由 engine->allocateBatchID 创建,执行者变成 MultiTransport。此时运行时还没有为 NVMeoF 建立私有上下文:

engine->allocateBatchID
    → MultiTransport 创建公共 BatchDesc
    → BatchDesc.context = nullptr

等到 engine->submitTransfer 解析请求后,它确实可以根据 Segment protocol 选中 NVMeoFTransport。但 transport 选择解决的只是“任务应该交给谁”,没有补上“这个后端的批次状态放在哪里”。

engine->submitTransfer
    → MultiTransport 选中 NVMeoFTransport
    → 调用 NVMeoFTransport::submitTransferTask
    → NotImplemented

旧实现只在专用的 NVMeoFTransport::allocateBatchID 中创建 descriptor;通用路径不会调用这个函数,而 submitTransferTask 又没有实现等价的私有批次分配、状态映射和回收逻辑,所以只能返回 NotImplemented

选中后端不等于接入运行时

installTransport("nvmeof") 成功、selector 能找到 NVMeoF、专用测试能够读写,这三件事都不能证明通用 Engine 路径已经接通。真正的集成必须同时覆盖分配、提交、状态推进、失败清理和释放。

Classic 难扩展在哪里

Classic 并不是不能装载多个 transport。困难出现在新后端需要自己的批次级私有状态时:公共 Engine 与后端必须临时商量“这份状态由谁创建、挂在哪里、什么时候可以释放”,但 Classic 没有把这套协商变成统一契约。

Batch 创建得太早

公共 Batch 在 transport 选择前已经存在。后端真正收到 task 时,Batch 的对象形态和所有权已经确定。

对只需逐 task 提交、几乎没有批次私有状态的后端,这未必构成问题。但 NVMeoF、GDS 这类异步批量后端还要维护:

  • 底层 BatchHandle;
  • 一个 task 展开后的多个物理 slice;
  • slice completion 到公共 task 的映射;
  • 取消后仍被设备引用的参数;
  • 只有物理终态后才能回收的资源。

这些对象不能在提交函数的栈上临时创建,也不能在 API 返回错误后立即删除。它们需要一个与异步 Batch 同寿命的私有容器。

单个 context 难表达多个后端

Classic 的公共 BatchDesc 只有一个泛型 context。如果整个 Batch 永远只交给一个后端,这个槽位尚可保存私有对象;一旦公共 Batch 中的 task 需要按 transport 分组,问题就出现了:

一个公共 Batch
├── task 0、1 → RDMA 私有状态
├── task 2    → NVMeoF 私有状态
└── task 3    → 其他后端私有状态

一个 context 无法自然表达多组彼此独立的 handle、队列和完成事件。理论上可以再增加 map、外部 side table 或新的特殊分支,但每接一个有状态后端,公共层就更了解一种后端细节,扩展成本会继续向中心聚集。

后端知识泄漏给调用者

专用 xport 路径把本该由运行时处理的问题交给了业务代码:

  • 应用要提前知道应该选择 nvmeof
  • 应用要保存后端指针;
  • Batch 必须由这个后端分配,也必须由它释放;
  • 误用 engine->freeBatchID 可能绕开私有资源回收;
  • 更换 transport 会改变业务代码的调用路径。

这使后端从“可替换的执行器”变成了“业务必须显式理解的对象”。后端数量增加后,统一 Engine 名义上仍在,真正的调度边界却散落到了调用者手中。

特殊路径会逐渐增多

如果每个新后端都通过专用分配接口解决自己的状态问题,系统最终容易形成多套彼此相似但不能互换的调用链:

RDMA     → 一套 Batch 前提与回收规则
NVMeoF   → 一套专用 xport 调用顺序
Backend C→ 又一套 context 和错误约定

这并不表示 Classic 无法继续增加代码,而是说每增加一个后端,都可能同时增加一套调用约定和生命周期例外。类数量在增长,统一抽象却没有同步变强。

TENT 改变了什么

TENT 没有要求所有后端把私有状态塞进同一个公共 Batch,也没有让应用直接保存 GdsTransport*。它在公共 Batch 与具体 transport 之间增加了明确的 SubBatch 层:

应用提交公共 Request
    → TENT 解析 Segment 与本地内存
    → selector 选择 transport
    → 按 transport 给 task 分组
    → transport->allocateSubBatch(...)
    → transport->submitTransferTasks(...)
    → transport->getTransferStatus(...)
    → transport->freeSubBatch(...)

对象关系也随之改变:

TENT 公共 Batch
├── 公共 TaskInfo
├── GDS SubBatch
│   ├── CUfile BatchHandle
│   ├── IOParamRange
│   └── completion cache
└── IOUring SubBatch
    └── IOUring 私有队列与状态

每个 transport 可以定义自己的 SubBatch 类型,公共运行时只负责在正确时间调用统一生命周期接口。这样一来:

  1. 选择与分配顺序正确:先知道使用哪个后端,再让该后端创建私有状态;
  2. 私有状态彼此隔离:GDS 与 IOUring 不争用一个 context
  3. 调用者保持统一:应用只面对 Engine,不直接保存某个 transport 指针;
  4. 生命周期成为契约:分配、提交、查询与释放不再是后端的隐藏约定;
  5. 一个公共 Batch 可以容纳多个执行后端:每组 task 进入自己的 SubBatch,状态再聚合回公共 task。

TENT 的关键变化不是把 Transport 类改了一个名字,而是把“后端私有批次”提升成运行时的一等对象。

两套 Engine 放在一起看

维度 Classic Engine TENT Engine
公共 Batch 创建时机 通常先创建,再在提交时选择 transport 先准备请求和选择 transport,再创建私有 SubBatch
后端私有状态 依赖公共 context、外部映射或特殊路径 每个 transport 拥有独立 SubBatch
应用是否知道后端 专用路径需要保存 Transport* 应用只调用统一 Engine
多后端分组 后端私有批次缺少统一落点 公共 Batch 下可挂多个 transport SubBatch
状态聚合 各后端自行适配公共 task,容易形成例外 runtime 根据 SubBatch 与 task 映射统一推进
资源回收 调用者可能必须找回原后端 runtime 协调 freeSubBatch
新后端接入重点 容易先实现数据搬运,再补生命周期 接口直接要求完整异步生命周期
典型失败 selector 选中后端,但提交接口 NotImplemented transport 未实现契约时不会形成完整可用路径

Classic 的优势是直接:后端掌握自己的对象,专用测试可以很快打通底层 I/O。TENT 付出的代价则是运行时更复杂,需要维护 selector、公共 task、SubBatch 和状态映射。

因此,这不是“旧架构一无是处、新架构天然正确”的比较。二者优化的阶段不同:Classic 更容易先证明某个 transport 能搬数据;TENT 更重视多个 transport 怎样长期共存、替换和回收。

新后端应先回答什么

接入一个新 NDS transport 时,可以先不看具体 SDK,而是检查下面五个问题:

  1. 谁选择后端:业务代码硬编码,还是 runtime 根据 Segment、内存与 capability 选择?
  2. 私有状态放在哪里:公共 Batch 的临时槽位,还是后端自己的稳定 SubBatch?
  3. 谁管理生命周期:调用者记住专用顺序,还是 Engine 统一调用分配、提交、查询与释放?
  4. 怎样与其他后端共存:一个 Batch 是否能够按 transport 分组,并保留多套私有状态?
  5. 失败后何时安全释放:API 返回错误时,设备是否仍可能引用参数与用户 buffer?

如果前三个问题的答案仍是“让业务代码拿着某个 backend 指针自行处理”,那么新增的只是一个能单独运行的后端,还没有成为统一运行时中的可替换能力。

最终判断

回到开头,“持有 Transport*”真正暴露的不是一个 C++ 指针问题,而是一条架构边界:Classic 的 NVMeoF 私有生命周期只存在于后端专用调用链中,没有成为公共 Engine 能够创建和管理的对象。

这解释了为什么专用测试能够成功,MultiTransport 也能选中 nvmeof,但通用提交仍然返回 NotImplemented。选择后端与管理后端生命周期,是两件不同的事。

TENT 用 SubBatch 把这两件事重新接了起来。现在可以确认的是,这种对象边界更适合多个有状态异步后端共存;但它并不消除后端实现本身的复杂度,每个 transport 仍必须正确处理切片、完成事件、取消和安全回收。

因此,新后端接入时最值得复制的不是某个 submit 函数,而是这条完整关系:公共请求由 runtime 选择和分组,私有状态由 transport 创建,最终状态再由 runtime 聚合和回收。

参考文献

评论