Paged Attention&Orca Paper笔记与解读
序
正如PagedAttention Paper开篇第一句话所说

ORCA: A Distributed Serving System for Transformer-Based Generative Models
Paper地址:https://www.usenix.org/system/files/osdi22-yu.pdf
会议视频:https://www.youtube.com/watch?v=Ob9PPLxETYU&t=1s

Orca的目标问题
在CPU计算系统中对计算资源的管理,以OLTP,OLAP系统分类讨论。OLTP系统,典型以http/rpc等服务来说,一般以线程池为硬件计算资源在操作系统中的映射,随即典型以golang、python的asyncio、erlang otp等M:N类协程调度机制组织。典型的OLTP的服务应用中一般不存在直接利用CPU SIMD的场景,协程/线程之间的计算是不做合并的。在OLAP系统中,使用SIMD就比较常见,如Spark/Doris等“矢量化”计算的场景;因为OLAP的计算一般任务比较复杂且容易并行化,调度系统的职责就是将其切成“小块”的同质化运算,如对一批同类型的数据做同一聚合函数。但OLAP的计算隔离性一般也以task/query为维度隔离,不同的任务之间是争抢的关系。这类OLAP系统可以使用如k8s分布式虚拟化CPU,来将计算资源切分成小块,提供最大化调度的机会。
在GPU计算系统中,最大的区别是,并行化本身这件事,是运行在硬件上的。本质差别是什么?本质是单个CPU的执行是线性的,因此CPU单位的所谓并行,是用软件层的调度塞满CPU的执行流水线。任何形式的CPU调度,无论是线程池/虚拟化,都是为了安排好空闲等待的任务不去占用CPU。而GPU的执行任何时候都是批量的。因此如果GPU计算的最小“请求”单位,无法在单次计算中填满GPU,那当前批次的计算就是浪费的。于是,GPU调度的系统需要去跨请求,寻求在整体系统层面的最大化利用。
总的来说,Orca或其他llm serving system的课题之一就是如何合并请求来最大化GPU的单次计算,提升吞吐量/降低延时。
Naive地来说,假设我们的baseline是一个非常朴素的,等待一批,处理一批的系统,他存在哪些问题以及有什么优化手段?这个Orca和vLLM讨论的核心命题。
Orca的Solution
batching策略上的几个挑战:
(1)以请求为最小颗粒调度时,调度的粒度不对齐;存在早结束(ealy-finished)和晚到等待(late-joining)的请求。
- 早结束;意味着一个batch的请求时长 跟随max_output_length的请求一起结束,延长了所有请求的完成时间
- 晚到等待:意味着下一个batch的请求,其等待时间也是上一个batch中最长请求的完成时间。
- 因此假设是naive的batching思路,“集合一批,处理一批”,latency取每一批中的最长请求为基本单位,且存在计算资源的闲置和内存的空占。
- iterative-level的思路也很简单,自回归本身即循环调度f(x),因此不必以request为最小颗粒,而是可以以一批对f(x)的调度为batching;那就可以将输出长度为n的请求和长度为m的请求视作(m+n)次调度,当某请求完成时,即退出调度;新的请求可以进入调度。
(2)在解决1的基础上,使用iterative-level,即遇到第二个问题,tensor对齐
- 在训练或request-granularity的推理中,计算之所以可以batching,是因为self-attention计算框架约定了[batch_size, max_token_length, hidden_size]这样的tensor shape。此处可以分别做一个讨论,在问题框架上,将推理prefill和decode by step。
- prefill阶段的naive batching可以认为是将token长度简单的padding到max_token_length可以解决
- decode by step阶段,应用简易的kv cache思想,q是当前新增token的一维矩阵,多个请求batching,q的数量可以放置在batch维度中,设置为[n, 1, hidden_size];K, V为前序token缓存二维矩阵,其shape为 [n, ?, hidden_size] —— 在iterative-level的调度中,K, V的长度不尽相同;若推理时,仍然按照潜在可能的max_token_length对齐,内存空间的使用依然是按照潜在最长输出占用。
- 当然latency上有优化,允许已完成的请求退出,允许等待的请求开始。理想的状况是,一个bacth中的多个请求,内存占用与真实的序列长度线性增长。
- selective-batching:思考batch_size维度在计算上存在的意义,先不考虑multi-head这个因素。同一个batch内,除了scaled-dot attention的QK计算需要行列交互,其他的计算如MLP,softmax,layer norm——更不用说element-wise,都是在行内的操作。因此若假设两个长度不一的请求(两段prompt),假设长度为s1, s2;在原始计算框架下需要合并成两个batch,[2, max(s1, s2), hidden_size];而在selective-batching方法中,可以合并成[1, s1 + s2, hidden_size]。在non-request-aware operation中,可以当做一个请求处理;在执行attention时,再拆成两个请求(也许是[2, max(s1, s2), hidden_size])。
- selective-batching不仅可以合并同样在prefill阶段的不同长度请求,在另一个方面也让prefill阶段和decode by step阶段可以合并计算;prefill阶段的输入是长度为任意s的用户promot;decode by step阶段输入是长度为1的last token配合有状态的kv cache manager。在计算时,系统只需要在attention时,能够区分(1) 不同请求的划分 (2)不同请求的阶段 - 决定是否需要查询kv cache参与计算。
- 🤔思考:在selective-batching的框架下,是否需要考虑[X, hidden_size]的形状对GPU计算效率的影响。X可能会变得远大于hidden_size。
(3)Orca scheduling algorithm: iterative-level和selective-batching解决了“颗粒”和“颗粒“细化后的合并问题;下一个Orca讨论的命题就是,面对N个request产生的M个iteration全集里,在时刻t的由(requtest_i, iteration_j)组成的request pool里,每一个调度计算时,如何选择的出一个集合执行计算的问题。
- Orca遵循先进先出(文中称FCFS,first come first serve)的原则;对请求到达时间排序,并以内存资源为优先的瓶颈,填充资源
- n_rsrv可以理解为KV cache使用的连续内存空间;每个request的prefill被执行时,会扣除request.max_token这么大的资源预占(这也是paged attention要解决的问题);而因为按到达序排序,因此prefill之后的请求会稳定地出现在之后批次的serve batch里,直到请求结束,占用的KV cache slots被释放。
- PagedAttention主要优化的问题,即KV cache的生命周期管理与分布方式 & 具体地与decoding algo结合的使用场景。

(4)paper中讨论的分布式并行方式(intra/inter-layer),我感觉不在重点讨论范围。本文更关键的思路还是如何通过调度原语(split or merge),分配计算资源。
Efficient Memory Management for Large Language Model Serving with PagedAttention
paper地址:https://arxiv.org/pdf/2309.06180
PagedAttention的目标问题
基于Orca这样解决了调度Batching问题的系统基础上,还有哪些地方的优化可以提升系统整体的吞吐量和延时?
Insight:Orca系统中最显眼的“浪费”是在处理batching请求时,batch中的每个request都占用了request.max_token大小的显存用于存放KV cache;且该占用的生命周期是整个request存在的期间。直观上,显存的利用率是比较低的。
若引入一种机制,使得request占用的显存尽量贴近当前真实使用的大小,那么同样大小的GPU memory就大概率会允许更多的request进入当前batch(假设batch大小对计算不是瓶颈),从而在memory-bound假设的系统中吞吐量会上升,延时会降低。
此外Orca中还有一类“隐藏问题”没有被提及,LLM的decoding算法不总是基于贪心的单一线性路径,单一线性路径下token generation path是唯一的;若使用beam search这样的多分支搜索,如何管理KV cache的临时存储与释放?Naive的方法是在beam search时,每一个可能的N条路径都预占一份len == request.max_tokens的KV cache且需要在“路径”被淘汰时释放,在“路径”被扩展时copy,这显然是不可行的。因此执行beam search时,在KV cache管理上需要允许引用公共前缀与独立分叉的申请与释放。这样势必导致KV cache在至少一个路径上是不连续。于是是否KV cache一定要存放在连续显存中,成为了一个疑问。
PagedAttention的方法
引用文中Abstract的描述
On top of it, we build vLLM, an LLM serving system that achieves (1) near-zero waste in KV cache memory and (2) flexible sharing of KV cache within and across requests to further reduce memory usage.
一句话描述:模拟操作系统的虚拟内存,在KV cache需要时才实际占用显存。
随之而来的几个问题
- 如何定义一页/一块(page or block)
- OS VMM模块管理的是一一映射的对等概念,虚拟内存与物理内存单位都是byte,只是按一定的page size划分;无论OS层面运行什么样的软件,one page就是4kb/8kb/16kb等整数。而vLLM的管理对象是KV cache,是一个有具体的计算含义的变长实体。具体来说,一个Transformer-based的模型有attention layer count, hidden_size, head_size,其他还有一些算法变体等参数会影响单个token的KV Cache到底多大。此外无论如何,单个token的所有KV cache放在一起过于巨大(100kb~10MB之间),理应有一个“切割”的思考,按类(K or V)切 or 按层切 or 两个维度都切分,也是一个因素。
- 如何trade off非连续的KV cache存放机制与实际计算时内存连续的要求
- 当KV cache以物理上非连续的方式在逻辑数据结构中被索引时,前序token的KV矩阵就不再是可以直接被GPU矩阵乘法计算的对象;不像Orca中申请max_tokens为基数的连续内存方便。
- HBM不足时,evict策略是什么
- Orca的连续内存策略能够“悲观”地保证进入bacth的请求,有足够的内存资源能够完成选中的请求;但虚拟内存的方式,只在申请物理内存时,“乐观”地事后知晓物理内存分配不足;(1)若没有evict策略,直接请求失败,则会导致类似惊群或死锁的问题,请求集体失败,集体重试,再集体失败(2)若有evict策略,调度系统逐出一些潜在低效的占用,那么有若干个问题:evict的时机是什么,慢介质是什么,选择策略是什么,数量怎么决定
- 如何与类似beam search的搜索路径配合,复用的策略
问题1:以token为单位,定义one block的token size(block size);K V分开存放;异构模型size不同如何解决,文中并未提到。
问题2:分块计算当前q向量与K的乘积,并开发相应的cuda kernel进行优化;且vllm是一个分布式系统,以local compute为计算策略是显然的。这里会涉及到较多细节且复杂的模型并行通信原语。
问题3:此处文中提出了两种逐出介质,并在消融实验中声称各有优劣
- 引入sequence group的概念,意即完成一个请求所带来的需要KV cache,根据解码算法不同,一个request会产生一到多条推理sequence; 本文采用了all or nothing的策略,在sequence group的维度上,要么逐出sequence group的所有的KV cache,要么不逐出任何一块。因为vLLM假设不存在一种有收益的场景叫做,部分KV cache存在,可以先进行部分的计算。这种比iteration-level更为复杂的计算机制,超出了Orca/vLLM等最细粒度的框架假设。
- 解法一:逐出到CPU内存中;解法二:清除内容,后续重新计算,重新计算的速度是不慢的,重新获得kv cache就是经历一次新的prefill阶段;;需要考量内存显存带宽 pk 计算能力
- 请求调度的额外策略:当逐出发生时,系统就不再接受新的请求,直到先前被抢占的任务完成。这也是一个FCFS的原则(那么在被抢占任务完成前,系统是否有一段时间是跑不满的?);文中讲到这获得了一个额外的特性,CPU RAM作为缓冲区,容量需求不会超过GPU HBM大小。
问题4:引入reference count + copy on write复用与释放内存
- 并行decoding采样。并行decoding采样定义:N个互不相关的随机采样序列以特定的随机参数运行。在这个场景下,request prompt是确定的N个sequence的prefix,token长度为L的prompt会产生至少向下取整(L/block_size)个完整的block,这些block在request生命周期中被每一个sequence的每一次generation复用;最后 < block_size个prompt的KV cache填不满一页,会被copy N份用于后续采样的一部分。
- beam search采样过程中,更复杂的点在于,路径会动态地生成与销毁。prompt也是确定可以被多个sequence复用的;进行过程中对token产生的block进行引用计数;beam search with k的设定下,t时刻下,m(m≤k)条路径依赖来自t-1时刻产生的前缀,那么reference count记为m。在t+1时刻,m条路径会派生出m*k条新路径参与下一轮概率选择,其中有[0, k]个路径会生出,由此也会决定,在t时刻,reference count是[0, k]中的一个数字。若某block的reference count降为0,则释放。
- 这两类采样算法,在PagedAttention中的原理完全是一致的。通过reference count + copy on write实现了复用,区别只是fork和reference count的操作时机不同。前者的fork只存在第一个token生成时(若token length与block size是整数关系,则没有fork过程);后者的fork在行进中随时会分叉。前者无需管理reference count;后者需要通过reference count管理释放时机。

总结
通过阅读Orca与Paged Attention Paper,大致了解了LLM serving中的一些基本问题。
但偏insight,还需要在实现中寻找更多的技术细节。
比如,vllm架构设计上是怎么样的,分为哪些模块;vllm管理分布式GPU是如何计算和通信的;哪些是kernel;block table是什么结构等
Prev
Megatron-LM中GEMM的推导
Next
较难无痛的各种PO眼保健操:VPG TRPO PPO DPO GRPO
Loading...