在PagedAttention架构中,显存管理面临着与传统连续分配架构完全不同的挑战和机遇。页面级显存管理需要解决的核心问题是:如何在一个有限的GPU显存池中,高效地管理数量庞大、大小固定、动态分配和释放的内存页面,同时保证Attention计算的正确性和高性能。vLLM将这个问题的解决方案借鉴自操作系统领域经过数十年验证的虚拟内存分页技术,开创性地将其应用于GPU推理场景。
在传统连续分配架构中,内存碎片分为两种类型,每种都严重制约着GPU资源的利用效率:
内部碎片:由于必须按最大可能长度预分配连续空间,实际生成过程中大量预分配的KV Cache空间被闲置。以一个典型的对话服务为例,系统预分配每个请求2048 token的KV Cache空间,但实际平均生成量只有512 token,这意味着高达75%的预分配空间被浪费。对于7B参数模型,每个token的KV Cache约为16KB(FP16精度下),2048 token的预分配就需要32MB,其中24MB完全闲置。
外部碎片:在动态批处理场景下,请求以不同的时间到达和完成,频繁的分配和释放会在显存中产生大量零散的空闲区域。即使总空闲显存充足(例如有100MB的空闲空间),但如果这些空间分散成许多10MB的小块,就无法为一个需要32MB连续空间的新请求服务。这种"有空间却无法使用"的窘境在长序列推理中尤为突出。
PagedAttention通过页面级管理几乎完全消除了这两类碎片。由于所有内存都以固定大小的页面为单位管理,任何空闲页面都可以分配给任何请求,不存在"小块无法利用"的问题,外部碎片被彻底消除。对于内部碎片,按需分配策略确保生成多少token就占多少页面,最坏情况下只有每个序列的最后一个页面可能不满,浪费不超过一个页面(16个token的KV Cache空间),相比传统方案的75%浪费率,提升极为显著。
# 对比连续分配与PagedAttention的显存碎片情况 class MemoryFragmentationSimulator: """内存碎片模拟器""" def __init__(self, total_memory_mb: int = 20480): self.total = total_memory_mb def simulate_continuous(self, requests: list): """模拟连续分配的碎片情况""" memory = [0] * self.total internal_waste = 0 external_waste = 0 for req_id, actual_tokens, max_tokens, kv_per_token_kb in requests: needed = max_tokens * kv_per_token_kb allocated = False for start in range(self.total - int(needed) + 1): if all(memory[i] == 0 for i in range(start, start + int(needed))): for i in range(start, start + int(needed)): memory[i] = req_id used = actual_tokens * kv_per_token_kb internal_waste += needed - used allocated = True break if not allocated: external_waste += needed return { 'internal_waste_mb': internal_waste, 'external_waste_mb': external_waste, 'utilization': sum(1 for x in memory if x != 0) / self.total * 100 } def simulate_paged(self, requests: list, page_size_tokens: int = 16): """模拟PagedAttention的页面分配""" kv_per_token_kb = requests[0][3] page_size_kb = page_size_tokens * kv_per_token_kb total_pages = int(self.total * 1024 / page_size_kb) free_pages = set(range(total_pages)) allocated = 0 for _, actual_tokens, _, _ in requests: pages_needed = (actual_tokens + page_size_tokens - 1) // page_size_tokens if len(free_pages) >= pages_needed: for _ in range(pages_needed): free_pages.pop() allocated += pages_needed return {'utilization': allocated / total_pages * 100, 'allocated': allocated} sim = MemoryFragmentationSimulator(total_memory_mb=20480) requests = [(1,300,2048,16),(2,800,2048,16),(3,150,2048,16),(4,1200,2048,16),(5,500,2048,16)] cont = sim.simulate_continuous(requests) paged = sim.simulate_paged(requests) print(f"连续分配: 利用率={cont['utilization']:.1f}%, 内部浪费={cont['internal_waste_mb']:.0f}MB, 外部浪费={cont['external_waste_mb']:.0f}MB") print(f"PagedAttention: 利用率={paged['utilization']:.1f}%, 已分配{paged['allocated']}页")
传统连续分配的一个隐性优势是KV Cache在显存中连续排列,有利于GPU的合并内存访问。PagedAttention将KV Cache分散存储后,看似会降低访问效率,但实际上通过精心设计的kernel实现了等效甚至更优的访问模式:
页面分配策略是显存管理的核心决策机制,直接影响系统的吞吐量和响应延迟。vLLM采用了极简但高效的设计哲学:全局共享的空闲块池加上O(1)的分配和释放操作。
vLLM的页面分配遵循"即用即分配"原则,分配操作发生在两个关键阶段:
Prefill阶段的批量分配:当一个新请求进入Prefill阶段时,系统根据输入prompt的token数量计算所需的页面数,一次性从空闲块池中取出相应数量的页面。例如,一个包含320 token的prompt,以每页16 token计算,需要20个页面。这20个页面通过一次批量操作完成分配,无需逐个请求。
Decode阶段的增量分配:在逐token生成的Decode阶段,系统持续跟踪每个序列的token总数和已分配页面数。每当一个序列新增的token使得token总数超出当前已分配页面的容量时(即当前token数超过了num_blocks × block_size),系统自动分配一个新页面。这个检查在每个Decode步骤开始时执行,确保KV Cache始终有足够的存储空间。
# 模拟vLLM风格的块分配器 class BlockAllocator: """vLLM风格的块分配器""" def __init__(self, num_blocks: int): self.num_blocks = num_blocks self.free_blocks = list(range(num_blocks)) self.allocated_blocks = {} self.block_refcount = {} def allocate(self, seq_id: int, num_blocks: int) -> list: """为序列分配指定数量的块""" if len(self.free_blocks) < num_blocks: raise RuntimeError(f"OOM: 需要{num_blocks}块, 可用{len(self.free_blocks)}块") blocks = [] for _ in range(num_blocks): block_id = self.free_blocks.pop() blocks.append(block_id) self.block_refcount[block_id] = 1 self.allocated_blocks.setdefault(seq_id, []).extend(blocks) return blocks def try_allocate(self, num_blocks: int) -> bool: """检查是否有足够空间""" return len(self.free_blocks) >= num_blocks def free(self, seq_id: int): """释放序列占用的所有块""" if seq_id not in self.allocated_blocks: return for block_id in self.allocated_blocks[seq_id]: self.block_refcount[block_id] -= 1 if self.block_refcount[block_id] <= 0: self.free_blocks.append(block_id) del self.block_refcount[block_id] del self.allocated_blocks[seq_id] def share_blocks(self, src_seq: int, dst_seq: int, num_shared: int): """共享块(Copy-on-Write基础)""" src_blocks = self.allocated_blocks[src_seq][:num_shared] for block_id in src_blocks: self.block_refcount[block_id] += 1 self.allocated_blocks[dst_seq] = src_blocks.copy() def stats(self): used = sum(len(v) for v in self.allocated_blocks.values()) return { 'total': self.num_blocks, 'free': len(self.free_blocks), 'used': used, 'utilization': f"{used/self.num_blocks*100:.1f}%" } allocator = BlockAllocator(num_blocks=1000) allocator.allocate(1, 10) # 请求1: 分配10块 allocator.share_blocks(1, 2, 5) # 请求2: 共享请求1前5块 allocator.allocate(2, 3) # 请求2: 额外3块 print(f"分配统计: {allocator.stats()}") print(f"请求1块表: {allocator.allocated_blocks[1]}") print(f"请求2块表: {allocator.allocated_blocks[2]}")
页面共享是PagedAttention显存管理中最精妙的设计之一。在典型的推理服务中,大量并发请求共享相同的system prompt,传统架构需要为每个请求单独存储一份prompt的KV Cache。PagedAttention通过引用计数和块表映射,让多个请求共享同一组物理页面。
在标准自回归推理中,历史token的KV值一旦计算完成就不会被修改(只读性质),因此Copy-on-Write中的"复制"操作实际上几乎不会触发。共享页面的引用计数大于1,直到所有引用该页面的请求都完成后,页面才被真正释放。这种设计使得N个共享相同prompt的请求,prompt部分的KV Cache只需存储一份,节省了(N-1)/N的显存空间。
当GPU显存中的所有页面都被占用且无法再接纳新请求时,vLLM提供了两种层次的应对策略:CPU Offload(Swap)和请求抢占(Preemption)。
Swap机制将部分请求的KV Cache从GPU显存转移到CPU系统内存中,释放GPU空间给更高优先级的请求。vLLM的Swap操作以页面为单位执行,通过DMA传输实现高效的GPU-CPU数据搬运。
Swap的核心流程如下:当调度器决定换出某个序列时,系统将该序列块表中的每个物理页面的KV Cache数据复制到CPU端预分配的缓冲区,然后释放对应的GPU物理页面。被换出的序列暂时挂起,直到有足够的GPU空闲页面时再将其换回。
# 模拟Swap机制 class SwapManager: """KV Cache的GPU-CPU交换管理""" def __init__(self, gpu_allocator: BlockAllocator, cpu_pool_size: int = 5000): self.gpu = gpu_allocator self.cpu_swap_map = {} # {gpu_block_id: cpu_block_id} self.cpu_free = list(range(cpu_pool_size)) self.swapped_seqs = {} # {seq_id: [gpu_block_ids被换出]} def swap_out(self, seq_id: int): """将序列的KV Cache从GPU换出到CPU""" if seq_id not in self.gpu.allocated_blocks: return blocks = self.gpu.allocated_blocks[seq_id] if len(self.cpu_free) < len(blocks): raise RuntimeError("CPU swap空间不足") swapped = [] for gpu_block in blocks: cpu_block = self.cpu_free.pop() self.cpu_swap_map[gpu_block] = cpu_block swapped.append(gpu_block) # 释放GPU端页面 self.gpu.free(seq_id) self.swapped_seqs[seq_id] = swapped print(f"[Swap-Out] 序列{seq_id}: {len(swapped)}块换出到CPU") def swap_in(self, seq_id: int): """将序列的KV Cache从CPU换回GPU""" if seq_id not in self.swapped_seqs: return swapped = self.swapped_seqs[seq_id] if not self.gpu.try_allocate(len(swapped)): raise RuntimeError("GPU空间不足,无法换回") for gpu_block in swapped: cpu_block = self.cpu_swap_map.pop(gpu_block) self.cpu_free.append(cpu_block) self.gpu.allocate(seq_id, len(swapped)) del self.swapped_seqs[seq_id] print(f"[Swap-In] 序列{seq_id}: {len(swapped)}块换回GPU") # 演示 alloc = BlockAllocator(num_blocks=100) alloc.allocate(1, 80) # 请求1占用80块 alloc.allocate(2, 15) # 请求2占用15块 print(f"Swap前: {alloc.stats()}") sm = SwapManager(alloc, cpu_pool_size=200) sm.swap_out(1) # 换出请求1 print(f"Swap后: {alloc.stats()}") alloc.allocate(3, 90) # 现在有空间接纳请求3 print(f"接纳请求3后: {alloc.stats()}")
当CPU Swap也不可行(例如CPU内存同样不足)时,vLLM会采用Preemption策略:完全取消某个低优先级请求,释放其所有资源,将GPU空间让给新请求。被抢占的请求可以稍后重新排队,从头开始Prefill。
vLLM的抢占策略支持两种模式:
在真实的推理服务中,GPU显存是一个被多个并发请求共享的资源池。PagedAttention的页面级管理使得这种池化变得极为高效和灵活。
vLLM使用一个全局块池来管理所有物理页面,所有请求从这个统一的池中分配和归还页面。这种设计有几个重要优势:
# 模拟全局块池的多请求管理 class GlobalBlockPool: """全局块池模拟""" def __init__(self, num_blocks: int, block_size: int = 16): self.block_size = block_size self.free_blocks = list(range(num_blocks)) self.num_blocks = num_blocks self.seq_blocks = {} self.seq_tokens = {} def admit_request(self, seq_id: int, estimated_tokens: int) -> bool: """判断是否可以接纳新请求""" blocks_needed = (estimated_tokens + self.block_size - 1) // self.block_size # 保留10%的缓冲 if len(self.free_blocks) < blocks_needed * 1.1: return False return True def allocate_and_track(self, seq_id: int, num_tokens: int): """分配并跟踪""" blocks_needed = (num_tokens + self.block_size - 1) // self.block_size blocks = [] for _ in range(blocks_needed): blocks.append(self.free_blocks.pop()) self.seq_blocks[seq_id] = blocks self.seq_tokens[seq_id] = num_tokens def add_decode_tokens(self, seq_id: int, new_tokens: int): """Decode阶段追加token""" old_total = self.seq_tokens[seq_id] new_total = old_total + new_tokens old_blocks = (old_total + self.block_size - 1) // self.block_size new_blocks = (new_total + self.block_size - 1) // self.block_size if new_blocks > old_blocks: extra = new_blocks - old_blocks if len(self.free_blocks) < extra: raise RuntimeError("OOM during decode") for _ in range(extra): self.seq_blocks[seq_id].append(self.free_blocks.pop()) self.seq_tokens[seq_id] = new_total def complete_request(self, seq_id: int): """请求完成,释放所有块""" self.free_blocks.extend(self.seq_blocks[seq_id]) del self.seq_blocks[seq_id] del self.seq_tokens[seq_id] # 模拟一个完整的推理服务周期 pool = GlobalBlockPool(num_blocks=500, block_size=16) print(f"初始空闲块: {len(pool.free_blocks)}") # 阶段1:接纳3个请求 pool.allocate_and_track(1, 200) # 13块 pool.allocate_and_track(2, 150) # 10块 pool.allocate_and_track(3, 300) # 19块 print(f"接纳3个请求后空闲块: {len(pool.free_blocks)}") # 阶段2:Decode生成 for _ in range(100): # 每个请求生成100个token pool.add_decode_tokens(1, 1) pool.add_decode_tokens(2, 1) pool.add_decode_tokens(3, 1) print(f"生成100步后空闲块: {len(pool.free_blocks)}") # 阶段3:请求1完成 pool.complete_request(1) print(f"请求1完成后空闲块: {len(pool.free_blocks)}")
显存监控是保障推理服务稳定运行的重要手段。vLLM提供了多种监控指标,帮助运维人员了解系统的显存使用状况并及时进行调优。
vLLM在运行过程中持续跟踪以下关键指标:
# 模拟显存监控面板 class MemoryMonitor: """显存使用监控器""" def __init__(self, total_blocks: int): self.total_blocks = total_blocks self.metrics_history = [] def record(self, timestamp: str, free_blocks: int, active_requests: int, waiting: int, swap_events: int = 0, preempt_events: int = 0): """记录一次监控数据""" used = self.total_blocks - free_blocks utilization = used / self.total_blocks * 100 entry = { 'time': timestamp, 'used_blocks': used, 'free_blocks': free_blocks, 'utilization_pct': utilization, 'active_requests': active_requests, 'waiting': waiting, 'swap_events': swap_events, 'preempt_events': preempt_events, 'health': self._assess_health(utilization, waiting, swap_events) } self.metrics_history.append(entry) return entry def _assess_health(self, utilization: float, waiting: int, swaps: int) -> str: if swaps > 10 or waiting > 50: return "CRITICAL - 大量Swap或等待,需要扩容" elif utilization > 95 or waiting > 20: return "WARNING - 显存紧张,关注等待队列" elif utilization > 80: return "GOOD - 高效利用" else: return "OK - 运行正常,显存充裕" monitor = MemoryMonitor(total_blocks=1000) samples = [ ("10:00", 700, 8, 0), ("10:05", 500, 15, 2), ("10:10", 200, 25, 8, 3), ("10:15", 100, 28, 15, 12), ("10:20", 50, 25, 22, 8, 2), ] for s in samples: entry = monitor.record(*s) print(f"{entry['time']}: 利用率={entry['utilization_pct']:.0f}%, " f"活跃={entry['active_requests']}, 等待={entry['waiting']}, " f"状态={entry['health']}")
基于监控数据,vLLM支持以下自动调优手段:
不同的语言模型参数规模对页面级显存管理提出了不同的要求。以下是几种典型模型在24GB GPU(如RTX 4090)上的显存管理分析:
| 模型规模 | 每Token KV Cache | 最大并发序列数(2048 token) | 关键管理策略 |
|---|---|---|---|
| 7B (LLaMA) | ~16KB | ~920 | 基础页面管理即可满足 |
| 13B (LLaMA) | ~32KB | ~460 | 需要关注Swap策略 |
| 70B (LLaMA) | ~160KB | ~90 | 必须启用CoW共享 |
| 175B (GPT-3) | ~480KB | ~30 | 需要多GPU张量并行 |
对于大参数模型(70B+),单纯的页面管理不足以解决显存瓶颈,还需要结合张量并行将模型权重和KV Cache分布到多个GPU上,每个GPU管理自己的局部页面池。页面管理策略在不同模型规模下的核心思想保持一致,但具体的页面大小、分配策略和Swap策略需要根据模型的KV Cache特征进行调优。
通过本节的学习,我们深入理解了页面级显存管理的核心技术和实现细节。从碎片问题的彻底解决方案到动态页面分配策略,从Swap和Preemption的OOM应对机制到全局块池的多请求管理,从监控指标体系到自动调优策略,我们掌握了完整的显存管理体系。这些技术共同构成了PagedAttention高效利用GPU资源的核心能力,使得vLLM能够在相同硬件条件下实现数倍于传统框架的推理吞吐量。