PagedAttention机制:显存碎片问题、传统KV Cache管理方式、PagedAttention核心思想、逻辑页与物理页映射
大家好,我是你们的老朋友。今天我们来聊聊vLLM里最核心的一个机制——PagedAttention。说实话,我第一次看到这个设计的时候,心里就一个想法:这玩意儿太聪明了。它解决了一个困扰大模型推理很久的痛点——显存管理。
咱们先从一个实际问题出发。你想想看,大模型推理的时候,每个请求都会生成一个KV Cache。这个Cache有多大呢?以LLaMA-13B为例,一个请求的KV Cache可能要占用几百MB甚至上GB的显存。如果同时处理几十个请求,显存很快就爆了。
显存碎片问题:一个被忽视的“隐形杀手”
我刚开始做推理优化的时候,踩过一个坑。当时我天真地以为,只要显存总量够用就行。结果呢?显存碎片化严重,明明还有几百MB空闲,但就是分配不出一个连续的128MB块。
为什么会这样?原因很简单:
- 请求长度不确定:有的请求生成10个token就停了,有的要生成1000个。你没法提前知道每个请求到底需要多少显存。
- 动态分配导致碎片:每次生成新token,都要给KV Cache分配新空间。频繁的分配和释放,就像往内存里塞不同大小的积木,时间一长就全是洞。
- 显存利用率低:传统做法是预先分配最大可能长度的空间。但大多数请求根本用不到那么多,白白浪费了显存。
传统KV Cache管理方式:简单但低效
在PagedAttention出现之前,主流的KV Cache管理方式有两种:
| 管理方式 | 原理 | 缺点 |
|---|---|---|
| 预分配固定大小 | 每个请求预先分配最大长度(如2048)的连续显存 | 显存浪费严重,利用率可能不到30% |
| 动态扩容 | 按需分配,每次生成token时扩展显存 | 频繁分配导致碎片,且无法预知最终大小 |
这两种方式都有一个共同的问题:它们都要求连续的显存空间。你想想看,显存里到处都是不连续的小块,但你要找一个能装下整个KV Cache的大块,这难度有多大?
我记得有一次做性能测试,用动态扩容方式跑100个并发请求。跑了不到5分钟,显存分配就卡死了。原因就是碎片太多,系统一直在做内存整理,推理速度直接掉到原来的十分之一。
PagedAttention核心思想:向操作系统取经
PagedAttention的灵感来自哪里?说出来你可能不信——它借鉴了操作系统的虚拟内存管理。说白了,就是把KV Cache分页管理。
核心思想其实很简单:
- 分页:把KV Cache切成固定大小的“页”(Page),每页通常16KB或32KB
- 非连续存储:这些页可以分散在显存的任意位置,不需要连续
- 按需分配:需要多少页就分配多少页,不浪费
你想想看,这解决了什么问题?
- 碎片问题:小页可以塞进任何空闲的小块显存里,碎片不再是问题
- 利用率:按需分配,不会预占空间
- 共享:多个请求可以共享相同的页(比如beam search场景)
逻辑页与物理页映射:一张“地图”搞定一切
好,现在问题来了:既然KV Cache的页是分散存放的,那怎么找到它们呢?
答案就是——页表。vLLM维护了一个映射关系:
- 逻辑页:从请求的角度看,KV Cache是连续的。比如第0页、第1页、第2页……
- 物理页:在显存里,这些页可能放在物理地址0x1000、0x3000、0x2000……完全不连续
- 页表:记录逻辑页到物理页的映射关系
我画了一张图,帮你理解这个映射关系:
你看,逻辑页0映射到物理页0x3000,逻辑页1映射到物理页0x1000……它们在物理上完全不连续,但通过页表,模型可以像访问连续内存一样访问它们。
PagedAttention的注意力计算
有了页表,注意力计算怎么做?嗯,这里有个巧妙的设计。
传统注意力计算需要连续的KV Cache,但PagedAttention的KV Cache是分散的。所以vLLM在计算注意力时,会先通过页表找到每个逻辑页对应的物理页,然后从物理页中读取数据。
具体来说:
# 伪代码:PagedAttention计算流程
def paged_attention(query, page_table, physical_kv_cache):
# page_table: [num_logical_pages, 2] # 每行是 [物理页号, 页内偏移]
# physical_kv_cache: [num_physical_pages, page_size, hidden_dim]
output = []
for i in range(len(query)):
# 找到当前token对应的逻辑页
logical_page = i // page_size
offset = i % page_size
# 通过页表找到物理页
physical_page = page_table[logical_page][0]
# 读取KV Cache
k = physical_kv_cache[physical_page, offset, :]
v = physical_kv_cache[physical_page, offset, :]
# 计算注意力
score = dot_product(query[i], k)
output.append(score * v)
return output
当然,实际实现比这个复杂得多。vLLM用了CUDA kernel来加速,一次读取多个页的数据,减少访存次数。
性能对比:PagedAttention到底有多强?
我直接说结论:在vLLM的测试中,PagedAttention相比传统方式,显存利用率提升了2-4倍,吞吐量提升了2-3倍。
为什么提升这么大?
- 显存碎片几乎消失:小页可以塞进任何空闲位置
- 按需分配不浪费:每个请求只分配实际需要的页数
- 支持页共享:beam search时,多个候选可以共享相同的KV Cache页
总结一下
PagedAttention的核心就是一句话:把KV Cache分页管理,用页表解决碎片问题。
它借鉴了操作系统的虚拟内存思想,但针对GPU显存的特点做了优化。说白了,就是让显存管理从“大块连续”变成“小块离散”,从而大幅提升利用率。
我个人觉得,PagedAttention是vLLM最精彩的设计之一。它不复杂,但效果惊人。如果你在做大模型推理优化,这个思路值得好好研究。