【大模型推理】vllm/pull/46216/ kv connector evict 性能瓶颈学习
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 主要做了以下几项改动:
-
核心逻辑优化:在
LRUCachePolicy中维护一个独立的“可驱逐列表”。这使得在进行缓存驱逐时,系统能快速定位到可以被驱逐的块,而无需遍历整个缓存,从而降低了时间复杂度。 -
数据结构微调:将
BlockStatus从ctypes.Structure改为使用__slots__。这减少了对象的内存占用,并加速了字段访问,因为开发者发现原方式在频繁访问ref_cnt时存在性能瓶颈。 -
API 简化:为了让缓存策略的实现更清晰、直接,PR 引入了
mark_evictable和mark_non_evictable这两个新方法,用于明确地标记一个块何时可以被驱逐。
状态
该 PR 已被合并到 vLLM 的主分支。
TieringOffloadingSpec 是 vLLM 中用于多级 KV 缓存卸载(Multi-tier KV Cache Offloading) 的配置规范。
简单来说,它定义了一个分层的缓存系统,让 KV 缓存数据可以在不同速度和容量的存储层级间流动,用更大的存储空间来换取更高的有效上下文容量。
这里的 “Tier”(层级) 指的是一个层次化、按速度和成本划分的存储结构。vLLM 的 TieringOffloadingSpec 正是构建了这样一个多层级的存储体系。
🏗️ 核心架构:三级存储体系
TieringOffloadingSpec 构建了一个“GPU → CPU → 二级存储(如NVMe硬盘)”的三级架构:
- GPU (最高速,容量最小):推理计算发生的地方,KV 缓存最初在此生成。这是性能最高但容量最有限的层级。
- CPU 主层级 (Primary Tier):即 CPU 内存,作为 GPU 与更慢存储之间的直接接口和中转站。它比 GPU 内存大,但比二级存储快。
- 二级存储层级 (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,通过。
四、这套配置下的完整数据流
五、和 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 倍提升成绩单的完整实验。
更多推荐




所有评论(0)