跳转至

Tutti SSD-Backed KV Cache

导言

把 KV Cache 放进 SSD 并不难,难的是让它取回来仍比重新 Prefill 更划算。GPU Direct Storage 已经能让数据绕过 Host DRAM,但每个 I/O 仍由 CPU 发起;Paged KV Cache 又会把一次长前缀恢复拆成海量小而散的请求。SSD 的标称带宽于是还没有用满,GPU 已经先停下来等数据。

Tutti 的核心不是再加一级缓存,而是重写 HBM 与 NVMe 之间的运行时路径:CPU 保留前缀索引和对象映射,却退出逐块 I/O 的数据与控制关键路径;GPU 通过对象化批处理、gio_uring 和 slack-aware 调度直接驱动本地 SSD。读完本文,应该能判断这三部分分别消除了什么瓶颈,以及论文的“接近 DRAM”结论在哪些条件下成立。

一句话结论:Tutti 让 SSD-backed KV Cache 变得实用,靠的不是把 SSD 伪装成慢内存,而是让存储 I/O 成为 GPU 执行计划的一部分。

SSD 慢在带宽之外

长上下文 Prefix Cache 有一个看似简单的收益公式。若某段前缀已经计算过,只要恢复已有 KV 的时间加上剩余后缀的 Prefill 时间,小于从头计算完整 Prompt 的时间,复用就值得:

\[ T_{lookup}+T_{restore}+T_{suffix}<T_{\mathrm{full\ prefill}} \]

问题在 \(T_{restore}\)。vLLM、SGLang 一类引擎用 Paged KV Cache 把变长序列拆成不连续小块,便于显存分配、共享与回收;到了 SSD,这种显存布局会直接变成 I/O 碎片。

Tutti 论文 §2.2 给了一个很有冲击力的例子。对 64 层 Qwen3-32B,若每个 Block 保存 64 个 Token,那么恢复 128K Token 的 K/V 需要处理:

\[ N_{object}=2\times L\times \frac{N}{B} =2\times64\times\frac{128\times1024}{64} =262{,}144 \]

这里 2 是 Key 与 Value,\(L\) 是层数,\(N\) 是命中 Token 数,\(B\) 是每个 Block 的 Token 数。论文给出的单对象大小约为 80 KB。一次逻辑上连续的前缀恢复,物理上变成约 25.6 万个散落对象。

传统 HBM—DRAM—SSD 路径还叠加了三类开销:

  • 数据路径开销:普通 SSD 路径先把数据读到 Host DRAM,再复制到 HBM。
  • 控制路径开销:CPU 为大量小 I/O 准备描述符、提交请求、处理完成并同步 GPU。
  • 流水碎片开销:按层搬运本可与计算重叠,却会进一步缩小单次 I/O 粒度、增加提交次数。

GDS 只解决了第一项。NVIDIA 官方说明明确区分了直接 DMA 数据路径与运行在 CPU 上的驱动控制路径:NVMe 可以直接读写 GPU Memory,但 cuFile 调用和文件系统、驱动仍由 Host 发起。因此 Tutti 论文观察到,LMCache-GDS 虽然绕过 bounce buffer,GPU bubble 仍超过总推理时延的 70%。

命中不一定比重算快

命中率越高,需要重算的后缀越短,可用于隐藏 I/O 的计算窗口反而越小。SSD 恢复速度若跟不上,缓存越“成功”,执行越容易从 compute-bound 变成 I/O-bound。这也是 Tutti 不能只追求容量或命中率的原因。

Tutti 换掉了什么

先看论文最重要的前后对比。左侧 LMCache 的普通路径需要 DRAM 中转;GDS 去掉数据中转后,CPU 仍逐次 initiate I/O。右侧 Tutti 中,CPU 只按层准备和加载 I/O Kernel,GPU 批量发起对象读写,SSD 与 HBM 直接交换数据。

LMCache 与 Tutti 的数据路径和控制路径对比

来自 Tutti 论文 Figure 1。它展示 CPU-centric LMCache、LMCache-GDS 与 GPU-centric Tutti 的关键路径差异;图中的控制面简化不代表 Tutti 删除了全部 CPU 元数据工作。

我更愿意把 Tutti 称为 CPU-prepared、GPU-executed 的 KV 对象存储。这个说法比“GPU 完全接管存储”更准确:

  • CPU 仍然保留 Prefix Hash、逻辑 Block 到 GPU File 的映射、分配与全局共享等不适合 SIMT 的元数据工作。
  • GPU 接管的是运行时关键路径:根据预先准备的 I/O Context 并行生成 NVMe 命令、提交请求、观察完成,并把数据直接搬到目标 HBM Block。
  • CPU 开销从逐层逐块变成逐层:论文将关键路径复杂度从 \(O(L\times B)\) 降到 \(O(L)\),其中 \(B\) 是本次命中的 KV Block 数。

这三项设计并不是同义重复:对象存储解决“读什么、放哪里”,gio_uring 解决“谁来发起和收割完成”,slack-aware Scheduler 解决“什么时候发,才不会抢走推理资源”。

从散页到 GPU KV 对象

直接把传统文件系统或 GPU Block I/O 搬进 KV Cache 并不够。LLM 引擎需要按 Prefix Block 分配和共享,却希望按 Layer 批量恢复;SSD 又需要足够大的并发请求才能吃满带宽。Tutti 在 GeminiFS 之上增加 GPU-centric KV Object Store,把这几个粒度接起来。

GPU File Pool

运行时为每个逻辑 KV Block 关联一个 GPU File。一个 GPU File 内按 Layer 保存 Key Object 与 Value Object,因此共有 \(2L\) 个对象;底层 NVMe File Pool 则预先在各块 SSD 上分配物理 Extent。Tensor-Stripe Layout 保留原始 Tensor 粒度,并用轮询方式把对象分散到多块本地 NVMe,避免运行时创建文件和重新分配 Extent。

这里有一个容易混淆的边界:Block 粒度没有消失。 Prefix 仍可按 Block 命中、共享和淘汰;变化发生在传输接口上,retrieve_layerstore_layer 会把同一 Layer 的多个 Block 组织成一批 GPU I/O Context,再由 GPU 并行发出。

P2P 映射与 SGL

NVMe Controller 不能直接理解 CUDA Virtual Address,需要 PCIe 可见的物理地址描述符。vLLM 会在启动时预分配相对稳定的 KV Memory Pool,Tutti 因而可以提前构建 P2P Memory Mapping Table,运行时不再逐请求做地址翻译。

论文没有沿用固定 4 KB 页的 PRP List,而是选择 NVMe Scatter Gather List(SGL)。在作者给出的 60 GB KV Pool 配置中:

描述方式 表达粒度 论文估算的 HBM 元数据 作用
PRP List 4 KB Physical Page 约 3.75 GB 通用,但中等大小 KV Object 需要大量 List Page
SGL 一个 16 B Descriptor 描述连续物理区间 约 15 MB 更贴合约 100 KB 的 KV I/O,并减少命令描述开销

这两个数字依赖论文的 60 GB Pool、4 KB Page 与 64 KB 分配假设,不是所有部署的固定比例。对应的单线程 500 MB Microbenchmark 中,SGL 的读写带宽分别为 8.891 GB/s 和 2.922 GB/s,PRP 为 0.287 GB/s 和 0.032 GB/s;这是描述符路径的隔离实验,不能直接当成端到端推理加速比

GPU io_uring 如何工作

有了对象和物理地址,GPU 还需要一种不阻塞计算的提交方式。Tutti 模仿 Linux io_uring 的 Submission/Completion 结构,实现了 GPU 侧的 gio_uring

  • SQ 与 CQ:一对 Lock-free Ring Buffer 驻留在 HBM,并通过 non-cached mmap 映射给 CPU。
  • IOCB:每个 Submission Entry 是一个 I/O Control Block,内含 2048 个 IOCTX,负责成批承载细粒度请求。
  • IOCTX:记录 SGL Address、GPU File Offset 与 Length,是 GPU 真正发给 NVMe 的轻量请求描述。
  • CUDA Event:约束目标 HBM Block、I/O Kernel 与 Attention Kernel 的依赖,防止异步执行读到未完成数据或提前覆盖仍在使用的 Block。

一次本地命中的教学化路径如下。它概括论文 §3.1—§3.2 的对象生命周期,不是公开源码摘录:

CPU Runtime
  1. 匹配可复用 Prefix,得到 logical_block_ids
  2. 查询 logical block → GPU file 的 CPU-side 映射
  3. 按 Layer 填充 IOCB,并提前 enqueue I/O Kernel

GPU I/O Control Domain
  4. 等待目标 HBM Block 可写的 CUDA Event
  5. 从 SQ 取得 IOCB;每个 GPU Thread 处理一个 IOCTX
  6. 依据 SGL 向 NVMe SQ 提交 SSD → HBM P2P DMA
  7. 观察完成后,把 IOCB ID 原子写入 CQ

GPU Compute Domain
  8. 当前 Layer 在 wait_cqe() 后读取已经恢复的 K/V
  9. 执行 Attention;下一 Layer 的恢复与本层计算重叠

仅使用不同 CUDA Stream 仍可能失效:长时间运行的 I/O Kernel 会占住 SM,而 GPU Kernel 调度通常不能像 CPU Thread 那样及时抢占。Tutti 用 NVIDIA Green Context 把 SM 划成 Compute Domain 与 I/O Control Domain,使 I/O Kernel 只能使用预留 SM。CUDA Programming Guide 也强调,Green Context 能限制工作使用指定 SM、减少相互干扰,但不能无条件保证并发;具体划分仍要按负载实验确定。

异步之后还要调度

把 I/O 放到 GPU 并不等于“越并行越快”。论文发现两种干扰:

  1. Read 与 Write 争用 SSD 内部资源。 在作者的 FIO 复现实验中,256 MB Read/Write 同时进行使总带宽下降 60%,而分开执行可以接近饱和。
  2. I/O 与模型 Kernel 争用 SM。 Embedding、Normalization、GEMM 等阶段可能占用接近 90% 的 GPU 资源;不受约束的 I/O Kernel 会抬高推理尾延迟。

Tutti 没有在请求到来时运行复杂优化器,而是离线 Profile 每层的空闲窗口,建立以 Input Length 与 Prefix Length 为键的 Lookup Table。表项记录可用窗口时长、SM Budget,以及不同 IOCB Count 的 I/O 执行特征。部署配置不变时,运行时只需查表。

调度优先级也很具体:

  1. Read 优先。 恢复历史 KV 位于 TTFT 关键路径;合适 Slack 存在时,发出能放入窗口的最大 IOCB Batch。
  2. 无窗口就立即读。 高命中导致剩余计算太少时,继续等只会让 Attention 更晚拿到 KV。
  3. Write 可以延后。 新生成 KV 的 store_layer 先留在 SQ;Prefill 没有安全窗口时推迟到 Decode,甚至后续请求的 Slack 中 Best-effort Flush。
  4. Read/Write 解耦。 不为了表面上的 Layer-wise Overlap 同时压满双向 I/O,避免 SSD 带宽塌陷。

这是一种很务实的工程取舍:用启动前 Profile 换运行时确定性。代价也很明确——模型版本、GPU/SSD 拓扑、Kernel 实现或资源分配改变后,原表不应被默认视为仍然有效。

KV 到底留在哪里

理解 Tutti 时,最好把元数据、KV 数据和执行状态分开。它们没有一起“迁移到 GPU”:

对象 主要位置 谁管理 生命周期与移动
Prefix Hash、Block Index CPU Memory vLLM / KVConnector 请求查询与共享管理;不随 KV Payload 写入 Attention Block
GPU File 与 NVMe File 映射 CPU Memory Tutti Runtime 启动预分配,运行时查询;CPU 保持 Engine-visible Mapping
P2P Mapping、SQ/CQ、IOCB GPU HBM Tutti gio_uring 映射表长期保留;IOCB 随 Layer Batch 循环使用
活跃 KV Block GPU HBM vLLM Block Manager Attention 前必须就绪;读取完成后由当前请求消费
本地历史 KV Object NVMe SSD Tutti / GeminiFS HBM Eviction 时写入,需要复用时 P2P DMA 回 HBM
集群 Replica Metadata CPU / Mooncake Control Plane Mooncake 记录空间与位置,不承载正常本地 KV Payload

单机本地路径是论文完成度最高的部分。多 GPU 时,每个 vLLM GPU Process 配一个 Tutti Instance 和独立 NVMe SQ/CQ Queue Pair,各自管理本 Rank 的 KV 分片;GPU 之间并不先聚合成一份完整 KV 再访问 SSD。

跨节点则要谨慎。论文 §3.4 用 Mooncake 负责全局空间、Replica Metadata 与 Location Lookup,但当前 Prototype 的 Remote Retrieval 仍通过 CPU Interface 把 GPU File 读到 Host Memory,再经 RDMA 发送。所以 Tutti 的强证据是 Local GPU↔Local NVMe Fast Path,不是端到端 GPU-driven Remote KV。

实验证明到哪里

截至 2026-08-26,本文使用的公开版本仍是 arXiv v1,下面讨论的是作者报告结果,不是独立复现。论文在一台 64 核 Intel Xeon 6530、512 GB Memory、两张 80 GB H100 与四块 7.68 TB Solidigm D7-PS1010 上评测;分层配置分配 256 GB Pinned DRAM,并为每张 GPU 提供 14 TB SSD Volume。端到端主实验使用单卡 Llama3-8B、LEval 与 LooGLE,并比较 vLLM 0.12.0/0.17.0 上的 HBM、LMCache-DRAM-LW、LMCache-SSD、LMCache-GDS 与 Tutti。请求按 Poisson Arrival 模拟,报告的是并发负载下的 Average TTFT/ITL

Tutti 在 LEval 和 LooGLE 上的端到端 TTFT 与 ITL

来自 Tutti 论文 Figure 8。上排为 vLLM 0.12.0,下排为 vLLM 0.17.0;违反 SLO 的数据点被省略,因此折线缺失不能解释为零时延。该图证明特定硬件、容量配置和合成到达过程下的端到端收益,不证明任意模型与远程 SSD 路径都有相同比例。

值得抓住的不是某一个最漂亮的百分比,而是三层证据彼此闭合:

  • 容量层:在论文配置中,HBM 在 LEval/LooGLE 的命中率为 8%/4%,DRAM 为 53%/24%,SSD 为 84%/86%。大容量确实减少了重算。
  • 数据路径层:双 SSD RAID-0 Microbenchmark 中,Tutti Retrieve 最高达到 25.9 GB/s,LMCache-GDS 约 11.9 GB/s;Tutti Store 在长序列约 9.8 GB/s。
  • 端到端层:新版 vLLM 的 LEval 高负载点上,Tutti 相比 DRAM 与 GDS 分别降低 69.1% 和 78.3% TTFT;在 1 秒 TTFT SLO 下,可承载 Request Rate 分别提高 50% 与 100%。

Slack-aware Pipeline 的边界同样重要。论文固定 32K Prompt、改变 Hit Rate 时,Tutti 的平均 Bubble 约 25 ms,在 93.75% Hit Rate 时为 6 ms,并把 compute-bound 到 I/O-bound 的 Crossover Point 推到 98.3%。但在固定 128K Input 的另一组实验中,当复用前缀超过 96K、工作负载接近纯 Retrieval 时,DRAM 重新领先,Tutti 最多落后 20.6%。

成本结论也不是 SSD 单价的同义反复。论文按 H100 5 美元/小时、DRAM 0.0088 美元/GB/小时、SSD 0.000082 美元/GB/小时计算单位 Token 成本;在 LooGLE 0.5 QPS 点,Tutti 相比 LMCache-GDS 降低约 27%。这个结果同时依赖更低存储价格与更高 GPU Utilization,换一组云价格、命中分布或写放大,比例都会变化。

什么时候值得采用

我的判断是,Tutti 最值得被看成一种有明确前提的 Local Storage Fast Path,而不是“DRAM 可以退出推理系统”的证明。部署前至少应过下面几道门:

  1. 先测复用价值。 用真实 Trace 比较 \(T_{lookup}+T_{restore}+T_{suffix}\)\(T_{\mathrm{full\ prefill}}\),同时看 TTFT、ITL 和 P99,而不只看 Cache Hit Rate。
  2. 确认 I/O 能直达。 检查 GPU—NVMe PCIe Root Complex、P2P DMA、企业 SSD 的 SGL 支持、队列数与实际 Random I/O;论文数字来自 H100 和高端 PCIe 5.0 NVMe。
  3. 控制 Profile 漂移。 Model Revision、CUDA/vLLM Kernel、TP 拓扑、SM Partition 或 SSD 组合变化后重新生成 Slack Table。
  4. 把 Remote Path 单独验收。 本地命中和远端命中分别统计 Bytes、Host Staging、RDMA 与 Tail Latency,不能用 Figure 8 替远端路径背书。
  5. 补齐持久化成本。 评估 SSD Endurance、Write Amplification、Crash Recovery、Replica Durability 和 Metadata Rebuild;arXiv v1 没有给出这些生产指标。

代码证据边界

论文报告 Tutti 约有 8,000 行 C++,另用约 1,500 行 Python 接入多个 vLLM 版本,并把它称为 Open-source Solution。但截至本文检索的 arXiv v1 HTML、TeX Source 与参考文献,未给出 Tutti Repository URL。因此本文可以核对论文描述和图表,不能把 retrieve_layerstore_layergio_uring 的接口说明当成已完成源码审计

回到最初的问题

SSD-backed KV Cache 过去不实用,并不只是 SSD 比 DRAM 慢。更致命的是:Paged KV 把长前缀变成大量细碎对象,CPU 又站在每个 I/O 的控制路径上;GPU 算得越快,这段老路径就越刺眼。

Tutti 的回答是把对象、地址、队列和计算窗口一起设计:CPU 做它擅长的索引与准备,GPU 做它擅长的大规模并发发起,Scheduler 决定哪些 I/O 真能藏进推理空隙。论文实验有力支持,本地 H100—NVMe 配置下,这条路可以让 SSD 接近 DRAM-backed LMCache 的端到端表现。

还没有被证明的,是同样收益能自然延伸到任意 GPU、消费级 SSD、大模型、多租户干扰和跨节点 Remote KV。对工程决策而言,这个边界不是给 Tutti 泼冷水,反而让它最有价值的启示更清楚:不要只把 KV Cache 当成要搬运的 Tensor;要把它当成与 GPU 执行共同调度的存储对象。

参考资料

  1. Shi Qiu et al., Tutti: Making SSD-Backed KV Cache Practical for Long-Context LLM Serving, arXiv:2605.03375v1, 2026-05-05。本文的系统机制、实验数字与两张原图均来自该版本。
  2. NVIDIA, GPUDirect Storage Overview Guide。用于区分 GDS 的 Direct Data Path 与 CPU Driver Control Path。
  3. NVIDIA, CUDA Programming Guide: Green Contexts。用于核对 SM Resource Partition 的能力与并发边界。
  4. Shi Qiu et al., GeminiFS: A Companion File System for GPUs, FAST 2025。Tutti GPU File 与 NVMe File Path 的底层文件系统基础。
  5. Woosuk Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention, SOSP 2023。Paged KV Block Layout 的基础工作。

评论