尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
LLM 推理中的 PageAttention 实现原理:虚拟内存管理在 KV Cache 中的创新应用
LLM 推理中的 PageAttention 实现原理虚拟内存管理在 KV Cache 中的创新应用一、KV Cache 的内碎片问题固定分配如何浪费 40% 显存传统 LLM 推理为每个请求预分配一块连续的 KV Cache 空间大小 max_seq_len × num_layers × 2 × head_dim × num_heads × dtype_size。以 Llama-2-70B 为例max_seq_len4096 时的单请求 KV Cache 为 2.5GB。当实际生成长度仅 128 tokens 时预分配的 96.9% 空间闲置。更严重的是连续分配导致严重的外碎片。多个请求的 KV Cache 在显存中交错分配短请求完成后释放的空间被长请求的活跃 KV Cache 块隔开无法合并成大块。最终显存利用率仅 60%但 GPU OOM 频率上升——不是因为总量不够而是无法分配连续空间。此外连续分配策略在动态批处理Dynamic Batching场景下表现极差。当 Batch 中某个请求提前结束时其释放的 KV Cache 空间若小于其他请求所需就会成为无法利用的空洞。PageAttention 的非连续分配天然解决了这个问题——释放的 Block 可以立即分配给任何其他请求无论其大小。PageAttentionvLLM 提出的 PagedAttention将虚拟内存的分页思想引入 KV Cache 管理KV Cache 被分割为固定大小的 Block类似 4KB 内存页每个 Block 存储固定数量 token 的 Key 和 Value。请求的 KV Cache 由 Block 链表组成不需要物理连续。释放时逐 Block 回收无外碎片。二、PageAttention 的虚拟内存管理架构Attention kernel 计算时通过block_table[request_id]查找每个 token 对应的物理 Block 地址然后使用 GPU 的gather指令从非连续的 Block 中收集 Key/Value。这个收集操作的额外开销约 3~5%——远小于显存利用率的提升从 60% 到 96%。gather操作的性能瓶颈在于显存带宽利用率。当 Block 分散在 VRAM 的不同区域时GPU 的 Memory Controller 需要发起多次显存事务Transaction降低了有效带宽。但 Attention 计算本身是 Bandwidth-Bound 的计算量小数据搬运量大额外的gather开销被掩盖在计算延迟中。实测显示在 A100 80GB 上PageAttention 的 Attention Kernel 吞吐量仅比连续分配低 2-3%。Block 大小是一个关键超参数。Block 越大映射表的条目越少减少查找开销但内碎片越大。vLLM 默认 Block Size 16这是基于实验的折中——A100 的 L2 Cache line 128 字节16 个 fp16 token 的 KV 约为 16 × 2 × 128 × 32 × 2 / 8 32KB适配 L1 Cache 大小。选择 Block Size 16 的另一个原因是与 GPU Warp Size32 threads对齐。每个 Block 的 Attention 计算可以由一个 Warp 独立完成避免 Warp 内分支发散。如果 Block Size 不是 Warp Size 的倍数会导致部分线程空闲降低 GPU 利用率。三、PageAttention 在 Rust 推理引擎中的实现use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use tokio::sync::Mutex; /// Block 配置 const BLOCK_SIZE: usize 16; // 每个 Block 存储 16 个 token /// 物理 Block 在 GPU 显存中的表示 #[derive(Debug, Clone, Copy)] struct PhysicalBlock { /// Block 在 Block 池中的索引 block_id: usize, /// GPU 显存中的虚拟地址 gpu_addr: u64, /// 引用计数多个请求可能共享前缀 Block ref_count: u32, } /// Block 池管理器GPU 显存管理器 struct BlockPool { /// 总 Block 数量 total_blocks: usize, /// 空闲 Block 队列 free_blocks: VecDequePhysicalBlock, /// 所有 Block索引 → Block all_blocks: VecPhysicalBlock, /// 当前已分配的 Block 数量 allocated_blocks: usize, } impl BlockPool { fn new(num_gpu_blocks: usize, base_addr: u64, block_size_bytes: u64) - Self { let mut all_blocks Vec::with_capacity(num_gpu_blocks); let mut free_blocks VecDeque::with_capacity(num_gpu_blocks); for i in 0..num_gpu_blocks { let block PhysicalBlock { block_id: i, gpu_addr: base_addr (i as u64) * block_size_bytes, ref_count: 0, }; all_blocks.push(block); free_blocks.push_back(block); } BlockPool { total_blocks: num_gpu_blocks, free_blocks, all_blocks, allocated_blocks: 0, } } /// 分配一个空闲 Block /// 设计原因使用 VecDeque::pop_front 实现 O(1) 分配 /// 返回的 Block 引用计数设为 1首次分配 fn allocate(mut self) - OptionPhysicalBlock { self.free_blocks.pop_front().map(|mut block| { block.ref_count 1; self.allocated_blocks 1; block }) } /// 释放一个 Block引用计数减 1归零时回收 /// 设计原因使用引用计数支持前缀共享 /// 多个请求可以共享同一个 Block如系统 prompt 的 KV Cache fn free(mut self, block_id: usize) { let block mut self.all_blocks[block_id]; block.ref_count block.ref_count.saturating_sub(1); if block.ref_count 0 { self.free_blocks.push_back(*block); self.allocated_blocks - 1; } } /// 增加引用计数共享 Block 时使用 /// 设计原因前缀共享时多个请求需要访问同一个 Block /// 只有当所有持有者都释放后Block 才真正回收 fn add_ref(mut self, block_id: usize) { self.all_blocks[block_id].ref_count 1; } fn usage_ratio(self) - f64 { self.allocated_blocks as f64 / self.total_blocks as f64 } } /// 逻辑 Block 映射一个请求的 Block 表 #[derive(Debug, Clone)] struct BlockTable { /// token_index → physical_block_id 的映射 /// key: 逻辑 Block 序号0, 1, 2, ... /// value: 物理 Block ID blocks: Vecusize, } impl BlockTable { fn new() - Self { BlockTable { blocks: Vec::new() } } /// 获取指定 token 索引对应的物理 Block 和块内偏移 fn get_physical_location(self, token_idx: usize) - Option(usize, usize) { let logical_block token_idx / BLOCK_SIZE; let offset token_idx % BLOCK_SIZE; self.blocks.get(logical_block).map(|phys_id| (phys_id, offset)) } /// 需要多少个逻辑 Block 来存储给定数量 token fn num_required_blocks(num_tokens: usize) - usize { (num_tokens BLOCK_SIZE - 1) / BLOCK_SIZE } } /// PageAttention 管理器协调 KV Cache 的分配与释放 struct PageAttentionManager { /// GPU 显存 Block 池 block_pool: ArcMutexBlockPool, /// 每个活跃请求的 Block 表 request_blocks: ArcMutexHashMapu64, BlockTable, } impl PageAttentionManager { fn new(num_gpu_blocks: usize) - Self { let block_pool BlockPool::new( num_gpu_blocks, 0x1000_0000, // GPU 显存基地址 2 * BLOCK_SIZE as u64 * 4096 * 32 * 2, // 每 Block 字节数 ); PageAttentionManager { block_pool: Arc::new(Mutex::new(block_pool)), request_blocks: Arc::new(Mutex::new(HashMap::new())), } } /// 为新请求分配 KV Cache Blocks /// 返回分配的 Block 数量 /// 设计原因分配可能部分失败在分配过程中 Block 池耗尽 /// 必须回滚已分配的 Block避免资源泄漏 async fn allocate_for_request( self, request_id: u64, prompt_len: usize, ) - Resultusize, String { let num_blocks BlockTable::num_required_blocks(prompt_len); let mut pool self.block_pool.lock().await; // 检查是否有足够的空闲 Block // 设计原因先检查再分配避免部分分配后失败 // 但此检查与后续分配之间存在 TOCTOU 竞态 // 生产实现中应使用 try_allocate 原子操作 if pool.free_blocks.len() num_blocks { return Err(format!( Insufficient blocks: need {}, available {}, num_blocks, pool.free_blocks.len() )); } let mut table BlockTable::new(); for _ in 0..num_blocks { match pool.allocate() { Some(block) table.blocks.push(block.block_id), None { // 回滚释放已分配的 Block // 设计原因必须保证原子性全部分配或完全不分配 for block_id in table.blocks { pool.free(block_id); } return Err(Allocation failed mid-way.to_string()); } } } let mut requests self.request_blocks.lock().await; requests.insert(request_id, table); Ok(num_blocks) } /// 为生成阶段追加新的 KV Cache Block /// 每次自回归解码生成一个新 token可能触发新 Block 分配 async fn append_slot( self, request_id: u64, current_len: usize, ) - ResultOptionusize, String { // 检查当前是否正好是 Block 边界 if current_len % BLOCK_SIZE ! 0 { return Ok(None); // 无需新 Block } let mut pool self.block_pool.lock().await; let mut requests self.request_blocks.lock().await; let table requests.get_mut(request_id) .ok_or(Request not found)?; match pool.allocate() { Some(block) { table.blocks.push(block.block_id); Ok(Some(block.block_id)) } None { // 无空闲 Block → 触发抢占Preemption // 策略选择 oldest request 驱逐其 Blocks Err(Preemption required: no free blocks.to_string()) } } } /// 释放请求的所有 Blocks async fn free_request(self, request_id: u64) { let mut requests self.request_blocks.lock().await; let mut pool self.block_pool.lock().await; if let Some(table) requests.remove(request_id) { for block_id in table.blocks { pool.free(block_id); } } } /// 前缀共享新请求复用已有请求的 prompt Block /// 例如系统 prompt 被多个请求共享无需重复计算 KV Cache async fn share_prefix_blocks( self, source_request_id: u64, target_request_id: u64, shared_len: usize, ) - Result(), String { let shared_blocks BlockTable::num_required_blocks(shared_len); let requests self.request_blocks.lock().await; let mut pool self.block_pool.lock().await; let source_table requests.get(source_request_id) .ok_or(Source request not found)?; if source_table.blocks.len() shared_blocks { return Err(Source has fewer blocks than requested share length.to_string()); } // 为共享的 Blocks 增加引用计数 for i in 0..shared_blocks { pool.add_ref(source_table.blocks[i]); } let mut target_table BlockTable::new(); target_table.blocks source_table.blocks[..shared_blocks].to_vec(); drop(requests); let mut requests self.request_blocks.lock().await; requests.insert(target_request_id, target_table); Ok(()) } } #[tokio::main] async fn main() { let manager PageAttentionManager::new(1024); // 1024 个物理 Blocks // 分配请求 A 的 KV Cacheprompt50 tokens → 需要 4 个 Blocks let blocks_a manager.allocate_for_request(1, 50).await.unwrap(); println!(Request A allocated {} blocks, blocks_a); // 自回归生成第 17 个 token触发新 Block 分配 let new_block manager.append_slot(1, 17).await.unwrap(); println!(Request A new block: {:?}, new_block); // 请求 B 共享 A 的前 2 个 Block系统 prompt manager.share_prefix_blocks(1, 2, 32).await.unwrap(); println!(Request B shared prefix blocks from request A); // 释放请求 manager.free_request(1).await; manager.free_request(2).await; println!(All requests freed); }前缀共享是 PageAttention 额外带来的重要优化。在 RAG 场景中多个请求共享相同的检索文档 prompt前缀 KV Cache 的复用可以减少 50~80% 的 prefill 计算量。引用计数机制保证共享 Block 在所有使用者释放后才回收。Block 分配失败时的抢占策略需要根据请求优先级选择性驱逐。低优先级请求的 KV Cache 被卸载到 CPU 内存Swap Out待 GPU 有空闲 Block 时再换入。这需要在 GPU 和 CPU 之间做高效的数据传输。四、PageAttention 的性能边界与退化场景额外开销gather操作从 Block 表中查找物理地址每次 Attention 增加约 3% 计算开销Block 管理的元数据内存每 Block 约 64 字节1000 个 Block 仅 64KB退化场景短序列 32 tokensBlock 内碎片占比大连续分配可能更优极高并发 100 requestsBlock 表查找的 TLB 压力增大需增大 Block SizeBeam Search每个 Beam 需要独立的 Block 表fork 操作需要复制 Block 引用显存效率数据连续分配利用率 60%总 KV Cache 浪费约 40%PageAttention利用率 96%Block 内碎片占比约 4%Block Size16PageAttention 的退化场景与应对短序列 32 tokensBlock 内碎片占比大可能 50%连续分配可能更优。应对策略动态选择 Block Size——短提示用 Block Size4长生成用 Block Size32。极高并发 100 requestsBlock 表查找的 TLB 压力增大每次 Attention 需要多次显存访问。应对策略增大 Block Size 到 32 或 64减少 Block 表大小。Beam Search每个 Beam 需要独立的 Block 表fork 操作需要复制 Block 引用引用计数1。当 Beam Width5 时KV Cache 占用放大 5 倍。应对策略使用共享前缀 独立后缀仅 fork 生成部分的 Block 表。Multi-Modal 模型图像文本图像 token 的 KV Cache 远大于文本固定 Block Size 导致图像 Block 浪费显存。应对策略使用可变大小 BlockvLLM 的 v2 引擎支持。与 DeepSpeed Inference 的 Tensor Parallelism 结合当使用 TPTensor Parallelism时每个 GPU 只持有部分注意力头。PageAttention 需要跨 GPU 同步 Block 表——这会导致额外的通信开销。优化方案每个 GPU 独立管理 Block 池仅共享逻辑 Block 表的元数据通过 NCCL AllReduce。五、总结PageAttention 将虚拟内存的分页管理引入 KV Cache消除物理连续分配的外碎片将显存利用率从 60% 提升至 96%。KV Cache 被分割为固定大小的 Block默认 16 tokens通过 Block 表完成逻辑→物理地址映射Attention kernel 使用 gather 操作访问非连续 Block。gather 操作的额外开销约 3%Block 内碎片约 4%净收益显存利用率提升 36%远超额外开销。前缀共享利用引用计数实现多请求复用公共 prompt 的 KV CacheRAG 场景下减少 50~80% prefill 计算。Block 耗尽时的抢占策略将低优先级请求的 KV Cache 交换到 CPU 内存GPU 空闲后换回。
RELATED

相关推荐

5大硬件加速技巧:深度解析Thorium浏览器性能优化架构

5大硬件加速技巧:深度解析Thorium浏览器性能优化架构

5大硬件加速技巧:深度解析Thorium浏览器性能优化架构 【免费下载链接】thorium Chromium fork named after radioactive element No. 90. Source code and Linux releases. Windows/MacOS/ARM builds served in different repos, links are towards the top of the …

📅 2026/9/13 10:39:10
如何用开源AI标书工具10倍提升投标效率?易标投标工具箱全攻略

如何用开源AI标书工具10倍提升投标效率?易标投标工具箱全攻略

如何用开源AI标书工具10倍提升投标效率?易标投标工具箱全攻略 【免费下载链接】OpenBidKit_Yibiao 开箱即用的AI标书编写工具,标书AI生成工具,投标工具箱、知识库、标书查重、废标项检查,完全开源免费,欢迎使用 项目…

📅 2026/8/25 19:48:54
【前端埋点生死线】:Cursor统计代码未做这4类上下文隔离,90%团队正在 silently 丢失核心行为数据

【前端埋点生死线】:Cursor统计代码未做这4类上下文隔离,90%团队正在 silently 丢失核心行为数据

更多请点击: https://codechina.net 第一章:Cursor埋点统计代码的生死线本质 Cursor 埋点统计代码并非普通业务逻辑,而是前端可观测性的神经末梢——它既不能阻塞主线程,又必须在页面生命周期关键节点精准触发;稍有偏…

📅 2026/7/20 18:22:00
MORE NEWS

更多资讯

📰

Android APK包体积优化实战:从88MB到51MB的全程复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

gRPC-Go 健康检查(Health Check)实战指南:从 Service Config 到透明探活与四态状态机

gRPC-Go 健康检查(Health Check)实战指南:从 Service Config 到透明探活与四态状态机 【免费下载链接】grpc-go The Go language implementation of gRPC. HTTP/2 based RPC 项目地址: https://gitcode.com/GitHub_Trending/gr/grpc-go …

📰

SpacetimeDB 表访问权限完全指南:公开/私有表与基于 View 的细粒度数据管控

SpacetimeDB 表访问权限完全指南:公开/私有表与基于 View 的细粒度数据管控 【免费下载链接】SpacetimeDB Development at the speed of light 项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB SpacetimeDB 通过"表可见性 执行上下文&q…

📰

力扣 LeetCode 226. 翻转二叉树(Day7:二叉树)

解题思路:递归翻转二叉树,前序和后序都是可以的,但中序不行中序会导致左边始终没有处理,所以如果一定要中序,两次内部递归都要用root.left拆开写(原地修改的,返回值为 void 的,就可以…

📰

PythonRobotics 如何用时空 A* 在动态障碍物环境中规划时间最优路径

PythonRobotics 如何用时空 A* 在动态障碍物环境中规划时间最优路径 【免费下载链接】PythonRobotics Python sample codes and textbook for robotics algorithms. 项目地址: https://gitcode.com/GitHub_Trending/py/PythonRobotics 在带动态障碍物的栅格环境中做路径…

📰

RIAV-MVS:不对称体积与循环索引实现高效多视图立体匹配

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬