1. 引言为什么我们需要一个“块管理器”如果你用过vLLM来部署大模型推理服务大概率听说过它“吞吐量高”、“显存利用率好”这些优点。但你是否想过当几百上千个用户的请求同时涌来时vLLM是如何在有限的GPU显存里有条不紊地为每个请求分配空间、执行计算甚至还能在显存不足时把一些数据临时“请”到CPU内存里去的这背后的大功臣就是我们今天要深入剖析的块管理器BlockManager。你可以把GPU显存想象成一个巨大的、格子大小固定的储物柜比如每个格子能放16件物品。每个用户的对话在vLLM里叫一个Sequence都需要占用一些格子来存放对话过程中产生的“记忆”也就是KV Cache。块管理器就是这个储物柜的超级管理员。它的核心工作就三件事1. 谁来用哪个格子分配2. 格子用完了怎么办调度3. 格子不够了怎么把不常用的东西暂时挪到别处交换原始文章已经为我们勾勒出了块管理器的轮廓但很多细节就像藏在代码深处的宝藏值得我们去挖掘。比如PhysicalTokenBlock这个“格子”对象到底承载了什么信息Cached和Uncached两种分配器在实际运行中有什么区别调度器Scheduler又是如何与块管理器“打配合”做出prefill、decode还是swap的决策的这篇文章我将结合自己实际使用和调试vLLM的经验带你从物理块的诞生开始一步步拆解这个核心枢纽的运作机制让你不仅明白它“是什么”更清楚它“为什么”要这么设计以及在实际部署中可能会遇到哪些“坑”。2. 物理块KV Cache的抽象与载体2.1 PhysicalTokenBlock不只是个编号在vLLM的调度世界里我们常说的“块”Block并不是直接指代GPU上那一片存储着KV Cache的物理内存。那块真实的内存由CUDA管理对Python层是“黑盒”。vLLM创造了一个精巧的抽象——PhysicalTokenBlock类来代表这片内存的状态和管理单元。我们来看看这个类的核心字段这能帮你理解它的设计意图# vllm/block.py 简化版 class PhysicalTokenBlock: def __init__(self, device, block_number, block_size, block_hash, num_hashed_tokens): self.device device # 设备GPU还是CPU self.block_number block_number # 关键这块内存的全局索引号 self.block_size block_size # 块容量默认16个token位置 self.block_hash block_hash # 用于前缀共享缓存非此场景为-1 self.num_hashed_tokens num_hashed_tokens # 计算hash的token数 self.ref_count 0 # 引用计数有多少个Sequence的逻辑块指向它 self.last_accessed -1 # 最后访问时间用于缓存淘汰策略 self.computed False # 该块的KV值是否已计算完成用于前缀缓存这里最需要理解的是block_number和ref_count。block_number是这块物理内存在其所属设备GPU或CPU上的唯一身份证。调度器所有关于“把数据放到第几号块”、“从第几号块读取数据”的操作最终都是通过这个编号来定位真实内存的。而ref_count则是实现内存共享和高效回收的关键。想象一下多个用户的请求有着完全相同的系统提示词比如“你是一个有用的助手”它们的KV Cache完全可以存在同一个物理块里这时ref_count就会大于1。只有当所有引用它的逻辑块都释放了ref_count减到0这个物理块才会被放回空闲池等待下次分配。所以PhysicalTokenBlock本身不存储数据它是一份“元数据”或“管理清单”。这就像仓库管理员手里的库存表表上记录着“A区-03号货架当前存放了商品X被订单Y和Z共用”。真正的货物KV Cache在货架上而管理员通过这张表来高效调度货物存取。2.2 BlockTable序列的“内存地图”单个物理块是存储单元那么一个完整的对话序列Sequence的KV Cache可能跨越多个块如何记录这种映射关系答案就是BlockTable。BlockTable在代码中就是一个List[PhysicalTokenBlock]。它按顺序记录了一个Sequence当前所有已占用的物理块。假设一个序列已经生成了35个token块大小block_size为16那么它就需要3个物理块。它的BlockTable就是一个包含3个PhysicalTokenBlock对象的列表分别对应存放第1-16、17-32、33-35个token的KV Cache的物理块。块管理器内部维护了一个全局字典block_tables: Dict[int, BlockTable]键是seq_id序列全局唯一ID值就是该序列的BlockTable。通过这个字典调度器可以快速找到任何一个序列的KV Cache分布在哪些物理块上这是执行注意力计算和块调度的基础。3. 块分配器两种策略两种哲学块管理器BlockSpaceManager手下有两员大将gpu_allocator和cpu_allocator。它们负责具体的“分配”与“释放”动作。而根据是否启用前缀缓存enable_prefix_caching这两员大将又有两种不同的“人格”UncachedBlockAllocator和CachedBlockAllocator。这是理解vLLM内存管理优化的关键分水岭。3.1 UncachedBlockAllocator简单直接的“物业经理”这是默认模式也是最容易理解的模式。它的工作方式非常直观初始化根据GPU/CPU的总块数num_blocks预先创建好对应数量的PhysicalTokenBlock对象全部放入free_blocks列表。这就像物业经理拿到一栋新楼的所有空房间钥匙。分配allocate当有序列申请块时直接从free_blocks列表末尾pop()出一个块将其ref_count设为1然后返回。简单粗暴。释放free当一个块的ref_count被减到0时就把它append()回free_blocks列表等待下次分配。这种模式不关心块里具体存了什么内容。它只负责“出借”和“收回”房间号。任何两个序列即使它们的提示词一模一样也会被分配到不同的物理块造成显存浪费。它的优点是逻辑极其简单没有额外开销。3.2 CachedBlockAllocator精打细算的“空间魔术师”当启动参数中设置enable_prefix_cachingTrue时就会启用这个更复杂的分配器。它的核心思想是内容相同的KV Cache应该共享同一块物理内存。这主要针对常见的场景大量请求共享相同的系统提示词Prefix。比如所有用户对话都以“你是一个由XX公司开发的AI助手...”开头。在Uncached模式下每个请求都会为这段相同的提示词分配独立的物理块重复存储上百份相同的KV Cache。而在Cached模式下系统会为这段提示词的内容计算一个哈希值block_hash。第一个遇到此提示词的请求会分配一个物理块并将哈希值存入block_hash字段。后续具有相同提示词的请求在分配时CachedBlockAllocator会先检查是否有block_hash匹配且已计算完成computedTrue的物理块。如果有就直接将该块的引用返回并增加其ref_count避免了重复计算和存储。这带来了巨大的显存节省但代价是管理逻辑变得复杂哈希计算与匹配需要为每个块或块组计算并存储哈希。状态管理需要区分一个块是“已计算完成可共享”还是“正在计算中”。淘汰策略当需要空间时不能随意淘汰一个ref_count0的共享块。last_accessed字段这时就派上用场了可以结合LRU等策略淘汰那些最近最少使用的、且可释放的缓存块。在实际生产环境中如果你的服务场景有大量相似或固定的提示词开启前缀缓存能显著提升服务容量。我实测过一个客服场景启用后同等显存下的并发数提升了近40%。但要注意它引入了额外的哈希计算和查找开销在提示词高度随机的场景下收益可能不明显甚至因为管理开销而略有性能下降。4. 与调度器的协同决策、分配与流转块管理器不是孤立工作的它紧密嵌入在调度器Scheduler的决策循环中。调度器在每个调度周期都会询问块管理器“现在这个情况我能做什么” 块管理器的回答直接决定了请求的生命周期状态切换。4.1 Prefill阶段入场资格审核当一个新请求SequenceGroup到达并处于WAITING状态时调度器的_schedule_prefills方法会调用block_manager.can_allocate(seq_group)。这个方法做了以下几件事计算该请求的提示词需要多少个物理块num_required_blocks ceil(prompt_length / block_size)。查询GPU分配器当前有多少空闲块num_free_gpu_blocks。应用一个水位线watermark机制。水位线例如默认是总块数的1%是一道保险丝目的是避免GPU块被完全耗尽导致没有空间进行后续的解码和必要的调度操作。决策逻辑如下表所示条件判断返回状态调度器行为总块数 - 所需块数 水位线块数NEVER请求被标记为永远无法分配通常意味着提示词过长直接失败或拒绝。空闲块数 - 所需块数 水位线块数OK立即为请求分配块进入RUNNING状态开始Prefill。以上都不满足LATER当前空间不足但未来可能有。请求保持在WAITING队列等待。只有返回OK时调度器才会调用block_manager.allocate(seq_group)进行实际分配。分配过程就是在block_tables全局字典中为这个seq_group下的每个Sequence创建一条记录并从gpu_allocator的free_blocks中取出对应数量的物理块填入它们的BlockTable。4.2 Decode阶段细水长流的空间保障请求完成Prefill进入RUNNING状态开始逐个生成token解码。在_schedule_running中调度器需要判断当前是否有足够空间让这个请求继续生成下一个token这里调用的是block_manager.can_append_slots(seq_group)。它的逻辑与can_allocate不同更加动态和保守所需块数估算解码时每个Sequence每步产生1个新token。最坏情况下每个Sequence的最后一个块都满了生成新token就需要为每个Sequence分配一个新块。因此最坏所需块数等于该seq_group中处于RUNNING状态的Sequence数量num_seqs。决策只要当前GPU空闲块数num_free_gpu_blocks num_seqs就返回True。这是一种“至少保证一个”的启发式策略确保每个序列至少能往前走一步避免某个序列因块不足而完全卡住。如果允许追加调度器会调用block_manager.append_slots(seq)。这里有一个精妙的写时复制Copy-on-Write机制检查该序列最后一个物理块的ref_count。如果ref_count 1说明这个块是此序列独享的可以直接往里面写入新token的KV Cache。如果ref_count 1说明这个块被多个序列共享可能来自前缀缓存。此时直接写入会污染其他序列的数据。于是系统会触发“写时复制”分配一个新的物理块将共享块的内容拷贝过来如果需要然后让当前序列指向这个新块并减少原共享块的引用计数。这样既保证了数据正确性又实现了内存共享。4.3 Swap机制显存的弹性伸缩当GPU显存紧张即有很多请求在等待WAITING或解码无法继续can_append_slots返回False时调度器就会考虑将一些暂时不活跃的RUNNING请求“交换出去”swap out。Swap Out从GPU到CPU在_schedule_running中如果系统决定要交换出一个请求会调用block_manager.swap_out(seq_group)。这个过程是遍历该请求所有序列的BlockTable。对于表中的每一个GPU物理块通过cpu_allocator在CPU内存中分配一个对应的块。将GPU块中的KV Cache数据异步拷贝到CPU块。更新block_tables将序列的映射从GPU块改为CPU块。释放freeGPU上的物理块使其回到空闲池。将该请求的状态置为SWAPPED。Swap In从CPU回到GPU当GPU又有空闲资源时在_schedule_swapped中调度器会调用block_manager.can_swap_in(seq_group)判断能否换入。这里的计算比can_allocate更复杂len(blocks): 该请求原来占用的总块数即KV Cache总量。num_swapped_seqs: 该请求中待恢复的序列数。num_required_blocks len(blocks) num_swapped_seqs这个公式意味着换入一个请求不仅要能装下它已有的全部KV Cache还要为它接下来的解码预留至少每个序列一个块的空间最坏情况。同样这个需求也必须满足水位线要求。如果允许换入block_manager.swap_in(seq_group)会执行反向操作将CPU块的数据拷贝回GPU并更新映射。交换的代价Swap操作涉及GPU与CPU之间的数据拷贝这是非常耗时的PCIe总线操作。频繁的Swap会严重拖慢推理速度。因此水位线的设置、调度策略优先交换哪些请求就变得至关重要。在实践中你需要根据你的工作负载请求长度、并发数和硬件配置仔细调整block_size、gpu_memory_utilization影响总块数和swap_spaceCPU交换空间等参数在吞吐量和延迟之间找到平衡点。5. 实战从配置到问题排查理解了原理我们来看看如何在实际中使用和调整块管理器。5.1 关键配置参数解析启动vLLM服务时以下参数直接影响块管理器的行为--block-size物理块的大小即每个块能容纳的token数。默认是16。增加此值如32会减少块的数量和管理开销但可能导致内部碎片最后一个块未用满的空间浪费增加尤其对于短序列不友好。减少此值则相反。通常保持默认即可。--gpu-memory-utilization设定可用于KV Cache的GPU显存比例。默认0.9。它决定了num_total_gpu_blocks。如果你的模型权重很大或者需要为其他操作留出空间可以适当调低。--swap-space指定用于交换的CPU内存大小以GiB为单位。默认是GPU KV Cache空间的2倍。如果你的请求长度很长或并发很高可能需要调大这个值防止交换空间不足。--enable-prefix-caching是否启用前缀缓存。默认False。如前所述在提示词重复性高的场景下强烈建议开启。--max-num-seqs调度器一次处理的最大序列数。这个参数间接影响了块管理器的压力。设置过小会限制吞吐设置过大会增加调度复杂度和内存碎片。一个典型的启动命令可能如下python -m vllm.entrypoints.api_server \ --model meta-llama/Llama-3.2-1B-Instruct \ --block-size 16 \ --gpu-memory-utilization 0.85 \ --swap-space 16 \ --enable-prefix-caching \ --max-num-seqs 2565.2 常见问题与调试技巧即使理解了原理在实际部署中你还是可能会遇到一些棘手问题。这里分享几个我踩过的“坑”和排查思路问题一吞吐量不达预期GPU利用率低。可能原因Swap过于频繁。每次Swap都涉及大量的数据搬运会造成GPU计算等待利用率出现周期性低谷。排查使用vllm.engine.metrics或nvidia-smi监控GPU显存波动和PCIe带宽。如果发现显存使用率在高低位剧烈震荡同时swapped状态的请求很多就是Swap太频繁。解决尝试降低--gpu-memory-utilization给解码预留更多空间减少触发Swap的压力。优化调度策略如果使用自定义调度器例如优先交换那些总长度长、但近期生成速度慢的请求。如果请求长度普遍很长考虑增加--swap-space并确保CPU内存充足且速度够快如使用NVMe SSD做虚拟内存但vLLM原生不支持需深度定制。问题二长序列推理后期速度变慢。可能原因BlockTable变得非常长在append_slots时遍历查找、以及在实际的注意力计算中索引块的开销增大。排查对比短序列和长序列的每一步解码延迟。如果延迟随着序列长度线性增长可能是这个问题。解决这更接近模型计算内核的优化范畴。但对于块管理器可以确认是否因ref_count1触发了频繁的Copy-on-Write。如果是共享前缀导致可以评估关闭前缀缓存是否对长序列场景更有利用空间换时间。问题三开启前缀缓存后出现奇怪的内容重复或错误。可能原因哈希冲突。虽然概率极低但不同的提示词内容可能计算出相同的block_hash导致KV Cache被错误共享。排查在确定性场景下复现问题比较开启和关闭前缀缓存时的输出差异。解决vLLM使用的哈希算法通常是可靠的。如果怀疑可以尝试在代码中增加更严格的匹配检查例如不仅比较哈希还抽样比较前几个token的嵌入向量但这会牺牲性能。通常首先应检查你的提示词是否真的“相同”注意空格、换行符等不可见字符的差异。调试时一个非常有用的方法是打印块管理器的状态。你可以修改vLLM源码在关键决策点如can_allocate,swap_out打印当前的num_free_gpu_blocks、watermark_blocks、各状态请求数量等信息。这能帮你直观地看到调度决策是如何做出的。块管理器是vLLM高效推理的基石它通过精细的抽象和协同调度将有限的物理内存转化为高效的并发计算能力。理解它不仅能帮助你在使用vLLM时做出更合理的配置更能让你在遇到性能瓶颈时有的放矢地进行深度优化。希望这篇深入的剖析能让你在驾驭这个大模型推理利器时更加得心应手。