ARTICLE · 1063167
Tutti 源码阅读(一):StorageRuntime 如何组织 KV Cache I/O
Tutti 的论文强调 GPU 主导 I/O,但应用首先接触的不是 NVMe doorbell,而是一套稳定的 StorageRuntime API。它负责把 URI、文件 extent、GPU 内存、后端实例和异步请求组装起来,让上层无需知道底层究竟是单盘、条带化 NVMe,还是未来的其他传输路径。
本文阅读 Tutti v0.1.1 的运行时源码与设计文档,聚焦五个问题:公开 API 长什么样、open 如何解析对象、register_memory 为什么独立存在、submit/wait 如何路由,以及 vLLM connector 如何接入。
项目源码:xPU-IO/Tutti。
一、先看整体分层
当前代码把系统拆成以下层次:
Application / vLLM Connector │ ▼ StorageRuntime open · register · submit · wait │ ┌──────┴──────┐ ▼ ▼ Resolver DataPath URI → target IO → backend │ │ └──── Binding ┘ │ local / striped NVMeStorageRuntime 是唯一稳定的应用接口;Resolver、Binding 和 DataPath 是内部 SPI。这样做避免上层依赖特定 NVMe 驱动或 GPU 厂商实现。
代码中的核心概念如下:
StorageRuntime | |
StorageTargetResolver | |
DataPath | |
这是一套“控制面接口稳定、数据面实现可替换”的设计。
二、配置不是参数列表,而是组件图
Tutti 当前把配置明确分成两类:
应用配置表达的是一张依赖图,而不是把 /dev/nvme0n1 一类事实硬编码进业务:
storage: resources: - id: kv_store resolver: striped_file datapath: striped_local_nvme运行时启动后向 daemon 请求资源,真实设备路径、BAR 大小、namespace 和队列授予以 daemon 响应为准。这样可以避免两个常见错误:
源码在 2026 年 8 月移除了旧式兼容字段,选择对混合配置 fail closed。这种做法牺牲平滑升级,但能防止存储映射被静默误解。
三、open:从 URI 到不可伪造的 TargetHandle
应用通常以 URI、偏移和长度描述持久化对象。open 的工作不是像 POSIX open 一样只返回文件描述符,而是建立一条经过验证的后端路由。
逻辑流程为:
open(uri, range) │ ▼Resolver 解析文件 extent / stripe │ ▼生成 pair-private payload │ ▼Binding 校验 type-id + version │ ▼DataPath 接受并创建 target │ ▼返回 opaque TargetHandle为什么需要 Binding?Resolver 与 DataPath 之间通常要传递比公共接口更具体的信息,例如物理 LBA、namespace、条带成员和 extent。若所有实现共享一个不断膨胀的公共结构,新增后端会污染整个 API。
Tutti 让配对组件使用私有 payload,但要求携带类型标识和版本。Binding 在装配阶段检查两端契约,不匹配就拒绝启动。这兼顾了扩展性与类型安全。
open_batch 则是 KV Cache 场景的重要优化。长上下文会打开大量对象,逐个解析会产生重复锁、分配与路径遍历;批量接口让 Resolver 一次处理整个对象集合,并为后续 GPU I/O 建立足够大的批次。
四、register_memory:把昂贵工作移出快路径
NVMe 控制器不能直接理解 CUDA 虚拟地址。GPU buffer 在参与 DMA 前,需要被固定并转换为设备可访问的地址;长 buffer 还要生成 PRP 映射。
因此 Tutti 把内存注册设计为独立生命周期:
cudaMalloc / framework KV allocation │ ▼register_memory(buffer, size) │ pin GPU pages + DMA map │ cache PRP / registration │ ▼ MemoryHandle一次注册可以服务多次 I/O。submit 只携带句柄、偏移和长度,不在每次请求中重新 pin 页和建立映射。
该设计对性能和安全都重要:
五、submit:按句柄身份路由,而不是让调用者选后端
一个运行时可以同时装配多个 DataPath。应用提交 I/O 时,不应该再次提供字符串后端名,否则 target、memory 和 backend 很容易被错误组合。
Tutti 采用 opaque identity:句柄由运行时创建,内部携带所有者和路由身份。提交过程可概括为:
submit(requests) │ ├─ 校验 target handle ├─ 校验 memory handle ├─ 检查范围与操作类型 ├─ 按 DataPath 分组 └─ 交给对应 backend 批量执行这种“由对象身份决定路由”的方式比调用者传 backend="nvme0" 更可靠。它也使单盘与条带化后端拥有相同的公共调用形式。
请求是异步的。submit 返回 operation handle,wait 再观察完成状态。公开接口隐藏具体完成机制:某个 DataPath 可以由 GPU 轮询 NVMe CQ,另一个未来实现也可以使用 CPU 或网络完成队列。
六、失败语义:批量执行也要逐请求可见
批量 I/O 最危险的实现方式是只返回一个总成功值。只要一个 extent 越界、设备错误或映射失效,调用方就无法知道哪些 KV block 可用。
Tutti 的运行时强调 per-request status 和 fail-closed:
对 KV Cache 来说,错误数据通常比 miss 更危险。miss 还能回退到 prefill,错误 KV 则可能造成不可见的推理结果污染。因此 fail closed 是正确取舍。
七、条带化如何藏在统一 API 后面
striped_local_nvme 可以把一个逻辑对象按 tensor-sized stripe 分布到最多四块 NVMe。上层仍提交连续的逻辑区间:
logical object[ stripe 0 ][ stripe 1 ][ stripe 2 ][ stripe 3 ] │ │ │ │ NVMe 0 NVMe 1 NVMe 2 NVMe 3Resolver 负责把文件或逻辑范围解析成各成员设备的 extent;DataPath 在一个 fused GPU kernel 中完成 fan-out。应用不需要手动拆成四组请求,也不需要分别等待四个设备。
这是运行时抽象的实际价值:条带策略进入后端,KV 管理层仍只看对象和逻辑 offset。
八、vLLM Connector 位于哪里
仓库中的 vLLM 集成大致分成三层:
vLLM KV Connector adapter │ Python engine chunk index · backend · lifecycle │ Python/C++ binding │ StorageRuntimeConnector 处理的是推理框架语义:哪些 token block 可保存、哪些 block 命中、何时触发 load/save。Engine 将 KV block 编排成 Tutti 可提交的对象与内存范围,C++ binding 再调用运行时。
GPU KV layout 与持久化 layout 往往并不一致,因此集成中还包含 CUDA kernels,用于显存中的 gather/scatter 和格式转换。Tutti 的 NVMe runtime 负责搬运,但“哪些字节构成某个 vLLM block”仍由 connector 层解释。
这条边界很重要:
九、源码设计的优点与代价
运行时层最值得借鉴的设计有三点:
open、register、submit、wait 覆盖完整生命周期;代价也很明显:
十、总结
Tutti 的 StorageRuntime 并不直接等于论文中的 GPU I/O kernel。它承担的是更基础的职责:把不稳定的设备、文件布局和后端实现,封装成可验证、可批量、可异步的对象生命周期。
一次请求的主线是:
URI → Resolver → Binding → TargetHandleGPU Buffer → register → MemoryHandleTarget + Memory → submit → DataPath → OperationHandle → wait理解这条链后,底层 GPU 如何真正写 SQE、敲 doorbell 和等待 CQE 就有了清晰入口。下一篇将继续下潜到 snvme、libnvm、PRP 映射和多盘 fused kernel。