vLLM V1 KV Cache Manager 源码学习

本文为vLLM V1源码学习系列的第二篇文章,往篇如下,

  1. vLLM V1 Scheduler 源码学习

引言

在了解了Schedule的基本工作原理后,我们也需要知道Scheduler是如何获取KV cache所需的GPU memory的。

具体而言vLLM结合BlockPool,KVCacheManager以及基于块内容的哈希来实现底层支持,涉及如下三个文件:

Block Pool

我们知道vLLM项目的起始论文为 Paged Attention (Efficient Memory Management for Large Language Model Serving with PagedAttention),其核心即为把一个请求拆分为固定大小的块(例如,16个token),每个块对应的KV cache(称为KV cache block,后称Block)单独管理,进而类似操作系统中的页管理实现对GPU memory的高效利用。而BlockPool就是核心管理组件,决定分配、释放和缓存Block。

除了表示Block数量和是否使用prefix caching的self.num_gpu_blocksself.enable_caching。其核心成员变量为self.blocksself.free_block_queueself.cached_block_hash_to_block

self.blocks

在实例化BlockPool时,vLLM就会创建一个self.num_gpu_blocks大小的列表。

python 复制代码
        # All kv-cache blocks.
        self.blocks: list[KVCacheBlock] = [
            KVCacheBlock(idx) for idx in range(num_gpu_blocks)
        ]

其中每个元素为KVCacheBlock(实现在kv_cache_utils.py中),包含一个Block的编号,哈希值和引用计数(用于释放Block)。

python 复制代码
class KVCacheBlock:
    """KV-cache block metadata."""
    # Block ID, ranging from 0 to num_gpu_blocks - 1.
    block_id: int
    # Reference count.
    ref_cnt: int = 0
    # The hash of the block composed of (block hash, tuple of token IDs).
    # It is only available when the block is full.
    _block_hash: Optional[BlockHashType] = None

    # Used to construct a doubly linked list for free blocks.
    # These two attributes should only be manipulated by FreeKVCacheBlockQueue.
    prev_free_block: Optional["KVCacheBlock"] = None
    next_free_block: Optional["KVCacheBlock"] = None

self.free_block_queue

FreeKVCacheBlockQueue(一个双向链表)的一个实例,链表的头部为最优先释放的Block。 在KVCacheBlock中对应的变量,即为prev_free_blocknext_free_block

self.cached_block_hash_to_block

记录缓存了的完整的块的字典,结构为{block_hash: {block ID: block}}。只有一个块满了(达到了Block Size个token)才会被缓存。缓存的Block可能是正在执行的请求所需要的,也可能是已完成请求的(可以被释放)。注意vLLM不会检查是否存在相同的Block,因为vLLM不希望分配的Block编号发生变化(Block表是仅追加的,不会修改原本的元素),例如两个相同的请求被同时执行,这两个请求的第一个块,被分配了Block 0和Block 1,此时内存中就出现了两个相同的Block但是ID不同。

Important methods

  1. cache_full_blocks:根据一个请求涉及的完整Block缓存Block。
  2. get_new_blocks:从空闲块池中获取指定数量的新块,
    • 如果请求的块数量超过了空闲块的数量,则引发 ValueError
    • 需要确保弹出的每个块的引用计数为 0(表示它是空闲的);
    • 如果启用了缓存,则调用 self._maybe_evict_cached_block 来尝试驱逐可能缓存的当前块;
    • 增加每个分配的块的引用计数 (incr_ref)。
  3. _maybe_evict_cached_block:如果一个块在 cached_block_hash_to_block 中被缓存,则重置其哈希元数据并将其从缓存中移除。
  4. touch: "触摸"一个或多个块,
    • 增加它们的引用计数,并可能将它们从空闲队列中移除;
    • 当具有相同前缀的另一个请求命中一个块时,会使用此方法。 如果块的引用计数为 0 并且不是 self.null_block,则将其从 self.free_block_queue 中移除(因为它现在被使用了,不再是驱逐候选者);
    • 更新块的引用计数。
  5. free_blocks:尝试释放一个块列表。具体操作是,
    • 遍历输入的块列表减少引用计数;
    • 如果块的引用计数变为 0 并且不是 self.null_block,则将其添加回 self.free_block_queue,使其成为空闲块并可以被再次分配或驱逐。
  6. reset_prefix_cache:重置前缀缓存。
    • 检查当前已使用的块数量(不包括 null_block)。如果除了 null_block 之外还有其他块被使用,则发出警告并返回 False,因为在重置缓存之前应该释放所有相关的块。
    • 清空 self.cached_block_hash_to_block,移除所有已缓存的块哈希,这样新的请求就不会命中任何缓存。
    • 遍历所有块,并调用 block.reset_hash() 清除每个块的哈希相关属性。
    • 记录成功重置缓存的信息并返回 True

KV Cache Manager

self.num_preallocate_tokens:每个请求预先分配的 token 数量并计算 num_preallocate_blocks(预先分配的块数量)。

self.block_pool:创建BlockPool 的实例来管理 GPU 块。

self.specialized_manager: 使用 get_specialized_manager 基于 KV 缓存规范和块池获取一个专门的管理器。

self.req_to_blocks:一个 defaultdict(list),用于将每个请求 ID 映射到为该请求分配的 KVCacheBlock 对象列表。这用于跟踪哪些块属于哪个请求,以便稍后释放它们。

self.req_to_block_hashes:一个 defaultdict(list),用于将每个请求 ID 映射到预先计算的块哈希值列表。这用于高效地查找前缀缓存。

self.num_cached_block:一个字典,用于跟踪每个正在运行的请求的完全缓存的块数量。

self.prefix_cache_stats:一个 PrefixCacheStats 的实例,用于收集关于前缀缓存的统计信息,是否统计取决于 log_stats 是否启用。

Important methods

  1. get_computed_blocks :这是前缀缓存机制的核心。它尝试查找与当前请求的提示开头匹配的先前计算并缓存的 KV 缓存块。

    • 如果 enable_cachingFalse,则立即返回一个空列表和 0 个已计算的 token。
    • self.req_to_block_hashes 中检索请求的预计算 block_hashes。如果尚未计算,则使用 hash_request_tokens 计算它们。
    • 查找最长缓存命中: 调用 self.specialized_manager.find_longest_cache_hit 并传入 block_hashes,以找到缓存中存在的最长连续块序列。
    • 处理边界情况: 如果提示长度可以被块大小整除并且所有块最初都被缓存,它会临时删除最后一个块哈希,以强制重新计算最后一个块。这是因为 allocate_slots 期望 num_computed_tokensblock_size 的倍数。这是一个已知的限制。
    • 更新 prefix_cache_stats.queriesprefix_cache_stats.hits
    • 计算 num_computed_tokens(始终是 block_size 的倍数,因为只有完整块才被视为可缓存的)。
    • 返回 computed_blocks 列表和 num_computed_tokens
  2. allocate_slots:为给定的请求分配新的 KV 缓存块,以容纳新的 token。

    • 接受 request、要分配的 num_tokens 数量以及可选的 new_computed_blocks 列表(来自前缀缓存命中)。
    • 块布局可视化: 提供了一个有用的图示,说明了涉及的不同类型的块(已计算、新计算、新分配、预分配)。
    • 错误处理: 如果 num_tokens 为 0,则引发 ValueError
    • 如果 new_computed_blocksNone,则初始化它。
    • self.req_to_blocks 中检索请求的现有块。
    • 释放跳过的块: 调用 self.specialized_manager.remove_skipped_blocks 来释放不再需要的块(例如,由于滑动窗口注意力)。在分配新块之前执行此操作可以减少块的驱逐。
    • 通过添加来自 new_computed_blocks 的 token 来更新 num_computed_tokens
    • 基于 token 总数(已计算 + 新分配)和 block_size 计算 num_required_blocks(所需的块数)。
    • 计算所需的 num_new_blocks(新块数)。
    • 检查是否有足够的空闲块: 它检查 block_pool 中是否有足够的空闲块(考虑到引用计数为 0 的已计算块可能是驱逐的候选者)。如果空闲块不足,则返回 None,表示分配失败。
    • 标记已计算的块: 如果启用了缓存,则调用 self.block_pool.touch 来标记 new_computed_blocks 为最近使用,以防止它们被驱逐。
    • new_computed_blocks 添加到 req_blocks 中。
    • 分配新块: 如果 num_new_blocks 为正数,则从 block_pool 获取新块(考虑预分配和每个请求的最大块数)。
    • 将新分配的 new_blocks 添加到 req_blocks 中。
    • 缓存完整块: 如果启用了缓存,则调用 self.block_pool.cache_full_blocks 将完全使用的块标记为可缓存,并将它们与请求及其哈希值关联起来。它只缓存生成的(已接受的)token 的块,而不缓存推测的 token 的块。
    • 更新请求的 self.num_cached_block
    • 返回分配的 new_blocks 列表。
  3. free

    • 释放与已完成或中止的 request 关联的 KV 缓存块。
    • self.req_to_blocks 中检索请求的块列表并删除该条目。
    • 如果启用了缓存,则按相反的顺序释放块。这种策略优先释放最近添加的块,因为这些块不太可能是公共前缀的一部分。
    • 调用 self.block_pool.free_blocks 将块释放回块池。
    • self.num_cached_block 中删除请求的条目。
  • reset_prefix_cache 方法:

    • 重置由 BlockPool 管理的前缀缓存。
    • 如果 BlockPool 成功重置,则将 self.prefix_cache_stats.reset 设置为 True 并返回 True。否则,返回 False
  • get_num_common_prefix_blocks 方法:

    • 计算当前所有处于 RUNNING 状态的请求共享的公共前缀块的数量。
    • 它遍历给定 request(必须处于 RUNNING 状态)的块。
    • 如果一个块在 BlockPool 中的引用计数 (ref_cnt) 等于 num_running_requests(当前正在运行的请求总数),则该块被认为是公共前缀块。这表明所有正在运行的请求当前都引用了这个块。
    • 一旦遇到不是所有正在运行的请求共享的块,它就会停止计数。
    • 重要提示: 该函数承认一个边界情况,即即使所有 已调度 的请求共享一个前缀,报告的公共前缀块数量也可能为 0。这可能是因为存在其他未调度的 RUNNING 请求不共享相同的前缀。目前,这种情况不容易检测到。
  • free_block_hashes 方法:

    • 丢弃与已完成的 request 关联的预计算块哈希值。
    • 当一个请求完全处理完毕并且不再需要其缓存的前缀信息时,会调用此方法。重要的是只对已完成的请求调用此方法,而不是对被抢占的请求调用,因为被抢占的请求可能会稍后恢复,并且它们的块哈希值可能仍然有用。
相关推荐
AIGC大时代5 分钟前
使用DeepSeek的AIGC的内容创作者,如何看待陈望道先生所著的《修辞学发凡》?
人工智能·chatgpt·aigc·智能写作·deepseek·aiwritepaper
刘大猫267 分钟前
Arthas stack (输出当前方法被调用的调用路径)
java·人工智能·数据分析
CoderJia程序员甲22 分钟前
KrillinAI:视频跨语言传播的一站式AI解决方案
人工智能·ai·大模型·音视频·短视频
北京天拓四方28 分钟前
当纺织车间遇上“数字魔法”--天拓四方飞鸟物联平台+边缘计算采集网关的智造革命
人工智能
CodeJourney.34 分钟前
DeepSeek与ECharts融合助力复杂图表高效制作
数据库·人工智能·算法·excel
绝顶大聪明42 分钟前
[图像掩膜,ROI切割] 图像预处理(OpenCV)-part4
人工智能·opencv·计算机视觉
Y1nhl1 小时前
搜广推校招面经七十六
人工智能·pytorch·深度学习·推荐算法·搜索算法
AI智能科技用户7946329781 小时前
okcc呼叫中心两个sip对接线路外呼任务怎么设置才能一个任务对应yigesip中继?
人工智能·后端
火山引擎边缘云1 小时前
开启报名!火山引擎 x PICO-全国大学生物联网设计竞赛赛题发布
人工智能·物联网·aigc
机器学习Zero1 小时前
自然语言处理(9)—— 共现词矩阵及Python实现
人工智能·python·自然语言处理·nlp