TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

AI 存储与 KV 缓存卸载:从权重加载路径到 LMCache 实测

状态:草稿(2026-10-07 更新),部分反映了实测数据。 本文基于官方文档确认的内容进行撰写,并追加了第 7 节的 KV 卸载实测(单张 8GB 级 GPU)以及「外部依据」一节的论文对比。存储性能(VAST、3FS、GDS)由于未能直接测得,仅达到了确认厂商资料和论文条件的程度。请先阅读文末的「确认的版本与日期」和「未确认的内容」。写作规则:实测数据作为基础,未测得的内容标记为「未实测」。

目录

在大模型服务中,与 GPU 计算一样经常出问题的有两个。一是将数百 GB 的权重从存储搬到 GPU 内存的时间,二是将无法完全放入 GPU 内存的 KV(Key-Value)缓存放在哪里。本文将这两个问题结合存储层次(对象存储、文件系统、本地 NVMe、页缓存、GPU 内存)进行说明。由于笔者的实测规模较小,因此一并写明了局限性。

读完本文后能够在面试中解释的内容

  1. 数百个 Pod 同时启动时加载成为瓶颈的原因,以及在加载器访问模式、文件系统布局、缓存层次中应该优先修复哪一个。
  2. VAST Data 等 AI 存储解决的问题、GPUDirect Storage(GDS)的本质,以及厂商数据中应当被归类为「主张」的部分。
  3. 建立 KV 缓存卸载带来收益的条件公式,并代入笔者的实测计算盈亏平衡命中率的方法。

1. 为什么加载是瓶颈

权重的大小等于参数数量乘以数据类型的字节数。如果将 700 亿(70B)参数设置为 bf16(2 字节),则是 140GB(计算得出)。由于每个 Pod 都在读取相同的文件,如果有 100 个 Pod 同时启动,需要读取的总量将达到 14TB。假设存储聚合带宽为 10GB/s,仅读取就需要约 1,400 秒(23 分钟)(两者均为说明性质的示例,并非实测)。

在自动扩缩容中,这段时间就是响应延迟。Pod 必须完成调度、拉取镜像、加载权重、完成编译和预热后才能通过 readiness 检查。如果加载需要 10 分钟,那么在负载上升后的 10 分钟内新增的 Pod 将无法接收请求,因此加载时间直接决定了预扩容、冗余容量以及优化加载路径的选择。

NVIDIA Dynamo 文档中有一个实测案例。将 554GiB 的模型从 Azure Managed Lustre(4 个 OST,聚合带宽约 8GB/s)加载到两台 8×H100 节点(张量并行 16)时,默认加载器的有效吞吐量约为 124MB/s,加载耗时约 76 分钟。更换加载器并将文件分布式条带化(striping)到所有 OST(Object Storage Target)后,时间缩短至约 5.3 分钟。这是针对特定环境的厂商测量数据。重点在于,即使存储具备高带宽,如果读取方的访问模式不好,也无法利用该带宽。

2. 加载路径的层次

 객체 저장소 (S3 호환)  ─┐
 병렬/분산 파일 시스템   ─┼─▶ 노드 로컬 NVMe ─▶ 페이지 캐시 ─▶ CPU 버퍼 ─▶ GPU HBM
 (Lustre, VAST, WEKA 등) ─┘   (hostPath/로컬PV)   (DRAM, mmap)   (핀드 메모리)  (샤드별 적재)
   용량 큼, 공유, 느림  ◀──────────────────────────────────────▶  작고 빠름, 노드/GPU 전용
层次 特性 常见失败
对象存储 容量巨大且所有节点共享。由于每个请求的延迟较高,必须并行读取较大的范围。 数百个 Pod 的并发 GET 请求涌向同一个端点。
并行/分布式文件系统 将数据分散存储在多个存储节点上。单个文件的读取上限由条带化(striping)决定。 文件仅放置在单个目标上,无法使用聚合带宽。
节点本地 NVMe 缓存 没有网络竞争。每个节点都需要一个副本。 节点变更时需要重新获取。
页缓存 同一节点的各个 Pod 从 DRAM 中读取相同的文件(操作系统常规行为)。 容器内存限制中包含了脏页,导致发生 OOM(第 7 节)。
GPU HBM(High Bandwidth Memory) 最终目的地。如果是张量并行,每个 GPU 只加载自己的分片。 加载过程中 CPU 缓冲区不足。

3. 模型文件格式与加载优化

safetensors 格式。 前 8 个字节(小端无符号 64 位)是头部大小 N,接下来的 N 个字节是包含张量名称、数据类型、形状和数据偏移量的 JSON 头部,其余部分是张量的原始字节。与 pickle 不同,它不执行代码,因此很安全,并且仅通过读取头部就能知道特定张量的位置,从而实现只读取一部分的延迟加载。在张量并行中, get_slice 可以只读取属于自己的那部分碎片。不过,正如项目 README 所指出的一样,在 CPU 上虽然对于缓存的文件可以实现零拷贝使用,但要传输到 GPU,则必然需要进行一次拷贝。所谓安全是指针对代码执行而言的,并不检查张量值(如 NaN 等)是否正确。

默认加载器的局限性。 根据 Dynamo 文档,vLLM 默认加载器(--load-format auto)对每个 safetensors 分片进行 mmap 并以单流进行拷贝。在远程存储中,由于小请求以浅队列深度发出,从而受到延迟的束缚。在同一文档的 Lustre 测量中,单目标读取在 128KiB 块大小下约为 228MB/s,在 1MiB 下约为 854MB/s,在 16MiB 下约为 1.6GB/s。

流式加载器。 vLLM 文档中的 Run:ai Model Streamer 通过 pip install vllm[runai] 之后传入 --load-format runai_streamer 来开启。通过 --model-loader-extra-config 调节 concurrency(读取线程数)、 memory_limit(CPU 缓冲区大小)以及 distributed(来自对象存储的分布式流式传输),并读取本地路径、S3、GCS 和 Azure Blob。如果模型已经通过 model-rank-{rank}-part-{part}.safetensors 预先切分,则通过 runai_streamer_sharded 让每个工作进程只读取自己的分片。该项目于 2026-09-13 更名为 Model Streamer 并移至 dsx-ai-factory/model-streamer ,但 PyPI 软件包名称保持不变。

此外,vLLM 文档中还提到了使用 GDS 的 fastsafetensors、CoreWeave Tensorizer,以及在重启时通过 CUDA IPC 将 GPU 上剩余权重重新映射的 vllm preload。Dynamo 的 ModelExpress 从已经处于服务中的副本 GPU 通过 RDMA(Remote Direct Memory Access)接收权重,其 README 声称在 8×B200 节点、vLLM 0.23.0、张量并行 8 的条件下,冷池启动时间从 8 分钟 53 秒缩短至 11 秒(48 倍)(厂商数据)。

4. 高性能 AI 存储:以 VAST Data 为中心

根据 VAST 白皮书,其架构是 DASE(Disaggregated Shared-Everything,经过分类解耦的全共享架构)。无状态的 CNode(作为容器运行的协议与逻辑服务器)通过 NVMe-oF(NVMe over Fabrics)挂载所有 SSD,数据则存放在 DNode/DBox 的 SCM(写入缓冲区)和 QLC 闪存中。访问协议包括 NFS(包括基于 RDMA 的 NFS)、S3、SMB、块存储等。NVIDIA GDS 文档列出了可使用 GDS 的网络文件系统为 DDN-EXAScaler、VAST-NFS、WekaFS,并将 Lustre 也列为支持对象。

GDS 是绕过 CPU 弹跳缓冲区(bounce buffer),通过 DMA(Direct Memory Access)直接从存储发送到 GPU 内存的路径。如果不满足条件(nvidia-fs 驱动程序缺失、文件系统不支持、无法使用 O_DIRECT),则会降级为经由 CPU 内存的兼容模式路径。因此,“支持 GDS”并不意味着已经开启,而是指可以开启,实际路径必须进行确认。

应当作为厂商主张来对待的数据

替代方案对比(简要)。 Lustre 是开源并行文件系统,条带化(stripe_count)决定了性能。DeepSeek 的 3FS 是基于 NVMe 和 RDMA 的分布式文件系统(MIT 许可证),将元数据放在 FoundationDB 中,其 README 宣称在 180 个节点上可实现约 6.6TiB/s 的读取速度(原文说明了这是在 180 台存储节点和 500 多个客户端、存在背景学习流量的读取压力测试条件,约为存储节点 NIC 总和的 81%)。除 GDS 文档中列出的内容外,未能打开确认 WEKA 和 DDN 的官方资料。

5. Kubernetes 中的存储

6. KV 缓存卸载

        GPU HBM (G1)  ◀──────────────▶  CPU 핀드 메모리 (G2)
        활성 KV, 가장 빠름     DMA, 비동기       │  ▲
                                                 ▼  │ 선인출(prefetch)
                              로컬 NVMe/디스크 (G3) ◀──▶ 원격/객체 저장소 (G4)
   적중 시 위로 올림(promote), 넘치면 아래로 내림(LRU 등), GPU 와 직접 닿는 층은 CPU 층뿐

层名称遵循了 Dynamo KVBM 文档(G1 GPU HBM、G2 钉扎 DRAM、G3 NVMe/SSD、G4 S3/MinIO)。vLLM 的 OffloadingConnector 也是同样的结构,只有 CPU 层直接接触 GPU,辅助层则通过 CPU 中转。

LMCache 的结构。 LMCache 是一个 KV 缓存管理层,当前文档区分了两种模式。传统方式是进程内(in-process)模式,在 vLLM 内部作为 LMCacheConnectorV1 运行,文档中已将其标记为废弃(deprecated)。新部署建议使用多进程(MP)模式,每个节点运行一个独立的 lmcache server (通过 DaemonSet),多个 vLLM Pod 通过 LMCacheMPConnector 共享。服务端分为 L1(CPU DRAM 或 GDS NVMe 块)和 L2(文件系统、NIXL、S3、Redis 等),数据从 L1 异步推送到 L2,未命中时则从 L2 加载到 L1。文档提到的优点是消除了 vLLM 进程的 GIL 争用并实现了故障隔离。Kubernetes 运算符通过一个 LMCacheEngine 自定义资源(lmcache.lmcache.ai/v1alpha1)来协调 DaemonSet、Service 和 ConfigMap。

实例间的前缀共享。 方法有四种。同一节点上的 Pod 共享 MP 服务器的 L1、将 L2 设为共享文件系统或 S3(适配器的 "shared": true)、将 vLLM 自身的 fs 层指定为共享 PVC 中的同一 root_dir (由于哈希种子是固定的,会生成相同的文件名,部分哈希算法必须匹配 PYTHONHASHSEED ),或者使用 RDMA P2P 共享(需要协调器和 RDMA 网络,且不能与 GDS L1 一起使用)。由于请求必须路由到持有缓存的实例才能获益,KServe 文档中的调度程序会利用 vLLM 发送的 BlockStored/BlockRemoved 事件构建索引,并根据前缀命中得分(权重 2.0)和负载得分(1.0)进行路由。

成本与收益。 每个 Token 的 KV 大小为 2 × 층 수 × KV 헤드 수 × head_dim × 자료형 바이트。笔者实验的模型 Qwen2.5-1.5B(Hugging Face 配置:28 层,KV 头 2,head_dim 1536/12=128,fp16)每个 Token 为 28,672 字节,4,864 个 Token 即为 139,460,608 字节(0.1299 GiB)。这与 LMCache 日志中的 0.1299 一致。70B 级别的 Llama(80 层,KV 头 8,head_dim 128,bf16)每个 Token 为 327,680 字节,因此 3.2 万个 Token 大约为 10.7 GB(经计算)。单次命中的收益为 T_재계산 − (S/B + 지연) ,命中概率为 p 时的平均收益 p×이득 − (1−p)×저장비용 必须为正数。如果存储层的带宽 B 较低,则传输时间 S/B 就会超过重新计算的时间。

7. 直接测量与常见陷阱

KV 卸载实测。 在一台 8GB 级别的笔记本 GPU 上,使用 vLLM 0.29.0 和 LMCache 0.5.5 部署了 Qwen2.5-1.5B-Instruct (fp16),两种配置下的 GPU KV 缓存大小均为 21,520 个 Token。按顺序发送了 10 个文档(每个文档约 5,011 个 Token,总计约 5 万个 Token)进行首次读取、按原顺序再次读取以及逆序读取。LMCache 的设置为:块大小 256、CPU 上限 1.5GB、进程内模式(LMCacheConnectorV1)。

配置 轮次 TTFT p50 (ms)
仅 vLLM 首次 575.5
仅 vLLM 按原顺序再次读取 576.2
LMCache 首次 597.8
LMCache 按原顺序再次读取 48.6

实验的局限性。 并发量为 1,模型较小(1.5B),文档是由随机词组成的合成文档,且只有一张 GPU。由于只测量了一次,因此未求标准差,并且镜像标签是 latest ,重新复现时版本可能会发生变化。由于进程内模式在当前的 LMCache 文档中已被废弃,因此未重新测量 MP 模式的数值。大模型会带来更大差距的预期并未在此次测量中得到体现。

深入实测:容量曲线、按长度收益、磁盘层、块大小

我们在相同的环境(单张 8GB 级 GPU、vLLM 0.29.0、LMCache 0.5.5、Qwen2.5-1.5B-Instruct)下更改条件重复了上述实验。这次将 GPU KV 池固定为 21,520 个 Token(重启服务器后数值保持不变),文档为 10 个(每个文档 5,189 个 Token,总计 52,025 个 Token)。该工作集的 KV 大小按每个 Token 28,672 字节(28 层 × KV 头 2 × 头维度 128 × 键和值 2 × 2 字节)计算,约为 1.49GB。

CPU 缓存容量曲线。 按原顺序再次读取时(LRU 最坏情况)的 TTFT 中位数。

LMCache CPU 上限 再次读取 TTFT 外部命中 Token / 查询 Token
无(仅 vLLM) 600ms 不适用
0.5GB 620ms 0 / 52,025
1.0GB 620ms 0 / 52,025
1.5GB 39ms 51,200 / 52,025 (98%)
2.0GB 39ms 51,200 / 52,025 (98%)

接近实际访问的偏斜情况(60 个文档,齐普夫分布 s=1,60 个请求)。 如果部分文档被频繁读取,较小的缓存也会有所帮助。

配置 TTFT 平均值 外部命中 Token 比例
仅 vLLM 278ms 不适用
CPU 0.5GB 289ms 0%
CPU 1.0GB 174ms 44%
CPU 1.5GB 121ms 64%
CPU 2.0GB 63ms 87%
CPU 0.2GB + 磁盘 10GB 47ms 98%

由于中位数大多在 30ms 左右(经常被读取的文档直接在 GPU 中命中),平均值和 p95 能更好地展示容量的效果。

按长度收益(CPU 2GB,从 GPU 驱逐后再次请求相同文档)。

输入 Token数 重新计算(仅 vLLM) 从 LMCache 加载 倍数
554 66ms 21ms 3.1 倍
1,077 119ms 24ms 5.0 倍
2,144 231ms 29ms 8.0 倍
4,273 481ms 45ms 10.7 倍
6,414 768ms 41ms (36~43) 约 19 倍

重新计算的时间几乎与长度成正比增长,而加载时间增长得要缓得多,因此上下文越长,收益越大。加载时间仅比相同长度的 GPU 命中(20~32ms)长 1~17ms。

磁盘层。 当使用 CPU 0.2GB 和本地磁盘 10GB 时,再次读取耗时 57ms(p95 为 255ms),虽然比仅使用 CPU(39ms)慢,但比重新计算(600ms)快得多。由于文件是刚刚写入的,可能受益于操作系统页面缓存,因此不能将其视为冷磁盘的数值(未隔离)。

块大小(64、256、1,024)。 用一个包含 2,000 个公共前缀 token 和 1,000 个尾部 token 的文档来测试前缀重用时,三种块大小的外部命中率都是 2,048 token,TTFT 也同样为 140~144ms。在这个测试中,没有看出块大小的影响。不过,由于此次测试的前缀已经留在了 GPU 前缀缓存中,其结果也与仅使用 vLLM 的数值(138ms)相同,因此不足以作为一个区分块对齐效果的测试(未分离)。

局限性。 在更改服务器状态的情况下分别测量了一次(未重复块与容量曲线),并发数为 1,使用的是合成文档和一个 1.5B 模型。在相同条件下重新进行上述第 7 节的第一个实验时,首次读取从 598 ms 到 618 ms,再次读取从 48.6 ms 到 39 ms 略有不同,因此个位数的毫秒差不应赋予实际意义。

常见陷阱:脏页与内存限制。 当把大对象放在网络文件系统上时,尚未写入存储的脏页缓存可能会被计算为容器内存,从而超出内存限制并导致 OOMKilled。我们曾遇到过通过限制并发上传数并增加内存限制来解决该问题的情况。这就是页缓存层与内存限制交织在一起的一个例子。

未曾亲历的经验。 我没有运营过 VAST、WEKA 或 GDS。第 4 节是通过阅读官方资料和厂商文档整理而成的。

外部依据:通过论文和官方资料确认的内容

我们将 KV 卸载和存储描述与第一手资料进行了对照。外部数据由于模型、GPU 和负载不同而没有进行直接对比,仅判定了方向和数量级是否一致。

主题 资料与确认位置 确认的内容与条件 与本文的关系
LMCache 的收益 Liu 等人,“LMCache: An Efficient KV Cache Layer for Enterprise-Scale LLM Inference”,arXiv 2510.09665(2025-12 修订),第 8.2、8.4、8.7、9 节 8×H100,Llama-3.1-8B 等 8B~480B 模型,CPU 500GB。在多轮文档问答中,QPS 为 1 时 TTFT 降低 1.9~8.1 倍,在相同 TTFT 下吞吐量提升 2.3~14 倍(摘要中的“最高 15 倍”在正文中仅确认到 14 倍)。远程存储的带宽低于 CPU,如果输入较短,加载速度可能会比预填充(prefill)更慢 有限支持。 方向和数量级相同。由于指标(低负载下的 TTFT 对比并发 1 的命中与未命中)和基准不同,无法与我们的 12 倍直接对比。论文未报告单独的存储成本数据
接近的独立测量 py-kvcache 论文(arXiv 2609.11744) 在重复相同文档、仅输出 1 个 token 并测量命中 TTFT 的测试中,相比重新计算,1k token 时为 2.2 倍,80k token 时为 32.8 倍(论文使用的工作站级单张 GPU,PCIe 4.0 x16,vLLM 0.16,CPU 层) 支持。 我们 5 千 token 约 12 倍的表现在此范围内
KV 压缩传输 Liu 等人,CacheGen,SIGCOMM 2024。Yao 等人,CacheBlend,EuroSys 2025 CacheGen:KV 大小减少 3.5~4.3 倍,准确率下降在 2% 以下,承认在低带宽下加载速度会变得和重新计算一样慢。CacheBlend:通过非前缀重用使 TTFT 提升 2.2~3.3 倍,质量下降在 0.02 以内 CacheGen 有限支持(劣势条件),CacheBlend 则为 无关(其他问题)
收支平衡点 未找到直接使用该公式的资料。CacheGen 附录 E(月请求数收支平衡点,原文算术有误故不引用),VAST 与 Lablup 联合博客(2026-06-24) 描述了收支平衡点取决于长度、存储带宽和命中率。根据 VAST·Lablup 的数据,不是 4%,而是约 18~21% 有限支持。 4% 并非通用常数(文章已说明)
PCIe 理论带宽 NVIDIA 产品资料与 Rambus 解析(PCI-SIG 规范文档因无法访问而未能打开) 4.0 x16 约 29.3 GiB/s,x8 约 14.7 GiB/s,3.0 x16 约 14.7 GiB/s 有条件支持。 根据链路宽度不同,可能在极限之内,也可能超出,因此将上述表述修改为了“与理论极限处于同一水平”
3FS DeepSeek 3FS README 180 个存储节点,500 多个客户端,后台训练流量,约 6.6 TiB/s(约占网卡总带宽的 81%) 支持。 在正文中补充了条件
GDS NVIDIA GPUDirect Storage 官方文档 仅描述了在某些系统中最大带宽至少翻倍,其余数据由合作伙伴提供 支持(定性)。 DDN 新闻稿没有条件说明,WEKA 的最新数据由于无法打开原文而未采用
权重加载时间 Fu 等人,ServerlessLLM,OSDI 2024 使用 PyTorch 加载 OPT-30B 需 34 秒,LLaMA-2-70B 需 84 秒(基于图表)。其中最小的模型为 2.7B 有限支持。 正文中没有可与 1.7B 的 4~6 秒相抗衡的数据
safetensors safetensors README,Python pickle 文档与 PyTorch torch.load 警告 关于标头结构以及要进入 GPU 必须复制一次的描述相符,pickle 存在任意代码执行的风险 支持。 在正文中补充了不检查数值(如 NaN 等)的限定条件

8. 亲自尝试

所有内容均为只读或以临时 Pod 的形式结束。

# (1) 모델 설정만으로 토큰당 KV 크기 계산: 로컬 실행, 환경 변경 없음
python3 - <<'PY'
import json, urllib.request
u = "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/resolve/main/config.json"
c = json.load(urllib.request.urlopen(u))
hd = c.get("head_dim") or c["hidden_size"] // c["num_attention_heads"]
b = 2 * c["num_hidden_layers"] * c["num_key_value_heads"] * hd * 2   # fp16/bf16
print(b, "B/token;", b * 4864 / 2**30, "GiB per 4864 tokens")
PY

# (2) 블록 크기에 따른 읽기 처리량: 기존 큰 파일을 읽기만 합니다(쓰기 없음)
for bs in 128k 1m 16m; do
  fio --name=r$bs --filename=/mnt/models/큰파일.safetensors --rw=read \
      --bs=$bs --direct=1 --size=4g --readonly | grep -E "READ:"
done

(3) 复现实验。 在一个使用 GPU 的临时 Pod 中运行 vLLM 和 LMCache,结束后删除该 Pod。如果节点内存与其他工作负载共享,请勿随意调高 Pod 的内存限制。

9. 可能会遇到的面试题

  1. 当数百个 Pod 同时启动时,应如何设计模型加载? 首先计算总读取量(Pod 数量 × 大小),读取方使用流式加载器(streaming loader)同时读取大块数据,存储方则通过文件布局、节点本地缓存以及副本间传输来减少共享存储的负载。验证时应采用实际规模的横向扩展(scale-out)测试,而非单 Pod 测试。
  2. 为什么在张量并行中可以按分片(shard)进行加载? 因为 safetensors 的标头中包含每个张量的偏移量,所以可以只读取所需的字节范围;如果是预先按 rank 拆分好的文件,每个 Worker 只需读取自己的文件,从而减少了整体读取量。
  3. 引入 VAST 后,加载和 KV 卸载会自动变快吗? 不会。GDS 必须在满足条件时才会生效,否则会降级为兼容模式,并且如果加载器的访问模式不好,就无法发挥带宽优势。厂商的基准测试必须核对条件(GPU、模型、网络)并在我自己的环境中重新测量。
  4. KV 缓存卸载在什么情况下会带来负面效果? 当命中率低于收支平衡点、存储层带宽较低导致加载时间长于重新计算、或者前缀很短且每次都不同时。根据作者的实际测量,首次读取约有 4% 的损失,收支平衡命中率约为 4%。
  5. 多个实例要如何共享前缀? 通过共享 L1(每节点一个 MP 服务器)、共享 L2(NFS/S3)或 P2P RDMA 来共享 KV,并设置一个前缀感知路由器,将请求导向持有缓存的实例。如果不匹配哈希种子,相同的前缀就会变成不同的键。

10. 常见误区

参考资料(实际打开查阅过的)

确认的版本与日期(2026-10-05)

LMCache v0.5.5(2026-09-12), vLLM v0.30.0(2026-09-22, docs.vllm.ai/en/latest 是开发版文档), Dynamo v1.5.0, Kubernetes v1.37.1, Model Streamer 先前公告 2026-09-13.

未能确认的内容。 WEKA 与 DDN 的官方文档、Lustre 的 GDS 详细条件、各容器运行时支持的镜像卷版本、VAST 产品的当前正式名称与价格、MP 模式的性能(不进行重新测量)、vLLM 稳定版文档与开发版文档的区别。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论