这篇深入拆解vLLM的三个杀手锏:请求块化内存管理减少碎片、Copy-on-Write共享解码省显存、continuous batching提升吞吐。你将学到每项技术的实现细节和协同效果。适合想深入理解vLLM高性能推理架构的系统和算法工程师学习参考,搞懂这三个技术才能理解vLLM为什么快。值得花时间深入学习这些内容。

岗位关联

需要学习PagedAttention vLLM:请求块化内存管理、Copy-on-Write共享解码、continuous batching调度,这是你的核心专业课,数据清洗、分析和可视化是你的日常工作。本篇涵盖数据清洗/ETL/数据仓库/特征工程,帮助你构建数据管道与洞察。

目录
  1. 可视化演示
  2. PagedAttention核心思想与请求块化内存管理
  3. KV Cache逻辑块物理块映射机制
  4. Copy-on-Write共享解码的显存优化原理
  5. continuous batching动态批处理调度策略
  6. 三项技术的协同效应与性能增益分析
  7. vLLM高性能推理架构的工程落地考量
  8. AI 应用场景
  9. Python 演示
  10. 常见问答
  11. 常见误区
  12. 参考来源

可视化演示

可视化展示vLLM高性能推理核心技术:PagedAttention分页内存管理、Copy-on-Write共享解码、Continuous Batching动态批处理,交互切换理解推理优化。

本节配有交互动效(进入视口后自动加载,约 3KB)。

PagedAttention分页内存管理 KV Cache分块存储 逻辑块映射物理块 KV = blocks[log\u2192phy] PagedAttention消除显存碎片大幅提升利用率

PagedAttention核心思想与请求块化内存管理

在传统的大模型推理框架中,KV Cache通常采用连续内存预分配策略:为每个请求预先分配其最大可能序列长度所需的连续显存空间。这种粗放的分配方式导致了严重的内部碎片(实际序列长度远小于预分配长度)和外部碎片(不同大小的空闲块散落在显存中无法利用)。研究表明,传统方案的实际显存利用率通常仅有约60%,大量宝贵的GPU显存被浪费在碎片上。

PagedAttention借鉴了操作系统中虚拟内存分页的核心思想,将KV Cache的存储从连续分配改为固定大小的块(Block)化管理。每个Block存储固定数量token(通常为16个)的Key和Value张量。请求的KV Cache不再需要占据一整块连续显存,而是可以分散存储在多个物理Block中,从而彻底消除外部碎片问题。

每个请求维护一张逻辑块到物理块的映射表(Block Table),类似于操作系统的页表。逻辑块编号代表请求KV序列中的第几个Block,物理块编号则指向GPU显存中实际存储该Block的位置。这种间接映射使得任意逻辑上连续的KV序列可以映射到物理上不连续的显存块上,极大提升了显存的分配灵活性。

通过块化管理,vLLM将显存利用率从传统方案的约60%提升至90%以上。近乎零浪费的显存管理意味着在相同GPU上可以同时服务更多的并发请求,直接带来吞吐量的大幅提升。同时,块化的管理方式也为后续的Copy-on-Write共享和动态批处理调度奠定了数据结构基础。

KV Cache逻辑块物理块映射机制

在PagedAttention的设计中,每个请求的KV Cache逻辑序列被划分为固定大小的逻辑块。例如,当一个请求生成了35个token且Block大小为16时,该请求需要3个逻辑块:前两个逻辑块各存储16个token的KV,第三个逻辑块存储剩余的3个token。逻辑块的编号从0开始递增,代表KV序列的逻辑顺序。

Block Table是连接逻辑块与物理块的桥梁,其结构类似于操作系统中的页表(Page Table)。Block Table是一个数组,索引为逻辑块编号,值为对应的物理块ID。例如,某请求的Block Table可能为[3, 7, 12],表示逻辑块0映射到物理块3,逻辑块1映射到物理块7,逻辑块2映射到物理块12。物理块在显存中可以是任意位置,无需连续。

在Attention计算过程中,当需要访问某个token的KV时,系统首先通过token位置计算出其所属的逻辑块编号和块内偏移,然后通过Block Table查找对应的物理块ID,最后从该物理块中读取KV数据。虽然多了一次Block Table查表的开销,但由于Block Table本身非常小(每个条目仅一个整数),查表操作的延迟可以忽略不计。

Block大小的选择是一个重要的工程权衡。Block大小通常为16个token,较大的Block可以减少Block Table的大小和查表开销,但会增加最后一个Block的内部碎片;较小的Block可以减少碎片,但会增加Block Table的大小和管理开销。vLLM的实验表明,16是一个在碎片率和管理开销之间取得良好平衡的默认值。

Copy-on-Write共享解码的显存优化原理

在大模型推理服务中,大量请求往往共享相同的前缀Prompt,例如系统指令、Few-shot示例等。在传统的独立KV Cache管理方式下,N个共享相同前缀的请求需要各自独立存储前缀对应的KV,造成N倍的显存浪费。PagedAttention通过Copy-on-Write(写时复制)机制,使得这些请求可以共享同一组物理KV Block,将N份前缀KV的存储开销降低为1份。

Copy-on-Write的核心思想是:当多个请求共享相同前缀时,它们的前缀token对应的逻辑块都映射到同一组物理块上。在Prefill阶段和Decoding阶段,只要请求之间尚未产生差异,它们就持续共享这些物理块。每个物理块维护一个引用计数(Reference Count),记录当前有多少个请求正在使用该块。

当某个请求在Decoding过程中生成了新的token,需要在已有Block中写入新的KV数据时,系统会检查该Block的引用计数。如果引用计数为1,说明只有当前请求使用该Block,可以直接写入;如果引用计数大于1,说明有其他请求也在共享该Block,此时需要先复制一份新的物理Block(Copy),更新当前请求的Block Table映射到新Block,然后在新Block中写入数据,同时将原Block的引用计数减1。这就是Copy-on-Write的“写时复制”语义。

引用计数机制还服务于Block的回收。当请求完成或被终止时,其所有KV Block的引用计数都会减1。当某个Block的引用计数降为0时,表示没有任何请求再使用该Block,系统可以将其回收到空闲Block池中,供后续请求分配使用。这种基于引用计数的Block回收机制确保了显存资源的高效循环利用。

continuous batching动态批处理调度策略

传统的静态批处理(Static Batching)策略将一个批次的请求打包在一起同时处理,整个批次必须等待其中最长的请求完成后才能输出结果并开始处理下一批。这种策略导致严重的GPU空闲问题:当批次中某些请求较早完成时,GPU的计算资源被闲置,无法服务新的请求。在请求长度差异较大的场景下,这种资源浪费尤为严重。

Continuous Batching(连续批处理)从根本上解决了静态批处理的GPU空闲问题。它的核心思想是将调度粒度从请求级别细化到token迭代级别:每生成一个token后,调度器都可以动态地将已完成的请求移出批次,并将等待队列中的新请求加入批次。这意味着GPU在每一个token迭代周期都能保持满载运行,不会因为个别请求的完成或等待而出现空闲。

在continuous batching的实现中,新请求可以在任意token迭代步骤加入当前批次。当请求到达时,调度器检查是否有足够的空闲物理Block来容纳该请求的初始KV Cache。如果有,则为该请求分配Block并将其加入当前批次;如果没有,则请求进入等待队列。类似地,当某个请求生成结束符(EOS)或被终止时,调度器在下一个迭代步骤将其从批次中移除,并回收其占用的所有物理Block。

Iteration级别的调度是continuous batching的关键特征。与请求级别的调度相比,它使得批次的组成在时间维度上持续变化,如同流水线一般源源不断地处理请求。这种调度方式配合PagedAttention的块化内存管理,使得系统可以在不中断计算的情况下灵活地插入和移除请求,从而将GPU的利用率推向极限。

三项技术的协同效应与性能增益分析

PagedAttention的块化内存管理是三项技术的基石。它将KV Cache从连续的大块显存拆分为固定大小的Block,这一基础数据结构变革使得后续的Copy-on-Write共享和continuous batching成为可能。没有块化,就无法实现细粒度的显存共享和动态分配。

Copy-on-Write共享解码直接依赖于块化的引用机制。只有当KV Cache被组织为独立的Block时,才能对共享的物理Block进行引用计数和写时复制。前缀相同的多个请求共享同一组物理Block,仅在Decoding分叉点触发Copy操作,这种精确到Block级别的共享粒度是连续内存方案无法实现的。

Continuous Batching同样依赖块化内存管理。在iteration级别动态插入和移除请求,要求系统能够快速分配和回收KV Cache空间。块化管理使得Block的分配和回收变为O(1)的操作(从空闲链表头取一个Block或归还一个Block),而连续内存方案中分配任意大小的连续空间可能需要复杂的内存整理。

三项技术的协同使得vLLM相比传统推理框架实现了2-4倍的吞吐量提升。PagedAttention消除了显存碎片,使得同等显存可以容纳更多并发请求;Copy-on-Write进一步压缩了共享前缀请求的显存占用;Continuous Batching则确保了GPU计算资源不被浪费。三者环环相扣,共同构成了vLLM高性能推理的核心引擎。

vLLM高性能推理架构的工程落地考量

Block大小的选择是vLLM工程部署中的第一个关键权衡。较大的Block(如32或64 token)可以减少Block Table的大小和查表开销,但会增加每个请求最后一个Block的内部碎片率;较小的Block(如4或8 token)可以降低碎片率,但会增加Block Table的大小、查表开销以及Block分配/回收的管理成本。vLLM默认采用16 token的Block大小,在大多数场景下是一个合理的折中选择。

GPU显存的分区管理是另一个重要的工程问题。在vLLM中,GPU显存被划分为三个主要区域:模型权重(Weights)、激活值(Activations)和KV Cache Blocks。模型权重在加载时固定占用一部分显存,激活值用于前向计算的中间结果,剩余显存则全部用于KV Cache Block Pool。如何合理划分这三部分的比例直接影响系统的并发能力和性能表现。

最大并发请求数受限于可用的物理KV Block数量。每个请求至少需要为其最大序列长度分配足够的Block。例如,如果可用物理Block为1000个,Block大小为16,最大序列长度为2048,则每个请求最多需要128个Block,理论最大并发数约为7个请求。实际并发数还受到请求实际序列长度的影响——短请求占用更少的Block,从而允许更高的并发度。

当物理Block不足以满足新请求时,vLLM提供了两种应对策略:显存交换(Swap)和重计算(Recompute)。Swap将暂时不活跃的请求的KV Block从GPU显存交换到CPU内存,待该请求重新活跃时再换回;Recompute则丢弃被抢占请求的KV Cache,在其重新获得资源时从头重新计算。Swap的延迟较低但受限于CPU-GPU间的数据传输带宽,Recompute无传输开销但需要额外的计算时间。

AI 应用场景

大模型推理服务高并发部署与显存优化

阶段:模型部署

解决的问题:某AI创业公司需要将开源7B大语言模型部署为在线API服务,预期需要支持至少100个并发请求。传统推理框架在单张A100 80GB GPU上仅能支持约15个并发请求,主要原因是KV Cache的连续内存预分配导致严重的显存碎片。引入vLLM后,通过PagedAttention的块化内存管理将显存利用率从约60%提升至90%以上,配合Copy-on-Write机制对共享系统Prompt的请求进行KV Block共享,最终在单卡上实现了120个并发请求的服务能力,吞吐量提升约8倍,满足了业务需求。

具体案例:某AI创业公司需要将开源7B大语言模型部署为在线API服务,预期需要支持至少100个并发请求。传统推理框架在单张A100 80GB GPU上仅能支持约15个并发请求,主要原因是KV Cache的连续内存预分配导致严重的显存碎片。引入vLLM后,通过PagedAttention的块化内存管理将显存利用率从约60%提升至90%以上,配合Copy-on-Write机制对共享系统Prompt的请求进行KV Block共享,最终在单卡上实现了120个并发请求的服务能力,吞吐量提升约8倍,满足了业务需求。

长上下文与多轮对话推理的显存高效管理

阶段:推理优化

解决的问题:在多轮对话场景中,每个请求的上下文长度随对话轮次持续增长,KV Cache的显存占用不断膨胀。某客服系统使用长上下文模型处理多轮对话,平均上下文长度达到8K token。通过vLLM的PagedAttention块化管理,系统可以灵活地为每个请求按需分配KV Block,避免预分配造成的浪费。当物理Block不足时,系统利用Swap机制将不活跃对话的KV Block交换到CPU内存,待用户发起新消息时再换回GPU;对于被Swap后长时间未活跃的请求,则采用Recompute策略释放其KV Cache。配合continuous batching的动态调度,系统在有限的GPU显存下高效管理数千个长上下文对话的KV Cache。

具体案例:在多轮对话场景中,每个请求的上下文长度随对话轮次持续增长,KV Cache的显存占用不断膨胀。某客服系统使用长上下文模型处理多轮对话,平均上下文长度达到8K token。通过vLLM的PagedAttention块化管理,系统可以灵活地为每个请求按需分配KV Block,避免预分配造成的浪费。当物理Block不足时,系统利用Swap机制将不活跃对话的KV Block交换到CPU内存,待用户发起新消息时再换回GPU;对于被Swap后长时间未活跃的请求,则采用Recompute策略释放其KV Cache。配合continuous batching的动态调度,系统在有限的GPU显存下高效管理数千个长上下文对话的KV Cache。

Python 演示

python118 行
class BlockAllocator:    """模拟PagedAttention的物理块分配器"""    def __init__(self, num_blocks, block_size=16):        self.block_size = block_size        self.free_blocks = list(range(num_blocks))        self.ref_counts = {}  # 物理块ID -> 引用计数     def allocate(self):        """分配一个空闲物理块"""        if not self.free_blocks:            raise MemoryError("无可用物理块,触发OOM")        block_id = self.free_blocks.pop(0)        self.ref_counts[block_id] = 1        return block_id     def free(self, block_id):        """释放物理块,引用计数减1,降为0时回收"""        self.ref_counts[block_id] -= 1        if self.ref_counts[block_id] == 0:            self.free_blocks.append(block_id)            del self.ref_counts[block_id]     def increment_ref(self, block_id):        """增加引用计数(用于共享)"""        self.ref_counts[block_id] += 1  class PagedKVCache:    """模拟PagedAttention的KV Cache管理"""    def __init__(self, allocator):        self.allocator = allocator        self.block_table = {}  # request_id -> [物理块ID列表]     def allocate_blocks(self, request_id, num_tokens):        """为请求分配足够的KV块"""        block_size = self.allocator.block_size        num_blocks = (num_tokens + block_size - 1) // block_size        blocks = []        for _ in range(num_blocks):            blocks.append(self.allocator.allocate())        self.block_table[request_id] = blocks        return blocks     def copy_on_write(self, request_id, logical_block_idx):        """Copy-on-Write:当共享块需要写入时复制"""        phys_id = self.block_table[request_id][logical_block_idx]        if self.allocator.ref_counts[phys_id] > 1:            # 引用计数>1,需要复制            new_block = self.allocator.allocate()            self.allocator.free(phys_id)  # 原块引用-1            self.block_table[request_id][logical_block_idx] = new_block            return new_block  # 返回新块ID,调用者需复制数据        return phys_id  # 引用计数=1,可直接写入     def share_prefix(self, source_id, target_id, shared_blocks):        """共享前缀:target请求共享source的前N个块"""        self.block_table[target_id] = []        for i in range(shared_blocks):            src_block = self.block_table[source_id][i]            self.allocator.increment_ref(src_block)            self.block_table[target_id].append(src_block)     def release(self, request_id):        """释放请求的所有KV块"""        for block_id in self.block_table[request_id]:            self.allocator.free(block_id)        del self.block_table[request_id]  class ContinuousBatchScheduler:    """模拟continuous batching调度器"""    def __init__(self, kv_cache, max_batch_size=64):        self.kv_cache = kv_cache        self.max_batch_size = max_batch_size        self.running_batch = {}  # request_id -> status        self.waiting_queue = []     def add_request(self, request_id, prompt_len):        """添加新请求"""        self.waiting_queue.append((request_id, prompt_len))     def schedule_step(self):        """每个token迭代执行一次调度"""        # 移除已完成的请求        done = [rid for rid, st in self.running_batch.items() if st == "done"]        for rid in done:            self.kv_cache.release(rid)            del self.running_batch[rid]        # 从等待队列中加入新请求        while (self.waiting_queue and               len(self.running_batch) < self.max_batch_size):            rid, plen = self.waiting_queue.pop(0)            try:                self.kv_cache.allocate_blocks(rid, plen)                self.running_batch[rid] = "running"            except MemoryError:                self.waiting_queue.insert(0, (rid, plen))                break        return list(self.running_batch.keys())  # 演示alloc = BlockAllocator(num_blocks=100, block_size=16)kv = PagedKVCache(alloc)scheduler = ContinuousBatchScheduler(kv) # 添加请求并调度kv.allocate_blocks("req1", 32)kv.allocate_blocks("req2", 48)kv.share_prefix("req1", "req3", 2)  # req3共享req1的前2个块new_block = kv.copy_on_write("req3", 1)  # req3写入时触发CoWscheduler.add_request("req4", 64)batch = scheduler.schedule_step()print(f"当前批次: {batch}, 空闲块: {len(alloc.free_blocks)}")kv.release("req1")kv.release("req2")kv.release("req3")print(f"释放后空闲块: {len(alloc.free_blocks)}")
目的:模拟vLLM PagedAttention的核心机制,包括物理块分配与回收、逻辑块到物理块的Block Table映射、Copy-on-Write写时复制共享、以及continuous batching的动态调度逻辑
  1. BlockAllocator类管理物理块池,维护空闲块列表和引用计数
  2. allocate方法从空闲池中取出一个物理块并初始化引用计数为1
  3. free方法将引用计数减1,降为0时回收到空闲池
  4. PagedKVCache类管理每个请求的Block Table映射
  5. allocate_blocks根据token数量计算所需块数并分配物理块
  6. copy_on_write实现写时复制:引用计数>1时分配新块并复制
  7. share_prefix实现前缀共享:将源请求的物理块映射给目标请求并增加引用计数
  8. ContinuousBatchScheduler模拟iteration级别的动态批处理调度
  9. schedule_step在每个token迭代时移除完成请求、加入新请求
  10. 演示部分展示了块分配、前缀共享、Copy-on-Write和调度的完整流程

常见问答

PagedAttention借鉴了操作系统的什么核心思想?它是如何解决KV Cache显存碎片问题的?
PagedAttention借鉴了操作系统虚拟内存的分页(Paging)思想。传统方案为每个请求预分配连续的KV Cache空间,导致内部碎片和外部碎片。PagedAttention将KV Cache拆分为固定大小的Block(通常16 token),每个请求通过Block Table将逻辑块映射到物理上不连续的Block,从而消除外部碎片,将显存利用率从约60%提升至90%以上。
Copy-on-Write机制在vLLM中是如何工作的?什么情况下会触发Copy操作?
当多个请求共享相同前缀时,它们的前缀KV Block通过引用计数共享同一物理块。当某个请求需要在共享Block中写入新数据(如Decoding生成新token)时,系统检查该Block的引用计数:若引用计数为1,直接写入;若引用计数大于1,则先分配一个新的物理Block,将原Block数据复制到新Block,更新当前请求的Block Table指向新Block,并将原Block引用计数减1。这就是Copy-on-Write的'写时复制'语义。
continuous batching与传统的static batching有什么本质区别?
Static batching将一组请求打包处理,整个批次必须等待最慢的请求完成才能输出结果并加载下一批,导致GPU在等待期间空闲。Continuous batching将调度粒度细化到token迭代级别(iteration-level),每生成一个token后,调度器可以动态地将已完成请求移出批次、将新请求加入批次。这样GPU在每个迭代周期都能满载运行,大幅提升吞吐量。
vLLM中PagedAttention、Copy-on-Write和continuous batching三项技术是如何协同工作的?
PagedAttention的块化内存管理是基础,它将KV Cache组织为固定大小的Block并通过Block Table管理映射。Copy-on-Write依赖块化结构实现前缀共享——只有块化的Block才能被多个请求共享并通过引用计数管理。Continuous Batching依赖块化结构实现请求的动态插入和移除——块化的分配/回收是O(1)操作,使得iteration级别的调度成为可能。三者协同实现2-4倍的吞吐量提升。

常见误区

误区PagedAttention是一种新的注意力计算公式

正解PagedAttention不是一种新的注意力计算方式,而是一种显存管理创新。它改变了KV Cache的存储和组织方式(从连续分配改为块化分配),但注意力计算的数学公式(Q·K^T/√d → Softmax → ·V)完全不变。它优化的是'KV存在哪里、如何管理',而非'如何计算注意力'。

为何常见:名称中包含'Attention'容易让人误以为它修改了注意力机制本身。实际上PagedAttention的全称是Paged Attention memory management,重点在于内存管理而非计算方式。

误区Copy-on-Write共享前缀意味着所有请求都能共享任意KV块

正解Copy-on-Write共享仅适用于具有完全相同前缀token的请求。只有当前缀token完全一致时,对应的KV Block才能被共享。一旦请求之间在某个位置产生了不同的token(Decoding分叉点),后续的Block就无法共享,必须通过Copy操作分配独立的物理块。因此,共享的前提是前缀完全相同,而非任意请求都能共享。

为何常见:对'共享'一词的过度泛化理解,误以为所有请求的KV Cache都可以互相共享,忽略了共享的前提条件是前缀token完全一致。

误区continuous batching就是简单地多打包几个请求一起算

正解Continuous Batching不是简单的静态多请求打包(Static Batching)。它的核心特征是iteration级别的动态调度:每生成一个token后,调度器都可以动态地将已完成请求移出、将新请求加入当前批次。批次的组成在每个迭代步骤都可能变化,而非固定不变。此外,并发请求数受限于可用KV Block数量,不是无限制地增加批次大小。

为何常见:将continuous batching与传统的static batching混淆,认为只是'一次处理更多请求'。实际上continuous batching的关键在于动态调度能力,而非简单的批次大小增加。

参考来源