https://github.com/vllm-project/vllm/pull/46216/ 这个主要干嘛的

PR #46216 的主要目的是优化 vLLM 中 CPU Offloading 场景下的缓存驱逐(eviction)性能

它通过为 LRUCachePolicy 引入一个“可驱逐列表”(evictable list),并配合一系列数据结构优化,显著提升了缓存驱逐的效率。

优化动机与效果

这个优化的直接原因是,在启用某些特性(如 KV_TRANSFER、prefix caching)进行压测时,evict 操作成为了性能瓶颈。

从 PR 中提供的性能对比数据来看,优化效果非常显著:

指标 优化前 (main) 优化后 (PR)
吞吐量 (总 Tokens/秒) 1959.0 11700.3
输出 Tokens/秒 22.1 138.0

可以看到,优化后的总吞吐量提升了约 6倍

主要改动点

为了实现这一性能提升,PR 主要做了以下几项改动:

  1. 核心逻辑优化:在 LRUCachePolicy 中维护一个独立的“可驱逐列表”。这使得在进行缓存驱逐时,系统能快速定位到可以被驱逐的块,而无需遍历整个缓存,从而降低了时间复杂度。

  2. 数据结构微调:将 BlockStatusctypes.Structure 改为使用 __slots__。这减少了对象的内存占用,并加速了字段访问,因为开发者发现原方式在频繁访问 ref_cnt 时存在性能瓶颈。

  3. API 简化:为了让缓存策略的实现更清晰、直接,PR 引入了 mark_evictablemark_non_evictable 这两个新方法,用于明确地标记一个块何时可以被驱逐。

状态

该 PR 已被合并到 vLLM 的主分支。

TieringOffloadingSpec 是 vLLM 中用于多级 KV 缓存卸载(Multi-tier KV Cache Offloading) 的配置规范。

简单来说,它定义了一个分层的缓存系统,让 KV 缓存数据可以在不同速度和容量的存储层级间流动,用更大的存储空间来换取更高的有效上下文容量。

这里的 “Tier”(层级) 指的是一个层次化、按速度和成本划分的存储结构。vLLM 的 TieringOffloadingSpec 正是构建了这样一个多层级的存储体系。

🏗️ 核心架构:三级存储体系

TieringOffloadingSpec 构建了一个“GPU → CPU → 二级存储(如NVMe硬盘)”的三级架构:

  1. GPU (最高速,容量最小):推理计算发生的地方,KV 缓存最初在此生成。这是性能最高但容量最有限的层级。
  2. CPU 主层级 (Primary Tier):即 CPU 内存,作为 GPU 与更慢存储之间的直接接口和中转站。它比 GPU 内存大,但比二级存储快。
  3. 二级存储层级 (Secondary Tiers):配置中的 secondary_tiers 列表。这是容量最大、速度最慢的层级,例如你配置中的本地 NVMe 硬盘 (/mnt/nvme_storage)。也可以支持网络存储等其他类型。

⚙️ 工作原理:数据如何流动

这个体系通过以下流程运作:

  • 存储 (Store) - 级联向下:当 GPU 内存不足时,不常用的 KV 块会先被卸载到 CPU 主层级。如果 CPU 层级也满了,这些块会进一步级联存储到二级存储层级(例如你的 NVMe 硬盘)。
  • 加载 (Load) - 逐级向上:当推理需要某个已被卸载的 KV 块时,它会先从二级存储提升(Promotion)到 CPU 主层级,然后再加载到 GPU 内存中。
  • 关键限制二级存储层级无法直接与 GPU 通信,所有数据进出都必须经过 CPU 主层级。这保证了数据一致性,但也意味着数据访问路径更长。

📝 你的配置解读

你提供的配置正是启动了这套多级卸载机制:

  • "spec_name": "TieringOffloadingSpec":明确启用多级 KV 缓存卸载。
  • "cpu_bytes_to_use": 429496729600:为 CPU 主层级分配了 400 GB 的内存。
  • "eviction_policy": "lru":CPU 主层级使用 LRU (Least Recently Used) 策略来淘汰旧缓存。
  • "secondary_tiers": [{"type": "fs", "root_dir": "/mnt/nvme_storage"}]:配置了一个类型为 fs (文件系统) 的二级存储层级,存储路径为 /mnt/nvme_storage。这意味着当 400GB CPU 内存也存满后,最不常用的缓存块会被进一步卸载到你指定的 NVMe 硬盘上。

⚠️ 几点注意事项

  • --enable-prefix-caching 的关系:在你的启动命令中,同时出现了 --enable-prefix-caching--no-enable-prefix-caching。在 vLLM 中,后者(--no-...)通常具有更高优先级,最终会禁用前缀缓存。需要注意的是,KV 卸载功能与前缀缓存是独立工作的。
  • --no-disable-hybrid-kv-cache-manager 的关系:这个参数意味着启用了混合 KV 缓存管理器。这是使用 TieringOffloadingSpec 等高级 KV 缓存功能时的常见要求。

这是 PR #46216 作者压测用的启动命令。我先快速确认一个关键细节(前缀缓存开关与块哈希的关系),再逐项解释。
确认完毕,可以完整解释了。这是 PR #46216 作者的压测启动命令,每一项配置都对应我们这几轮讲过的代码。逐项拆解:


一、整体一句话

启动一个 gemma-4-31B 服务(TP=2),关闭 GPU 本地前缀缓存,把 KV 复用全部交给 OffloadingConnector 的分层卸载链路:GPU → CPU 内存(400GiB,LRU 淘汰)→ NVMe 磁盘(文件系统二级缓存)——人为制造最大的淘汰压力,专门压 evict 热点。


二、KV_TRANSFER_CONFIG 逐项

配置 含义(对应我们讲过的代码)
kv_connector OffloadingConnector 用卸载连接器([factory 注册表](file:///Users/seadawn/ali/public/vllm/vllm/distributed/kv_transfer/kv_connector/factory.py#L207)里的那个)
kv_role kv_both 本实例既存又读:算完的 KV 卸载出去,命中时加载回来
spec_name TieringOffloadingSpec 分层 spec 而非纯 CPU spec——OffloadingSpecFactory 按这个名字解析([factory.py](file:///Users/seadawn/ali/public/vllm/vllm/v1/kv_offload/factory.py))
cpu_bytes_to_use 429496729600 400 GiB CPU 内存做一级卸载缓存(就是 CPUOffloadingSpec.__init__ 里换算 num_blocks 的那个预算)
eviction_policy lru CPU 缓存用 LRU 淘汰(CPUOffloadingManager._CACHE_POLICIES,就是被 PR 优化的那个类)
secondary_tiers [{type: fs, root_dir: /mnt/nvme_storage}] 二级缓存:CPU 被淘汰的块继续下沉到 NVMe 磁盘上的文件系统目录,而不是直接丢弃

三、vllm serve 参数逐项

参数 含义
google/gemma-4-31B-it 模型(注意:gemma-4 是混合架构——滑动窗口注意力 + 全量注意力层,会产生多个 KV cache group,就是之前讲 OffloadKey 要拼 group_idx 的那种模型)
--chat-template ... 覆盖聊天模板为 ChatML 格式(<|im_start|>/<|im_end|>),适配 guidellm 发的消息格式,与本 PR 主题无关
--tensor-parallel-size=2 TP=2 → world_size=2,每个卸载 job 要 2 个 worker 都报完成才算完(pending_count=2 那套聚合机制)
--kv-transfer-config "${KV_TRANSFER_CONFIG}" 把上面的 JSON 传进去

重点:那对"自相矛盾"的开关

--enable-prefix-caching \
--no-enable-prefix-caching \

两个都给了。vLLM 的参数解析是后出现者生效 → 实际效果是关闭 GPU 本地前缀缓存

为什么关了它,卸载链路还能工作?关键在我刚确认的 [core.py:214](file:///Users/seadawn/ali/public/vllm/vllm/v1/engine/core.py#L214):

if vllm_config.cache_config.enable_prefix_caching or kv_connector is not None:
    # 计算 block_hashes

只要配了 KV connector,即使本地前缀缓存关闭,块哈希照样计算——而哈希正是 OffloadKey 的原料。所以效果变成:

  • GPU 侧不做任何前缀复用(get_computed_blocks 永远 0 命中)
  • 所有前缀复用必须走 CPU/NVMe 卸载链路(lookup → 命中 → prepare_load 加载)

这是刻意的压测设计:200 个不同的 10000-token 前缀,如果 GPU 缓存开着,一部分命中会在 GPU 层就被消化掉;关掉后,每一次复用都砸在 CPU 卸载的 lookup / store / evict 上——把 PR 要优化的 evict 热点压到极限。

--no-disable-hybrid-kv-cache-manager

双重否定 = 启用混合 KV 缓存管理器(HMA)(这本就是默认,显式写出)。gemma-4 是混合模型必须用它;同时工厂会检查 connector 是否支持 HMA([factory.py:54-60](file:///Users/seadawn/ali/public/vllm/vllm/distributed/kv_transfer/kv_connector/factory.py#L54-L60))——OffloadingConnector 实现了 SupportsHMA,通过。


四、这套配置下的完整数据流

首次 MISS

是 → evict
(PR 优化的热点)

再次 HIT

CPU 未命中
但 NVMe 有

请求: 10000 token 共享前缀

CPU lookup
(get_num_new_matched_tokens)

GPU 全量计算 prefill

prepare_store → 存入 CPU 400GiB
store job → 异步 DMA

CPU 满了?

淘汰块下沉到
NVMe /mnt/nvme_storage

prepare_load 钉住
→ DMA 回 GPU

从 fs tier 读回
promote

省掉 10000 token 的 prefill 计算


五、和 PR 的关系总结

命令元素 在压测中扮演的角色
400GiB CPU + LRU 制造约 20 万个块的大规模缓存(旧 evict 扫描地狱)
--no-enable-prefix-caching 关闭 GPU 侧缓存,让所有复用流量 100% 打到 CPU 卸载路径
NVMe 二级缓存 让淘汰更频繁发生(CPU→fs 下沉 + 回读 promote)
100 并发 × 200 前缀 持续制造 store 洪峰,每批 store 都可能触发 evict

正是在这套"故意最坏化"的配置下,旧 evict 全表扫描把吞吐打到 0.1 req/s,PR 的双表优化把它拉回 1.1 req/s——这组命令就是那张 6 倍提升成绩单的实验台

这是 PR #46216 配套的 guidellm 压测命令——上一段 vllm serve 是把服务按"最坏情况"架起来,这一段是用什么样的流量去打它。逐项解释:

(小提醒:你贴的 BENCH_TURNS=5a 里的 a 是粘贴杂质,原 PR 是 BENCH_TURNS=5。)


一、环境变量逐项

变量 含义
TARGET_URL http://localhost:8000 打刚才启动的 vLLM 服务
BENCH_RATE 100 压力强度 = 100
BENCH_RATE_TYPE concurrent 压力类型 = 固定并发:始终保持 100 个请求在途,一个完成立刻补一个(不是每秒 100 个的 RPS 模式)
BENCH_MAX_SECONDS 700 压测最长跑 700 秒
BENCH_RANDOM_SEED 889 固定随机种子,保证两次压测生成的请求序列完全一致(可复现对比 main vs PR)
BENCH_TURNS 5 多轮对话,每个会话 5 轮
BENCH_PROMPT_TOKENS 128 每轮新增的 prompt 内容 128 token
BENCH_OUTPUT_TOKENS 128 每轮生成 128 个输出 token
BENCH_PREFIX_TOKENS 10000 每个请求前拼接 10000 token 的共享前缀
PREFIX_COUNT $((2 * 100)) = 200 前缀池大小:一共只有 200 个不同的前缀,所有请求从这里随机抽取

二、DATA JSON:合成数据规格

{"prompt_tokens":128, "output_tokens":128, "prefix_tokens":10000, "turns":5, "prefix_count":200}

guidellm 不用真实数据集,而是按这个规格合成请求

每个请求 = [从 200 个前缀池中抽一个 10000-token 前缀] + [128 token 新内容]
每个会话 = 这样的请求连续发 5 轮(上下文逐轮增长)
并发生成 128 token

这个形状的刻意设计(配合上一问的 serve 配置):

  • 200 个前缀 × 10000 token = 200 万 token 的可复用内容——远超 GPU 缓存容量(而且本地前缀缓存还被 --no-enable-prefix-caching 关了),复用只能走 CPU/NVMe 卸载链路
  • 前缀池只有 200 个但请求源源不断 → 同一个前缀被反复命中 → 持续压 CPU 缓存的 HIT 路径(lookup → prepare_load → DMA 回 GPU)
  • 5 轮对话每轮都产生新 KV → 持续的 STORE 洪峰 → CPU 缓存满 → 高频触发 evict(PR 优化的那个热点)
  • 100 并发 → 调度循环在高负载下连轴转,任何 O(N) 操作都会被放大成吞吐塌方

三、guidellm benchmark run 参数

参数 含义
--target 目标服务地址
--rate-type=concurrent 固定并发模式
--rate=100 并发数 100
--max-seconds=700 最多跑 700 秒(到点停止汇总)
--random-seed=889 固定种子(main 和 PR 两次跑生成一模一样的请求,对比公平)
--data="${DATA}" 上面的合成数据规格
--sample-requests=0 跳过预热采样请求(guidellm 默认先发几个样本请求热身,设 0 直接开压)

四、跑出来的结果怎么看(PR 里那张表)

| Strategy   | Concurrency | Per Sec | Input Tok/s | Output Tok/s | Total Tok/s |
| concurrent | 100.0       | 0.1     | 1936.9      | 22.1         | 1959.0      |   ← main
| concurrent | 100.0       | 1.1     | 11562.3     | 138.0        | 11700.3     |   ← PR

读法:

  • Concurrency 100.0:确实维持住了 100 并发(压测设置生效)
  • Per Sec 0.1 → 1.1:请求完成速率,~11 倍
  • Input Tok/s 1936 → 11562:每秒处理的输入 token,~6 倍——因为每个请求输入约 10128 token(前缀+prompt),它基本正比于 req/s
  • Output Tok/s 22 → 138:生成侧同步提升(输入侧不再被 evict 卡死,流水线顺了)

五、和前面所有内容的闭环

vllm serve(最坏化配置)          guidellm(最坏化流量)
400GiB CPU 缓存 + NVMe 分层   ×   200 前缀反复命中 + 100 并发 + 5 轮 store 洪峰
关闭 GPU 前缀缓存                    │
        └──────────┬───────────────┘
                   ▼
        每一次复用都走 CPU 卸载链路
        每一批 store 都可能触发 evict
                   ▼
   main:evict 全表扫描 → 调度循环卡死 → 0.1 req/s
   PR:evictable_blocks 直取 → 循环顺畅 → 1.1 req/s

一句话总结:serve 命令负责"把系统架在淘汰压力最大的状态",guidellm 命令负责"用可复现的高压流量持续捶打 evict 路径"——两者合起来构成 PR #46216 那张 6 倍提升成绩单的完整实验。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐