引言
KV cache的管理是vLLM框架最关键内容之一,在框架升级到V1后其逻辑进行了一次大的调整。为更好的了解KV cache的管理逻辑,本文结合代码(v0.10.2版本),从整体架构到关键细节进行讲解,涵盖逻辑层、物理层以及两者间的联系,帮助使用者/开发者对整体结构有个基本了解。
一、整体框架
1.1基本原理
KV cache管理逻辑以PagedAttention为基础进行构建,分了逻辑层与物理层,该方式类似于操作系统的虚拟内存(virtual memory)管理。虽然vLLM版本在快速迭代更新,但这个基础的逻辑保持一致,所以学习PagedAttention是了解KV cache管理的第一步。
PagedAttention的核心逻辑是将Attention运算中的KV值按照虚拟映射的方式管理起来,如下图所示。图中有两个请求request A和B,他们各自拥有自己的逻辑块(logical kv blocks),通过对应的映射表(block table)找到每个词在物理块(physical kv blocks)中的位置。
PagedAttention基本原理示意
这种方式优势:
- 能够充分利用显存,降低KV cache导致的存储碎片化问题;
- 减少物理显存的反复申请/释放操作,提升效率;
目前在V1版本中KV cache管理还融合了前缀树,更好地适配了Prefix cache功能。整体的架构如下图所示,逻辑层由KV Manager管理、物理层由Model Runner处理;通过Scheduler(调度器)作为信息传递的桥梁,衔接了逻辑层与物理层。定义的cache的管理单位包括:池(pool)表(table)、层(layer)、块(block)和槽(slot)。
- slot为最小管理单元,每个token占一个slot;
- block为请求分配的基本单位,一个block包含多个slot;
- pool为逻辑层block的管理合集,通过链表将block数据组织起来;
- table管理请求与数据的映射,一个table可包含多个请求的信息。位于物理层;
- layer是物理层申请的数据单位,单个layer包含多个block被所有请求共用;
模块之间运行的关键步骤:
- Scheduler分配资源给请求,通过KV Manager申请逻辑blocks;
- KV Manager把Pool中空闲的blocks选中后给到对应请求;
- 分配好逻辑blocks后Scheduler构建scheduler.output传递给ModelRunner;
- ModelRunner为每条请求创建block table,并生成slot_mapping;
- 计算时把slot_mapping传入attention,就能够从物理KV blocks上面找到所需的数据。
1.2cache的重复利用
KV cache manager中有个关键功能:重复利用已计算的KV cache,该功能可以降低重复前缀的计算。**使用cache对计算量的影响有多大?**从计算量的角度来分析一下这个问题:
在prefill阶段,仅考虑Attention(MLA)模块和FFN(MoE)模块的flops计算,通过增加已计算的前缀缓存(prefix cache)长度获得算量的变化。相关公式如下:
选择总长度不同的序列1k、4k、8k和16k分别计算得到如下曲线。通过对比可以知道:
- KV cache匹配命中率越高计算量越小。
- 输入总序列越长,KV cache对计算的影响越大。
2.1blocks的管理逻辑
KV cache的存储是以block为基本单位组织的。据需求设定block size,表示block里面可存储的tokens的数量,用slot来表示token在block中位置。所有的block都在pool里面。
Block的定义:KVCacheBlock,它是一个双向链表。
# 代码位置:vllm/vllm/v1/core/kv_cache_utils.py # KVCacheBlock定义: 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 key (block hash + group id) of the block, only available # when the block is full and cached. _block_hash: Optional[BlockHashWithGroupId] = 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 # Whether the block is a null block that should never be cached. is_null: bool = False其中关键参数ref_cnt,表示有多少请求正在使用该block;_block_hash 用作已完成计算块的唯一标记。KVCacheBlock双向链表示意:
组织blocks的几个关键模块:
- 块池(Block Pool):存储KVCacheBlock,一般初始化时决定数量,避免多次的python操作。
- 空闲队列(Free Block Queue):空闲的块的队列,仅存储头尾节点指针信息。
- 缓存协调模块(KVCache Coordinator):协调不同的KV cache组。
# 当前版本模块的定义位置: # BlockPool vllm/vllm/v1/core/block_pool.py # FreeKVCacheBlockQueue vllm/vllm/v1/core/kv_cache_utils.py # KVCache Coordinator vllm/vllm/v1/core/kv_cache_coordinator.py2.2KV cache Manager的运行逻辑
KV cache Manager关键动作包括:开辟、释放、淘汰,逻辑流程如下图所示:
执行逻辑示意图(未考虑优先级)
a> 内存开辟
a.1 检查是否有足够的空间为新请求来开辟block;
a.2 从cache block检查是否可以复用,可以复用从free队列剔除,引用计数+1;
a.3 若无复用数据从free队列中头中弹出一个block用于写数据
a.4 如果block写满了数据,则被cached block标记
b> 内存释放
request使用完后,将block送回free队列里面,如果是cached block,引用计数-1,最后的这个block 最先放入队列中,它可能未被cache;
c> 淘汰策略
根据LRU策略淘汰队首的block cache,并从cached blocks字典中去除记录。
基本操作在近期KVCacheManager迭代中有所更改,以适配新特性比如PD connector、KV cache group等。具体细节参看代码:vllm/vllm/v1/core/kv_cache_manager.py
KV cache管理中比较重要的功能:
**a. Connector:**主要解决跨实例/节点之间传输问题,建议参看之前的总结,
b. Prefix cache:前缀匹配,在prefill阶段,重复利用已计算的KV cache,即把保存的KV cache数据(未被淘汰)提供给新请求使用。实现中,在创建blocks时检查是否有已计算的blocks,然后复用这些已计算的blocks。
prefix cache实现由scheduler和kv manager共同参与完成。参数“enable_caching”控制功能的开启与否,默认开启prefix cache。
主要执行动作:scheduler计算tokens时会触发匹配函数,然后交由kv manger进行匹配计算并返回已匹配的blocks;在为tokens申请新slots时,对block的使用情况进行标记。
prefix cache关键流程
这份完整版的大模型 AI 学习和面试资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
相关步骤代码:
# scheduer 中调用kv manager # vllm/vllm/v1/core/sched/scheduler.py # 触发匹配计算 # Get already-cached tokens. if request.num_computed_tokens == 0: # Get locally-cached tokens. new_computed_blocks, num_new_local_computed_tokens = \ self.kv_cache_manager.get_computed_blocks( request) # vllm/vllm/v1/core/kv_cache_manager.py # 进行命中率计算: max_cache_hit_length = request.num_tokens - 1 computed_blocks, num_new_computed_tokens = ( self.coordinator.find_longest_cache_hit(request.block_hashes, max_cache_hit_length)) # vllm/vllm/v1/core/sched/scheduler.py # scheduler申请slots: new_blocks = self.kv_cache_manager.allocate_slots( request, num_new_tokens + num_external_computed_tokens, num_new_local_computed_tokens, new_computed_blocks, num_lookahead_tokens=effective_lookahead_tokens, delay_cache_blocks=load_kv_async, num_encoder_tokens=num_encoder_tokens, ) # vllm/vllm/v1/core/kv_cache_manager.py # manger标记匹配的blocks: # Touch the computed blocks to make sure they won't be evicted. if self.enable_caching: self.block_pool.touch(new_computed_block_list) # vllm/vllm/v1/core/block_pool.py # pool里面管理的block的引用计数(ref_cnt )+1: def touch(self, blocks: tuple[list[KVCacheBlock], ...]) -> None: for blocks_per_group in blocks: for block in blocks_per_group: # ref_cnt=0 means this block is in the free list (i.e. eviction # candidate), so remove it. if block.ref_cnt == 0 and not block.is_null: self.free_block_queue.remove(block) block.ref_cnt += 1 # vllm/vllm/v1/core/single_type_kv_cache_manager.py # pool释放请求对应的blocks: def free(self, request_id: str) -> None: """ Free the blocks for the request. Args: request_id: The request ID. """ # Default to [] in case a request is freed (aborted) before alloc. req_blocks = self.req_to_blocks.pop(request_id, []) # Free blocks in reverse order so that the tail blocks are # freed first. # 此处有个按照逆序来淘汰策略,但该方式目前看非最佳(可留言讨论@kaiyuan) ordered_blocks = reversed(req_blocks) self.block_pool.free_blocks(ordered_blocks) self.num_cached_block.pop(request_id, None) # vllm/vllm/v1/core/block_pool.py # 释放时,引用计数-1: def free_blocks(self, ordered_blocks: Iterable[KVCacheBlock]) -> None: # Materialize the iterable to allow multiple passes. blocks_list = list(ordered_blocks) for block in blocks_list: block.ref_cnt -= 1 self.free_block_queue.append_n([ block for block in blocks_list if block.ref_cnt == 0 and not block.is_null ])三、 应用层(物理层)
应用层承载实际的KV cache数据,在vLLM框架中也可其视为“物理层”。应用层通过torch创建一个连续的tensor实现,Attention运算时使用该tensor的部分数据。torch为作为应用层的基础,其有一套独立的显存管理逻辑,该逻辑依赖底层硬件API库。作为框架开发者/使用者,我们不需要太多关注底层逻辑,聚焦到torch之上的内容即可。
3.1kv cache的创建
kv cache的创建主要是两个步骤:
- 步骤1,**可用空间计算。**在初始化阶段,确认kv cache可用空间。
- 步骤2,**层的创建。**根据模型特点为每一层分配一个固定的空间。
可用空间计算
kv cache的可用空间是在预热(dummy run)阶段确定的。目前可用的空间(available_gpu_memory)计算有两种方式:
# vllm/vllm/v1/engine/core.py # _initialize_kv_caches函数: if os.environ.get("VLLM_ELASTIC_EP_SCALE_UP_LAUNCH") == "1": dp_group = getattr(self, "dp_group", None) assert dp_group is not None self.available_gpu_memory_for_kv_cache = \ ParallelConfig.sync_kv_cache_memory_size(dp_group, -1) available_gpu_memory = [ self.available_gpu_memory_for_kv_cache ] * len(kv_cache_specs) else: # Profiles the peak memory usage of the model to determine how # much memory can be allocated for kv cache. available_gpu_memory = ( self.model_executor.determine_available_memory()) self.available_gpu_memory_for_kv_cache = \ available_gpu_memory[0]其中通过dummy run取峰值(peak)计算的方式的执行逻辑在determine_available_memory函数中完成。
# 代码位置:vllm/vllm/v1/worker/gpu_worker.py determine_available_memory # 主要函数:determine_available_memory计算满足:KV blocks = 总显存 × gpu_memory_utilization −(模型显存 + 激活峰值 + 非 torch 显存)
blocks的大小计算如下,其中page size 跟模型结构相关,不同层的计算可以不一样。
# 代码位置:vllm/vllm/v1/core/kv_cache_utils.py def get_num_blocks(vllm_config: VllmConfig, num_layers: int, available_memory: int, page_size: int) -> int: """ Get the number of kv cache blocks. Args: vllm_config: The global VllmConfig num_layers: The number of layers available_memory: Memory available for KV cache in bytes. page_size: The page size of the KV cache. """ num_blocks = int(available_memory // page_size // num_layers) num_blocks = max(num_blocks, 0) num_blocks = may_override_num_blocks(vllm_config, num_blocks) return num_blocks # 其中同构的attention的page_size计算: # 代码位置:vllm/vllm/v1/kv_cache_interface.py def page_size_bytes(self) -> int: # For MLA we only store a single latent vector coef = 1 if self.use_mla else 2 return coef * self.block_size * self.num_kv_heads * self.head_size \ * get_dtype_size(self.dtype)层的创建
blocks的物理显存分配是由pytorch完成,创建的连续tensor视为KV cache所能使用的物理显存。在GPU runner中有一个_allocate_kv_cache_tensors函数,通过torch.tensor逐层创建kv_cache_tensor。模型每层共用tensor数据,比如MLA模块会创建nope和rope模块,nope和rope可以创建在一起,也可以由两个tensor承载。
nope与rope连续
nope与rope分开
# 代码位置:vllm/v1/worker/gpu_model_runner.py kv_cache_raw_tensors: dict[str, torch.Tensor] = {} kv_cache_raw_tensors: dict[str, torch.Tensor] = {} for kv_cache_tensor in kv_cache_config.kv_cache_tensors: tensor = torch.zeros(kv_cache_tensor.size, dtype=torch.int8, device=self.device) for layer_name in kv_cache_tensor.shared_by: kv_cache_raw_tensors[layer_name] = tensor3.2层数据分配
请求的逻辑blocks分配好后,通过scheduler.out传递给model runner,进而构造request与物理blocks的映射关系;scheduler.out里面还标记了每个请求中已计算完成的tokens。
在model runner中需要完成:scheduler.out信息要转化为PagedAttention所需要的KV数据。
先看一下PagedAttention运算时的数据形态,以FA为例,如下所示。根据Attention的一般计算可知,其输入是:QKV数据、历史的KV数据。
# Attention的输入 Args: query: shape = [num_tokens, num_heads, head_size] key: shape = [num_tokens, num_kv_heads, head_size] value: shape = [num_tokens, num_kv_heads, head_size] kv_cache: shape = [2, num_blocks, block_size, num_kv_heads, head_size] attn_metadata: Metadata for attention. # 输出: Returns: shape = [num_tokens, num_heads * head_size]其中,query/key/value是当前forward产生的数据,kv_cache参数是整层的数据,该数据所有请求公用。
接着要明确问题是:Attention拿kv_cache的哪些数据?
在数据读取时仅有block的索引信息是不够的,还需要知道token在block中的具体位置,而这个信息存在attn_metadata的slot_mapping中(数据类型为torch.Tensor)。
slot_mapping如何运作?
slot_mapping存储了每个token在layer数据中的位置,它包含的元素总数与请求的tokens数量相等,其运算需要与block size结合。
计算方式:数据进行整除+取余运算,整数表示所在block id,余数表示在对应的block id里面的slot(槽位)。举个例子,假设block size=128,slot_mapping=[958, 714, 238, 427],计算如下:
scheduler是多个请求同时下发,映射关系有多组,slot_mapping如何构造?
vLLM中通过映射表(block table)来记录每个请求信息,并处理多请求问题。用BlockTable类实现请求的增、删操作,代码如下(节选):
# 代码位置: vllm/vllm/v1/worker/block_table.py class BlockTable: def __init__( self, block_size: int, max_num_reqs: int, max_num_blocks_per_req: int, max_num_batched_tokens: int, pin_memory: bool, device: torch.device, ): # .... self.block_table = self._make_buffer(max_num_reqs, max_num_blocks_per_req, dtype=torch.int32) self.num_blocks_per_row = np.zeros(max_num_reqs, dtype=np.int32) self.slot_mapping = self._make_buffer(self.max_num_batched_tokens, dtype=torch.int64) def append_row( self, block_ids: list[int], row_idx: int, ) -> None: if not block_ids: return num_blocks = len(block_ids) start = self.num_blocks_per_row[row_idx] self.num_blocks_per_row[row_idx] += num_blocks self.block_table.np[row_idx, start:start + num_blocks] = block_ids # 添加请求: def add_row(self, block_ids: list[int], row_idx: int) -> None: self.num_blocks_per_row[row_idx] = 0 self.append_row(block_ids, row_idx)关键参数:**self.block_table,**其大小等于最大请求数乘以单个请求blocks数量最大值。它存储了所有需要运行请求相关的blocks信息。
block table通过add_row添加请求,其中row_idx参数是指传入的请求索引(req_index)。slot_mapping的生成计算如下(节选),由compute_slot_mapping函数完成。
# 代码位置: vllm/vllm/v1/worker/block_table.py def compute_slot_mapping(self, req_indices: np.ndarray, positions: np.ndarray) -> None: # ..... block_table_indices = (req_indices * self.max_num_blocks_per_req + positions // self.block_size) # 找到请求索引的token对应的 block位置 block_numbers = self.block_table.np.ravel()[block_table_indices] # 展平后取索引数据 block_offsets = positions % self.block_size # 转化为在每个block中的位置偏移 np.add(block_numbers * self.block_size, block_offsets, out=self.slot_mapping.np[:req_indices.shape[0]]) # 构造slot_mapping数据。这个计算实际上就是一个二维转一维的运算。举个例子帮助理解,假设block_size大小为5,max_num_blocks_per_req大小为4,索引某个token的计算如下:
接下来,需要了解req_indices,positions是如何计算出来的?
代码在ModelRunner的_prepare_inputs函数里面,根据scheduler_output.num_scheduled_tokens的数据来构造req_indices与positions。
注释里面给了一个例子,[2, 5, 3] 表示的是有三个请求,每个元素表示请求需要的tokens数。其中num_computed_tokens_cpu参数是指已计算过的tokens数量。
# 代码位置:vllm/vllm/v1/worker/gpu_model_runner.py # def _prepare_inputs # ... req_ids = self.input_batch.req_ids tokens = [scheduler_output.num_scheduled_tokens[i] for i in req_ids] num_scheduled_tokens = np.array(tokens, dtype=np.int32) # 调度到每个请求上的数据 max_num_scheduled_tokens = max(tokens) # Get request indices. # E.g., [2, 5, 3] -> [0, 0, 1, 1, 1, 1, 1, 2, 2, 2] req_indices = np.repeat(self.arange_np[:num_reqs], num_scheduled_tokens) # cu_num_tokens: [2, 5, 3] -> [2, 7, 10] # arange: [0, 1, 0, 1, 2, 3, 4, 0, 1, 2] cu_num_tokens, arange = self._get_cumsum_and_arange( num_scheduled_tokens) # Get positions. positions_np = self.positions.np[:total_num_scheduled_tokens] np.add(self.input_batch.num_computed_tokens_cpu[req_indices], arange, out=positions_np)至此分析完了scheduler_output与slot_mapping的计算逻辑,还有两个细节点:
i> KV cache group
为了适配异构形态、或者不同Attention模块(MHA/MLA/GQA/slide等)的kv cache混合使用设计了kv cache group概念,对应的block size可以设置不同,用不同的block table来管理每个KV cache group的映射关系。不同KV cache group之间协同工作由KVCacheCoordinator管理。
相关的PR参考:
- https://github.com/vllm-project/vllm/pull/13296
- https://github.com/vllm-project/vllm/pull/25101
ii> DCP(Decode Context Parallel)
为了减少KV cache的冗余存储,开启Attention序列并行时,可根据CP的数量让不同设备存储KV cache的部分数据。
当前计算逻辑单个token的存储位置满足: rank_id = token_idx % cp_world_size。
关键细节介绍到这里,其它内容参考源码:
https://github.com/vllm-project/vllm/tree/releases/v0.10.2
新模型在不断推出,KV cache管理也在持续迭代。下一步的方向:
- 模型的适配: 当模型中Attention结构出现改进时,目前代码修改涉及逻辑层和应用层,这种方式很显然是高成本的,所以需要一种更加解耦降低修改量。
- 池化应用: 本地池化和跨节点的池化功能应该成为一种标配。
- 传输优化: 全掩盖的传输方式、不影响指标(TTFT/ITL)的传输方式的探索。
四、如何学习AI大模型?
大模型时代,火爆出圈的LLM大模型让程序员们开始重新评估自己的本领。 “AI会取代那些行业?”“谁的饭碗又将不保了?”等问题热议不断。
不如成为「掌握AI工具的技术人」,毕竟AI时代,谁先尝试,谁就能占得先机!
想正式转到一些新兴的 AI 行业,不仅需要系统的学习AI大模型。同时也要跟已有的技能结合,辅助编程提效,或上手实操应用,增加自己的职场竞争力。
但是LLM相关的内容很多,现在网上的老课程老教材关于LLM又太少。所以现在小白入门就只能靠自学,学习成本和门槛很高
那么针对所有自学遇到困难的同学们,我帮大家系统梳理大模型学习脉络,将这份LLM大模型资料分享出来:包括LLM大模型书籍、640套大模型行业报告、LLM大模型学习视频、LLM大模型学习路线、开源大模型学习教程等, 😝有需要的小伙伴,可以扫描下方二维码领取🆓↓↓↓
学习路线
第一阶段: 从大模型系统设计入手,讲解大模型的主要方法;
第二阶段: 在通过大模型提示词工程从Prompts角度入手更好发挥模型的作用;
第三阶段: 大模型平台应用开发借助阿里云PAI平台构建电商领域虚拟试衣系统;
第四阶段: 大模型知识库应用开发以LangChain框架为例,构建物流行业咨询智能问答系统;
第五阶段: 大模型微调开发借助以大健康、新零售、新媒体领域构建适合当前领域大模型;
第六阶段: 以SD多模态大模型为主,搭建了文生图小程序案例;
第七阶段: 以大模型平台应用与开发为主,通过星火大模型,文心大模型等成熟大模型构建大模型行业应用。
👉学会后的收获:👈
• 基于大模型全栈工程实现(前端、后端、产品经理、设计、数据分析等),通过这门课可获得不同能力;
• 能够利用大模型解决相关实际项目需求: 大数据时代,越来越多的企业和机构需要处理海量数据,利用大模型技术可以更好地处理这些数据,提高数据分析和决策的准确性。因此,掌握大模型应用开发技能,可以让程序员更好地应对实际项目需求;
• 基于大模型和企业数据AI应用开发,实现大模型理论、掌握GPU算力、硬件、LangChain开发框架和项目实战技能, 学会Fine-tuning垂直训练大模型(数据准备、数据蒸馏、大模型部署)一站式掌握;
• 能够完成时下热门大模型垂直领域模型训练能力,提高程序员的编码能力: 大模型应用开发需要掌握机器学习算法、深度学习框架等技术,这些技术的掌握可以提高程序员的编码能力和分析能力,让程序员更加熟练地编写高质量的代码。
1.AI大模型学习路线图
2.100套AI大模型商业化落地方案
3.100集大模型视频教程
4.200本大模型PDF书籍
5.LLM面试题合集
6.AI产品经理资源合集
👉获取方式:
😝有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓