vllm源码学习
本学习主要围绕vllm的一些特性展开
kv_cache特性
路径vllm/v1/core/kv_cache_manager.py
1 | Request 到来 |
这段代码是 vLLM v1 架构中核心的显存管理层实现。
它位于调度器(Scheduler)和底层物理显存池(Block Pool)之间,
主要负责 PagedAttention 机制下的显存块分配、回收,以及 Prefix Caching(前缀缓存)功能。
代码主要由两个类构成:
- KVCacheBlocks:调度器与管理器之间的隔离层
- KVCacheManager:缓存与分配的控制中枢
1 |
|
1 | class KVCacheManager: |
1 | def allocate_slots( |
1 | def free(self, request: Request) -> None: |
1 | def allocate_slots( |
总结:
这段代码展示了 vLLM 如何把“理论上的 PagedAttention”落地成“工业级的显存调度系统”。
它通过哈希匹配实现跨请求的内存共享(Prefix Caching),并通过非常细致的槽位类型划分,
支持了当前大模型推理领域中的多种前沿加速技术。
文末问答
以下是对 vLLM KV Cache 核心问题的详细解答。这里的“代码实例”只保留两类:一类是本文前面已经贴出的源码片段;另一类会明确标注为“示意代码”,避免把机制说明误写成“这段源码里就直接实现了它”。
一、 核心架构:PagedAttention 原理
显存浪费问题与 PagedAttention 的解决思路
• 传统痛点: • 外部碎片:传统框架(如早期 FasterTransformer)需要提前按模型的最大序列长度(如 2048 或 4096)为每个请求预分配连续显存。但大部分请求往往达不到最大长度,导致预留的显存空置。 • 内部碎片:即使按实际生成长度动态分配,由于内存对齐或分块策略粗糙,也会产生浪费。 • 共享困难:不同请求即使 System Prompt 相同,由于显存必须连续,也无法在物理层面共享这段 Prompt 的 KV Cache。
• PagedAttention 方案:将 KV Cache 拆分为固定大小的“物理块”(Physical Blocks),每个块仅存储少量 Token(如 16 个)。通过引入“逻辑块”(Logical Blocks)到物理块的映射表(Block Table),实现了按需动态分配显存,显著缓解外部碎片,使显存分配从“按最大长度连续预留”变成“按 block 按需分配;内部碎片被限制在序列的最后一个块中(若 block_size=16,最多浪费 15 个 Token 的空间)。
代码实例:1
2
3
4
5
6
7
8
9
10num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate(
request_id=request.request_id,
num_tokens=num_tokens_need_slot,
new_computed_blocks=new_computed_block_list,
num_encoder_tokens=num_encoder_tokens,
total_computed_tokens=num_local_computed_tokens + num_external_computed_tokens,
num_tokens_main_model=num_tokens_main_model,
)
if num_blocks_to_allocate > self.block_pool.get_num_free_blocks():
return NonePagedAttention 的执行流程与底层的 CUDA Kernel
• 执行流程:在 Attention 计算阶段,系统不再向底层算子传递一个巨大的连续张量,而是传递预先分配好的巨大显存池(K/V Block Pool)以及当前请求的 Block Table(记录了逻辑顺序到物理索引的映射)。
• Kernel 内寻址:在定制的 PagedAttention CUDA Kernel 中,GPU 线程在计算 Query 与对应位置 Key 的内积时,会动态计算该位置所在的逻辑块 logical_block_id = pos / block_size 和块内偏移 offset = pos % block_size。接着,通过查 Block Table 获取 physical_block_id,直接跨越不连续的显存地址抓取数据。全程实现零拷贝(Zero-copy)。
示意代码(说明访问过程,非本文源码原样):1
2
3
4
5
6logical_block_id = pos // block_size
offset = pos % block_size
physical_block_id = block_table[logical_block_id]
k_ptr = k_cache_pool[physical_block_id][offset]
v_ptr = v_cache_pool[physical_block_id][offset]block_size 的权衡与影响
• 过大的弊端:会增加内部碎片。例如 block_size = 256,如果一个请求刚好生成了 257 个 Token,就会占用两个物理块,第二个块浪费了 255 个 Token 的空间。
• 过小的弊端:会导致 Block Table 变得极长,增加 CPU 端的调度开销;同时在 GPU Kernel 执行时,频繁的查表和非连续内存访问会降低访存带宽的利用率。
• 工业界选择:通常设为 16 或 32,这是显存利用率与 Kernel 访存效率之间的一个最佳甜点。
示意代码(用于说明权衡,本文源码并没有直接写“block_size 应该取多少”):1
2
3
4
5
6
7
8def estimate(num_tokens: int, block_size: int) -> tuple[int, int]:
num_blocks = (num_tokens + block_size - 1) // block_size
tail_waste = num_blocks * block_size - num_tokens
return num_blocks, tail_waste
# 例如 257 个 token:
# block_size=16 -> 17 个块,尾块浪费 15
# block_size=256 -> 2 个块,尾块浪费 255二、 显存分配与调度策略
遇到 OOM 时的调度处理机制(抢占机制)
当物理块池耗尽时,vLLM 的调度器会暂停低优先级(通常是最晚到达的)请求,执行抢占(Preemption)。主要有两种策略:
• Swap(交换):将受害者请求已生成的 KV Cache 从 GPU 的 HBM 拷贝到 CPU 的系统内存中。当高优先级请求执行完毕释放出物理块后,再将数据从 CPU 换回 GPU 恢复执行。
• Recomputation(重计算):直接丢弃受害者请求在 GPU 上的 KV Cache。当系统资源充足轮到它恢复执行时,将已经生成的序列作为新的 Prompt 输入,重新经历一次 Prefill 阶段来恢复 KV Cache。
代码实例:1
2if num_blocks_to_allocate > self.block_pool.get_num_free_blocks():
return None连续批处理(Continuous Batching)中的资源分配
• vLLM 在 iteration(即生成一个 Token 的推理步)级别进行调度。调度器每次循环都会评估当前等待队列中的 Prefill 请求和正在执行的 Decode 请求。
• 调度器会计算当前所有运行中请求的 num_tokens_to_allocate,并对比 block_pool.get_num_free_blocks()。只要空闲块足够,系统就会为老请求分配 Decode 阶段需要的新块,并同时将新请求的 Prefill 任务加入到当前的 Batch 中一起计算,极大地提高了 GPU 计算单元的饱和度。
代码实例:1
2
3
4
5
6new_blocks = self.coordinator.allocate_new_blocks(
request.request_id,
num_tokens_need_slot,
num_tokens_main_model,
num_encoder_tokens,
)三、 前缀缓存 (Prefix Caching)
自动前缀缓存 (APC) 的工作原理
• 哈希匹配:vLLM 为每个 Token 序列计算哈希值。为了高效匹配,系统底层使用基数树(Radix Tree)来管理这些哈希值与物理块的对应关系。树的每个节点对应一个物理块。
• 复用条件:当新请求到来时,系统会以 block_size 为粒度计算其前缀的哈希,并在基数树中检索。如果找到完全匹配的路径,系统不会分配新的显存,而是直接将该请求的 Block Table 指向这些已存在的物理块,并将物理块的引用计数(Reference Count)加 1。
代码实例:1
2
3
4
5computed_blocks, num_new_computed_tokens = (
self.coordinator.find_longest_cache_hit(
request.block_hashes, max_cache_hit_length
)
)Prefix Caching 的驱逐与淘汰策略
• 系统采用 LRU(Least Recently Used,最近最少使用) 策略。
• 每个物理块都有一个访问时间戳。当物理显存水位过高,需要申请新块而空闲块不足时,调度器会扫描所有 ref_count == 0(即当前没有正在活跃的请求依赖这个块)的物理块,按照时间戳优先淘汰最久未被使用的块,将其回收到空闲池中。
代码实例(本文这段代码只展示“驱逐入口”,LRU 排序细节在更下层):1
2def evict_blocks(self, block_ids: set[int]) -> None:
self.block_pool.evict_blocks(block_ids)四、 进阶与复杂场景适配
Beam Search / 并行采样下的 Copy-on-Write 机制
• 内存共享:在生成初始阶段,同一个 Prompt 衍生出的多条路径会完全共享同一组 KV 物理块,此时这些块的 ref_count = N(比如并行度为 4,则计数为 4)。
• 写时复制 (CoW):当这些序列开始生成各自不同的 Token,需要修改当前所在的物理块(如果该块尚未填满)或分配新块时,系统检测到该块的 ref_count > 1。此时,调度器会向系统申请一个新的空闲物理块,将原块的数据拷贝过来,然后在这个新块上写入新的 Token,同时将原块的 ref_count 减 1。这大幅降低了 Beam Search 的显存翻倍问题。
示意代码(本文贴出的 KVCacheManager 片段没有直接展开 CoW 实现):1
2
3
4
5
6
7
8if block.ref_count > 1:
new_block = allocate_empty_block()
copy_block(src=block, dst=new_block)
block.ref_count -= 1
new_block.ref_count = 1
write_token(new_block, token)
else:
write_token(block, token)投机解码(Speculative Decoding)的槽位管理
• Lookahead 预分配:在 allocate_slots 时,不仅为当前的 Token 分配空间,还会为草稿模型预测的多个未来 Token 预留 lookahead 物理块。
• 状态隔离与回滚:草稿 Token 的 KV Cache 会被写入物理块,但它们在被目标大模型验证之前,处于“未提交”状态(不会加入到全局前缀缓存树中)。如果验证阶段某些 Token 被拒绝(Reject),调度器会调整请求的逻辑长度,直接释放被拒绝 Token 占用的物理块,实现了显存的高效回收。
代码实例(这两行能直接支撑“预留 lookahead 槽位”和“延迟写入缓存”这两个点):1
2
3
4
5
6num_tokens_need_slot = min(
num_tokens_main_model + num_lookahead_tokens, self.max_model_len
)
if not self.enable_caching or delay_cache_blocks:
return self.create_kv_cache_blocks(new_blocks)滑动窗口注意力 (Sliding Window Attention, SWA) 的适配
• 在类似 Mistral 这样采用 SWA 的模型中,由于当前 Token 只与过去窗口大小为 W 的历史 Token 计算注意力,超过窗口的旧 Token 实际上已经不需要参与计算了。
• vLLM 的 KVCacheManager 提供了一个 remove_skipped_blocks 机制。当请求的生成长度超过 W 时,调度器会识别出那些由于滑动窗口前进而彻底失效的逻辑块,并将其对应的物理块引用解除。如果该物理块没有被其他前缀缓存请求依赖,就可以提前被释放并放回内存池,使该请求的显存占用维持在一个恒定的上限。
代码实例:1
2
3
4def remove_skipped_blocks(
self, request_id: str, total_computed_tokens: int
) -> None:
self.coordinator.remove_skipped_blocks(request_id, total_computed_tokens)block 是什么时候分配的?
结合前面的allocate_slots()来看,block 不是请求一进来就一次性全部分完,而是在 prefill / decode 推进过程中按需分配。函数会先根据当前已经计算的 token、这一步新增的 token、以及num_lookahead_tokens计算num_tokens_need_slot,再调用get_num_blocks_to_allocate()和allocate_new_blocks()判断并申请真正需要的新 block。logical token 怎么映射 physical block?
逻辑上先把 token 位置映射到逻辑块,再通过 Block Table 找到对应的 physical block。本文源码虽然没有展开 kernel 本体,但KVCacheBlocks.get_block_ids()暴露的就是“当前请求对应哪些物理块 ID”,这正是调度层把逻辑顺序交给底层物理块寻址的桥梁。request 结束后怎么回收?
request 结束后,不是简单把一大片连续显存直接删掉,而是把它持有的 physical blocks 归还给底层 block pool。你前面“代码说明”里提到的free(request)就是在做这件事;如果某些 block 仍被 Prefix Cache 或其他请求共享,那么它们会等到底层引用关系解除后再进入真正可回收状态。attention kernel 怎么读 KV?
PagedAttention 的关键就是 kernel 不要求 K/V 连续存放。它会先根据 token 位置算出logical_block_id和块内offset,再通过 Block Table 找到physical_block_id,最后去对应 block 的 offset 位置取 K/V。也正因为如此,vLLM 才能把 KV Cache 拆成分页块来管理,而不是强依赖一整段连续显存。为什么 Prefix Caching 通常按 block 粒度命中,而不是按 token 粒度命中?
从本文源码看,KVCacheBlocks、get_block_ids()、get_unhashed_block_ids()暴露的核心对象都是 block,而get_computed_blocks()也是基于request.block_hashes去查命中。因此工程上的复用单位天然就是 block:这样可以直接复用整块物理 KV,调度、引用计数和回收路径都更简单;如果细到 token 粒度,元数据和访存组织会复杂很多。为什么前缀缓存通常只能复用“完整前缀”,而不是中间任意一段?
因为get_computed_blocks()做的是“从当前请求开头开始找最长 cache hit”,也就是从序列起点往后匹配连续前缀。Transformer 的 KV 本身也是按时间顺序累积的,所以最稳定的复用方式是“从开头开始的连续公共前缀”,而不是把中间某一段单独剪出来拼接。num_new_computed_tokens和num_new_tokens的区别是什么?
这两个量在allocate_slots()里是分开处理的。num_new_computed_tokens表示通过 Prefix Cache 新命中的 token 数,它们已经有现成 KV,会进入num_local_computed_tokens;num_new_tokens则表示当前这一步真正还要新增槽位、参与本轮计算或生成的 token 数。前者是“命中复用”,后者是“实际新增”。为什么
allocate_slots()里要区分 local computed 和 external computed?
因为源码里total_computed_tokens是由num_local_computed_tokens + num_external_computed_tokens一起算出来的。这说明 vLLM 允许一部分 KV 不是主模型在当前进程里一步步算出来的,而是由外部组件提前提供的,比如多模态 encoder。这样容量判断和 block 分配时,才能把这部分上下文也算进去。为什么源码里要有
full_sequence_must_fit这种 admission check?
因为有些场景不能接受“先跑起来,跑到一半再发现后面放不下”。allocate_slots()里会在这个开关打开时,先估算整段序列需要多少 block,再和get_num_free_blocks()比较。这样可以减少中途 OOM 或频繁抢占,换来更稳定的时延表现。delay_cache_blocks的作用是什么?
从if not self.enable_caching or delay_cache_blocks: return ...这段逻辑可以看出,它表示“这轮先分到 block,但先不要把这些 block 固化进 Prefix Cache”。这在投机解码里很重要,因为 lookahead 里的草稿 token 还没有被最终确认,过早写入缓存会污染后续请求的命中结果。Prefix Caching 为什么不等于“所有重复请求都能加速”?
因为get_computed_blocks()依赖的是request.block_hashes的严格匹配,而不是“看起来差不多”就能复用。只要 system prompt、模板、特殊 token 或前处理插入字段不一样,block hash 就会不同,最终就不会命中。所以它复用的是“严格一致的前缀 block”,不是“语义相似的 prompt”。Prefix Caching 的收益为什么通常主要体现在 TTFT,而不是 decode 吞吐?
从源码流程上看,get_computed_blocks()是在请求进入时先查前缀命中,命中后直接跳过前面那一大段本该做的 prefill 计算,所以最直接改善的是首 token 前的等待时间。decode 阶段每轮只新增很少 token,因此 Prefix Cache 对 decode 吞吐的改善通常没有对 prefill 那么显著。为什么说 Continuous Batching 和 KV Cache 是强耦合的?
因为 Continuous Batching 想让不同请求在不同 iteration 上动态进出 batch,就必须让每个请求的上下文能被低成本保存、扩展和回收。本文allocate_slots()做的正是这件事:算 block 需求、清理无效 block、申请新 block、必要时延迟固化到缓存。没有这套分页式显存管理,动态合批的收益会被显存管理成本吃掉。如果两个请求前缀相同,但采样参数不同,还能复用同一份前缀 KV 吗?
通常可以。因为本文讨论的 Prefix Caching 命中条件来自输入 token 序列对应的block_hashes,而不是 temperature、top-p、top-k 这类采样参数。采样参数影响的是“后续选哪个 token”,不会改变已经存在的前缀 token 的 K/V 内容。面试里如果被问“KV Cache 真正占显存的主要矛盾是什么”,怎么答?
可以结合本文allocate_slots()的参数来答:真正决定显存压力的,不只是“开没开 cache”,而是“总上下文长度、并发请求数、每轮新增 token、lookahead 预留、外部已计算 token”等因素叠加后的 block 需求。vLLM 的核心价值不是凭空减少理论 KV 体积,而是用 PagedAttention 把这些 KV 变成“可分页、可复用、可延迟固化、可及时回收”的资源,从而让同样的显存承载更多真实请求。
代码说明
这段代码是 vLLM v1 架构中核心的显存管理层实现。它位于调度器(Scheduler)和底层物理显存池(Block Pool)之间,主要负责 PagedAttention 机制下的显存块分配、回收,以及 Prefix Caching(前缀缓存)功能。
代码主要由两个类构成,下面按模块拆解。
1. KVCacheBlocks:调度器与管理器的隔离层
这是一个数据类,本质上是对物理显存块列表的封装,用来把 KVCacheManager 内部的数据结构隐藏起来,只向上层调度器暴露稳定接口。
- 设计目的:作为 Scheduler 的返回对象,避免调度器直接接触内部 block pool 的复杂结构。
- 多组支持:
blocks是一个元组,支持多个kv_cache_group。这样可以兼容未来不同组采用不同block_size的架构。 - 核心功能:
get_block_ids:把对象转换成物理块 ID 列表,方便调度器使用。get_unhashed_block_ids:返回尚未进行哈希、也就是还没有进入前缀缓存管理的块 ID。get_unhashed_block_ids_all_groups:按组返回未哈希块 ID,并跳过 padding block。
2. KVCacheManager:缓存与分配的控制中枢
这是管理 KV Cache 生命周期的大脑。它不仅处理常规的生成请求,还兼容投机解码(Speculative Decoding)、多模态外部缓存接入等复杂场景。
3. 核心机制一:前缀缓存匹配 get_computed_blocks
当系统启用了 enable_caching 时,这个方法会在处理新请求前被调用。
- 它会根据请求的
block_hashes,去底层 coordinator 里查找是否已经存在可复用的 KV Cache。 - 典型场景是 System Prompt 复用或多轮对话。如果命中,就直接返回已经缓存好的物理块列表,以及命中的 token 数量。
- 这样可以跳过对应前缀的 Attention 计算,从而显著降低首字延迟(TTFT)。
4. 核心机制二:复杂的槽位分配 allocate_slots
这是整个类里最核心、也最复杂的函数。它负责为一个请求在当前推理步申请物理显存块。代码中把请求在显存中的逻辑布局划分得很细,可以理解成下面几段:
- 已经计算或缓存的部分:
comp:当前请求之前已经计算完的 token。new_comp:刚刚通过前缀缓存匹配到的 token。ext_comp:由外部组件(比如多模态 connector)计算并传入的 token。
- 需要新分配空间的部分:
new:当前步骤即将输入或生成的新 token。lookahead:专门为投机解码预留的额外槽位,用来存放模型一次性预测出的多个未来草稿 token。
分配流程可以概括为四步:
- 容量预检:如果设置了
full_sequence_must_fit,会提前计算该请求直到最大长度所需的总块数。如果系统当前空闲块不足,就直接拒绝分配,防止请求中途 OOM。 - 清理废弃块:调用
remove_skipped_blocks。如果模型使用了滑动窗口注意力,超出窗口范围的旧 token 占用的块会被提前释放。 - 物理分配:通过
coordinator.allocate_new_blocks向系统申请真正的物理块。 - 状态固化:将当前已经确认无误的 token,尤其是排除了投机解码中可能被拒绝的草稿 token 后的部分,推入前缀缓存池,供后续请求复用。
5. 核心机制三:资源回收与维护
free(request):当请求生成完毕或被中止时,将其持有的所有物理块交还给底层池。evict_blocks(block_ids):强制从前缀缓存中驱逐指定块,通常用于缓存池满了之后执行最近最少使用(LRU)式淘汰。get_num_common_prefix_blocks:统计当前正在运行的所有请求共享了多少个前缀块,这对于调度策略优化和缓存效率评估非常关键。
kvcache的特点
KV cache 本身并不会减少单个 token decode 时必须读取的历史 K/V 总量。PagedAttention 主要解决的是显存管理问题:减少预分配浪费、支持动态 batch、支持 prefix cache 复用和请求级回收。
decode 阶段仍然容易 memory-bound,因为每生成一个 token 都要读取历史 K/V,访存量随 seq_len 增长。PagedAttention 通过更好的显存管理提高并发承载能力,但 block table 间接寻址也会带来一定 kernel 访问开销。vLLM 的设计是在显存利用率、动态调度能力和 attention kernel 访存效率之间做权衡。
- Title: vllm源码学习
- Author: Ikko
- Created at : 2026-05-04 13:57:24
- Updated at : 2026-05-04 21:30:11
- Link: http://ikko-debug.github.io/2026/05/04/vllm-1/
- License: This work is licensed under CC BY-NC-SA 4.0.