大家好,今天想来介绍下当红推理框架vLLM的核心技术PagedAttention。PagedAttention的设计灵感来自操作系统的虚拟内存分页管理技术。vLLM的论文是在假设读者对这项分页管理技术非常熟悉的情况下,对PagedAttention进行介绍的,这对一些非计算机专业出身,或者对操作系统相关知识有所遗忘的读者来说并不友好。 因此,本文进行介绍时,会对照着操作系统的相关知识,和大家一起来看vLLM是如何“一步步”从传统方法进化到PagedAttention的,同时本文会尽量将抽象的显存优化知识通过图解的方式向大家说明。 全文目录如下: 一、LLM推理的两阶段 二、为KV cache分配存储空间的传统方法 三、PagedAttention原理 四、示例:PagedAttention在不同decoding场景下的运作流程和优势 五、vLLM的调度与抢占 六、分布式管理 一、LLM推理的两阶段一个常规的LLM推理过程通常分为两个阶段:prefill和decode。通常会使用KV cache技术加速推理。 1.1 Prefill预填充阶段。在这个阶段中,我们把整段prompt喂给模型做forward计算。如果采用KV cache技术,在这个阶段中我们会把prompt过后得到的保存在cache_k和cache_v中。这样在对后面的token计算attention时,我们就不需要对前面的token重复计算了,可以帮助我们节省推理时间。 在上面的图例中,我们假设prompt中含有3个token,prefill阶段结束后,这三个token相关的KV值都被装进了cache。 1.2 Decode生成response的阶段。在这个阶段中,我们根据prompt的prefill结果,一个token一个token地生成response。 同样,如果采用了KV cache,则每走完一个decode过程,我们就把对应response token的KV值存入cache中,以便能加速计算。例如对于图中的t4,它与cache中t0~t3的KV值计算完attention后,就把自己的KV值也装进cache中。对t6也是同理。 由于Decode阶段的是逐一生成token的,因此它不能像prefill阶段那样能做大段prompt的并行计算,所以在LLM推理过程中,Decode阶段的耗时一般是更大的。 从上述过程中,我们可以发现使用KV cache做推理时的一些特点:
下图展示了一个13B的模型在A100 40GB的gpu上做推理时的显存占用分配(others表示forward过程中产生的activation的大小,这些activation你可以认为是转瞬即逝的,即用完则废,因此它们占据的显存不大),从这张图中我们可以直观感受到推理中KV cache对显存的占用。因此,如何优化KV cache,节省显存,提高推理吞吐量,就成了LLM推理框架需要解决的重点问题。 二、为KV cache分配存储空间的常规方式对于训练好的模型,一种常用的部署方式是将其打包成一个推理服务(server),它接收客户端发送来的请求(request),读取请求中的数据(prompt)来做推理。一个请求中可以只有1个prompt,也可以包含多个prompt。 在常规的推理框架中,当我们的服务接收到一条请求时,它会为这条请求中的prompts分配gpu显存空间,其中就包括对KV cache的分配。由于推理所生成的序列长度大小是无法事先预知的,所以大部分框架会按照(batch_size, max_seq_len)这样的固定尺寸,在gpu显存上预先为一条请求开辟一块连续的矩形存储空间。然而,这样的分配方法很容易引起“gpu显存利用不足”的问题,进而影响模型推理时的吞吐量。你可能觉得这个描述有点抽象,别着急,我们来具体看一个例子。 下图展示了一个常规的推理框架是如何为请求中的prompt在gpu显存上分配KV cache的。在本例中,我们假设一个请求只发送1条prompt(本例中共有3条请求): 我们假设 当第2条请求(prompt2)过来时,同样也需要1块(1, 8)大小的存储空间。但此时prompt1所在的位置上,只剩3个空格子了,所以它只能另起一行做存储。对prompt3也是同理。 仔细观察这3条prompt的KV cache排布,你是不是隐约觉得这种排布似乎没有充分利用起gpu的显存?:
观察整个KV cache排布,你会发现它们的毛病在于太过“静态化”。当你无法预知序列大小时,你为什么一定要死板地为每个序列预留KV cache空间呢?为什么不能做得更动态化一些,即“用多少占多少”呢?这样我们就能减少上述这些存储碎片,使得每一时刻推理服务能处理的请求更多,提高吞吐量,这就是vLLM在做的核心事情,我们先通过一张实验图来感受下vLLM在显存利用上的改进效果(VS 其它推理框架): 不难发现,相比于别的推理框架,vLLM几乎能做到将显存完全打满。 读到这里,你可能会有以下疑问:
在后文的第三~四章,我们将回答问题1。第五章回答问题2。 三、PagedAttention原理在本节中,我们先来回答问题1:vLLM通过一种名为PagedAttention的技术,动态地为请求分配KV cache显存,提升显存利用率。 整体上来说,PagedAttention的设计灵感来自操作系统中虚拟内存的分页管理技术。所以本节会先通过通俗易懂的方式,和大家一起快速回顾操作系统的虚拟内存技术,在这个过程中和大家一起具象化感受PagedAttention的设计思想。然后再来详细介绍PagedAttention的运作流程。 3.1 操作系统的虚拟内存(1)不使用虚拟内存我们知道程序运行时,会将代码、数据等内容存放在物理内存上。在最原始的做法中(没有操作系统,例如单片机),程序直接对物理内存进行操作,决定使用它的哪些存储地址。 如果你只跑一个进程,那还好说。但如果需要运行多个进程时,麻烦就来了:由于我直接操作了物理内存地址,所以我在为自己的进程分配物理内存时,还要考虑别的进程是如何分配物理内存的(别人已经占用的我不能用)。这样不同进程间的耦合性太高了,给开发带来难度。 有没有一种办法,让各个进程间的开发能够相互独立呢?一种直觉的做法是:
虚拟内存的核心思想可简化成下图: (2)虚拟内存的分段管理在分段式内存管理中,虚拟内存会尽量为每个进程在物理内存上找到一块连续的存储空间,让进程加载自己的全部代码、数据等内容。我们来看一个具体的例子: 在这个例子中,3个进程的虚拟内存各自为它们在物理内存上映射了一块连续的存储空间。在某一时刻,我释放了进程2,同时想运行进程4。这时我尴尬地发现,虽然物理内存上有640M的空间剩余,但因为是碎片化的,我的进程4无法加载进去,因此它只能等待(回想一下本文第二部分对传统KV cache显存分配的分析)。 在这个情况下,如果我硬要运行进程4,也是有办法的:我可以先把进程3从物理内存上交换(swap)到磁盘上,然后把进程4装进来,然后再把进程3从磁盘上加载回来。通过这种方法我重新整合了碎片,让进程4能够运行。 但这种办法的显著缺点是:如果进程3过大,同时内存到磁盘的带宽又不够,整个交换的过程就会非常卡顿。这就是分段式内存管理的缺陷。 这时,我自然而然会想到:我为什么要给所有进程都预分配一个固定的存储块(段)呢?假设这个进程是一个浏览器,我难道会一下就用到这个进程里所有的功能吗?就不能进程运行到哪里,或者我想用哪个具体功能时,再加载这部分相关的内容去内存,以此让整个内存分配更加动态? (3)虚拟内存的分页管理我们可以将进程1、进程2想成是两本书。代码分布在书的不同page上。我们希望想读哪一页,就加载哪一页,而不是一下把两本书都加载进来。同时,当我们不想读某些页的时候,我们也能根据页码将其清空。 现在,我们希望读进程1和进程2的page1,我们就将其加载到物理内存上。虚拟内存会帮我们做好映射,把来自不同进程的这两页分别加载到物理内存对应位置。 虚拟内存的分业管理技术总结起来就是:
3.2 PagedAttention(1)处理单个请求现在,你已经知道虚拟内存分页管理的基本原理和优势,趁热打铁,我们马上来看以其为灵感的PagedAttention技术是如何操作的。我们还是从具体的例子讲起。 假设现在你向模型server发送一条请求,prompt为 在图中:
图中带圈的序号表示操作步骤,我们就按这个顺序来看看。 (i) Prefill阶段
(ii)Decode阶段-生成第1个词
(iii)Deocde阶段-生成第2个词类比步骤(2)来进行。 (2)处理多个请求有了(1)的解释,大家看懂这张图应该不难。通过多个请求(prompt)同时做推理的例子,大家可以更好感受到PagedAttention是如何通过动态存储KV cache的方式,来更充分利用gpu显存的。 四、PagedAttention在不同解码场景下的例子通过前文的解释,我们已经基本掌握了PagedAttention的设计思想、运作流程。你可能隐隐能感受到它在显存管理上的“灵活性”,和减少碎片化显存的能力。但可能你觉得还不够具象,所以在本节中,我们通过更具体的场景,再假设一下对PagedAttention优势的理解。 我们知道,根据实际需求,大模型的解码方式也比较复杂,例如:
在下文里,我们会详细解释PagedAttention在Parallel Sampling和Beam Search场景上的优势。剩余两个场景读者可以自行做类比分析。 4.1 Parallel Sampling下面说明在parallel sampling的场景下,vLLM(PagedAttention)是怎么做到节省显存的。 传统KV cache怎么做:假设模型的max_seq_len = 2048。传统KV cache可能在显存中分配两块长度是2048的空间。由于prompt一致,这两块2048的空间中存在大量重复的KV cache。 vLLM怎么做: 假定我们发给模型1个request,这个request中包含2个prompt/sample,记为Sample A1和Sample A2,这两个prompt完全一致,都为 (1)首先,Prefill阶段,vLLM拿到Sample A1和Sample A2,根据其中的文字内容,为其分配逻辑块和物理块。
(2)然后,进入decode阶段,A1和A2各自做推理,得到第一个token,分别为fathers和mothers。
总结起来,vLLM节省KV cache显存的核心思想是,对于相同数据对应的KV cache,能复用则尽量复用;无法复用时,再考虑开辟新的物理空间。 4.2 Beam Search我们从右往左来看这张图。虚线位置表示“当前decoding时刻”,beam width = 4。图中所有的block皆为逻辑块。 因为beam width = 4,这意味着根据beam search算法,在当前阶段我们生成了top 4个概率最大的token(我们记这4个token为beam candidate 0/1/2/3),它们分别装在block5,block6,block7和block8中。 现在我们继续使用beam search算法做decoding,继续找出top 4个最可能的next token。经过我们的计算,这top 4 next token,有2个来自beam candidate 1,有2个来自beam candidate 2。因此我们在block6中引出block9和block10,用于装其中两个top 2 next token;对block7也是同理。 现在,block9/10/11/12中装的top 4 next token,就成为新的beam candidates,可以按照和上述一样的方式继续做beam search算法。而对于block5和block8,它们已经在beam search的搜索算法中被淘汰了,后续生成的token也不会和它们产生关系,所以可以清除掉这两个逻辑块,并释放它们对应的物理块的内存空间。 好,我们继续往左边来看这幅图。block3引出block5/6/7,block4引出block8,这意味着当前这4个top4 token,是上一个timestep下candidate1和candidate3相关序列生成的(candidate0和2的block没有画出,是因为它们所在的序列被beam search算法淘汰了,因此没有画出的必要)。由于block8已经被淘汰,所以block4也相继被淘汰,并释放对应的物理内存空间。 由此往左一路推,直到block0为止(block0代表着prompt,因此被beam seach中所有的序列共享)。这一路上,我们都根据最新时刻的beam search decoding结果,释放掉不再被需要的逻辑块和对应的物理内存空间,达到节省显存的目的。 五、调度和抢占到目前为止,我们已经回答了“vLLM是如何优化KV cache显存分配”的问题,现在我们来回答另一个重要的问题:
5.1 总原则当有一堆请求来到vLLM服务器上时,vLLM需要一个调度原则来安排如何执行这些请求,这个调度原则概括如下:
(1)先来的请求先被服务这个很好理解,当有一堆请求到达vLLM服务器时,vLLM肯定优先处理来得早的请求 (2)后来的请求先被抢占想象一下,当一堆请求来到vLLM服务器做推理,导致gpu显存不足时,vLLM会怎么做呢? 最直接的办法,就是暂停这堆请求中最后到达的那些请求的推理,同时将它们相关的KV cache从gpu上释放掉,以便为更早到达的请求留出足够的gpu空间,让它们完成推理任务。如果不这样做的话,各个请求间相互争夺gpu资源,最终将导致没有任何一个请求能完成推理任务。等到先来的请求做完了推理,vLLM调度器认为gpu上有足够的空间了,就能恢复那些被中断的请求的执行了。 在资源不足的情况下,暂时中断一些任务的执行,这样的举动就被称为“抢占(preemption)”。 5.2 终止和恢复被抢占的请求对于这些因gpu资源不足而被抢占的任务,vLLM要完成两件事:
针对这两件事,vLLM分别设计了Swapping(交换策略)和Recomputation(重计算策略)来解决。我们来细看这两个策略。 (1)Swapping对于被抢占的请求,vLLM要将其KV cache从gpu上释放掉,那么:
先看问题1。由前文PagedAttention原理可知,一个请求可能对应多个block。我们既可以选择释放掉部分block,也可以选择释放掉全部block,或者更科学地,我们可以预测一下哪些block被使用的频率最低,然后释放掉这些低频block(但这种方式实现起来难度较大,性价比不是很高)。在vLLM中,采取的是all-or-nothing策略,即释放被抢占请求的所有block。 再来看问题2。对于这些被选中要释放的KV block,如果将它们直接丢掉,那未免过于浪费。vLLM采用的做法是将其从gpu上交换(Swap)到cpu上。这样等到gpu显存充份时,再把这些block从cpu上重载回来。 (2)Recomputation知道了Swapping机制,重计算的过程也很好理解了:当vLLM调度器任务gpu资源充足时,对于那些被抢占的请求,它会将其卸载到cpu上的KV block重新加载进gpu中,继续完成推理任务。 (3)总结好,到这里,我们总结一下vLLM对请求的调度处理流程:
六、分布式管理在本文的最后部分,我们再来看看分布式环境下vLLM的整体架构。本文不再对vLLM的性能实验部分做说明,感兴趣的朋友可以自行阅读。 在LLM推理实操中,某些场景下单卡是完成不了推理的,需要多卡。那么对于多gpu这种更普适性的情况,vLLM是怎么处理的呢? 上图显示了在分布式场景下,vLLM的整体运作流程:
上图中给出的例子,是用张量模型并行(megatron-lm)做分布式推理时的情况,所以图中每个worker上写的是model shard。在张量并行中,各卡上的输入数据相同,只是各卡负责计算不同head的KV cache。所以这种情况下,各卡上的逻辑块-物理块的映射关系其实是相同的(用的同一张block table),只是各卡上物理块中实际存储的数据不同而已。 |
|