Contents

MiniPaged-Qwen:把 Paged Attention 讲清楚

Contents

大模型推理里,大家一提优化,最容易先想到的是 FlashAttention、Tensor Core、Kernel Fusion。但在实际的 LLM Serving 场景里,另一个同样关键的问题往往更“系统”一些:

历史 KV 到底放在哪里?不同请求长度不一样时,显存怎么管?Decode 时又怎么在不连续的物理存储上完成 Attention?

Paged Attention 解决的就是这一类问题。

很多时候我们会把 Paged KV CachePaged Attention 混在一起说。严格一点看:

  • Paged KV Cache 解决的是:KV 怎么分块、怎么映射、怎么分配和释放;
  • Paged Attention 解决的是:在 KV 已经按 Block 分页存储之后,Decode Attention 怎么正确、有效地读取它们并完成计算。

前者更偏内存管理,后者更偏注意力计算与 Kernel 设计。

本文按一个比较“从直觉到实现”的顺序来整理:

  1. Paged Attention 是什么;
  2. 它用于什么场景、解决什么问题;
  3. 按原论文思路写出 Paged Attention 的计算过程;
  4. 用一张图把 静态显存组织动态计算过程 放在一起说明;
  5. 最后回到 MiniPaged-Qwen,说明我们现在是怎么实现的,与官方方案有哪些差异,以及后续能往哪里改进。

1. Paged Attention 是什么

Paged Attention 出自 vLLM 论文 Efficient Memory Management for Large Language Model Serving with PagedAttention。它的核心思想并不是“重新定义 Attention 数学公式”,而是:

把 Decode 阶段 Attention 所访问的 KV Cache 分成固定大小的 Block,通过 Block Table 把逻辑连续的历史 Token 映射到物理上不连续的存储块,再在 Attention 计算时按这个映射逐块访问。

如果只看标准单步 Decode Attention,对于当前 Query:

$$ q \in \mathbb{R}^{d} $$

历史 Key 和 Value 为:

$$ K \in \mathbb{R}^{L\times d},\qquad V \in \mathbb{R}^{L\times d} $$

则输出为:

$$ o

\sum_{j=0}^{L-1} \frac{ \exp\left( \frac{qk_j^T}{\sqrt d} \right) }{ \sum_{t=0}^{L-1} \exp\left( \frac{qk_t^T}{\sqrt d} \right) } v_j $$

Paged Attention 并不改变这个数学结果。它做的事情是:

  1. 逻辑上,历史 Token 仍然按 $0,1,2,\dots,L-1$ 排列;
  2. 物理上,这些 Token 的 KV 可以位于不同的 Physical Block;
  3. 计算时,先通过 block_table 找到某个逻辑位置属于哪个 Physical Block,再从分页后的 KV Cache 中把对应的 K/V 读出来。

所以它本质上是:

“分页式 KV 存储 + 面向分页存储的 Attention 访问与计算方式”。


2. 它用于什么场景

Paged Attention 主要用于:

2.1 LLM Serving / 在线推理服务

离线单条推理时,连续 KV Cache 也能用;但在线服务的情况不同:

  • 多个请求同时并发;
  • 每个请求的长度动态变化;
  • 有的请求很快结束,有的请求会持续生成很长;
  • 需要 Continuous Batching、Beam Search、Parallel Sampling 等更复杂的调度。

在这种场景里,KV Cache 的内存管理本身会成为吞吐瓶颈

2.2 长上下文 Decode 阶段

Prefill 里更像“矩阵 Attention”;而 Decode 阶段更像:

  • 每一步只有一个新 Query;
  • 但要访问越来越长的历史 KV。

因此随着上下文变长,Decode 中:

  • 历史 KV 的显存占用 持续上升;
  • 访问历史 KV 的方式 变得越来越重要;
  • 不同请求之间的内存碎片 会逐步限制并发数。

Paged Attention 正是在这种“长生命周期、动态长度、多并发请求”的环境里价值最大。


3. 它解决了什么问题

原论文强调的核心问题可以概括为三类。

3.1 连续 KV Cache 带来的显存浪费

如果给每个请求都预留一块连续的大 Buffer:

$$ [H_{kv}, L_{max}, d] $$

那就必须按最大可能长度预留空间。真实生成长度远小于 $L_{max}$ 时,大量显存会被浪费。

3.2 动态扩容连续 Buffer 的搬移成本

如果不提前预留最大长度,而是用“空间不足再扩容”的方式,那么当当前 Buffer 放不下新 Token 时,需要:

申请更大的连续区域
拷贝旧 KV
释放旧区域

这在高并发 LLM 服务里代价很高。

3.3 多请求结束时间不同导致的外部碎片

多个请求的 KV Buffer 如果都要求物理连续,就会像传统堆分配那样产生外部碎片:中间零散的空闲区域不能轻易给一个更大的新请求复用。

Paged Attention 的解决思路是:

取消“每个请求的 KV 必须连续存储”这一约束。

只要逻辑上知道历史第 $pos$ 个 Token 落在哪个 Block、哪个 Offset,Attention 就仍然能算对。


4. 先区分:Paged KV Cache 与 Paged Attention

这两个概念容易混。

4.1 Paged KV Cache

它回答:

新生成的 KV 应该写到哪里?历史 KV 目前分布在哪些物理 Block 里?

核心数据结构包括:

  • block_size
  • block_table
  • free_blocks
  • slot_mapping
  • key_cache / value_cache

4.2 Paged Attention

它回答:

在 Decode 的时候,当前 Query 如何通过 block_table 找到所有历史 K/V,并按正确顺序完成 Attention?

也就是说:

  • Paged KV Cache 决定“怎么存”;
  • Paged Attention 决定“怎么读、怎么算”。

5. 原论文视角下的 Paged Attention 计算过程

下面参考原论文思路,把单步 Decode 的 Paged Attention 写清楚。

假设当前只生成 1 个 Query,对应某个 Sequence、某个 Query Head。

设:

  • 历史长度为 $L$;
  • Block Size 为 $B_s$;
  • Head Dimension 为 $d$。

则逻辑上可把历史 K/V 按 Block 划分为:

$$ K

[K^{(0)}, K^{(1)}, \dots, K^{(T-1)}] $$

$$ V

[V^{(0)}, V^{(1)}, \dots, V^{(T-1)}] $$

其中:

$$ K^{(t)}, V^{(t)} \in \mathbb{R}^{B_s \times d} $$

最后一个 Block 可能没有填满;Block 总数为:

$$ T

\left\lceil \frac{L}{B_s} \right\rceil $$

对第 $t$ 个逻辑 Block,有:

$$ pb_t

block_table[t] $$

这表示逻辑块 $t$ 实际对应的 Physical Block 编号是 $pb_t$。

5.1 第一步:逐 Block 读取历史 Key

对于当前 Query:

$$ q \in \mathbb{R}^{d} $$

第 $t$ 个 Block 的 Score 向量为:

$$ s^{(t)}

\frac{ q (K^{(t)})^T }{ \sqrt d } \in \mathbb{R}^{B_s} $$

但注意这里的 $K^{(t)}$ 并不是连续内存上的“第 $t$ 块”,而是通过:

$$ pb_t

block_table[t] $$

从:

$$ key_cache[pb_t, kvh, :, :] $$

取出的。

5.2 第二步:按全部历史 Token 做 Softmax

Attention 的概率并不是“每个 Block 自己做一次 Softmax”,而是要在全历史维度上归一化:

$$ p_j

\frac{ \exp(s_j) }{ \sum_{u=0}^{L-1} \exp(s_u) } $$

Paged Attention 常见实现不会先把全部 Score 收集完再统一 Softmax,而是使用 Online Softmax分块归约

设当前累积状态为:

  • 运行最大值 $m$;
  • 归一化分母 $l$;
  • 未归一化输出累积 $\tilde o$。

遍历到一个新 Block 的 Score 向量 $s^{(t)}$ 后,先取该 Block 最大值:

$$ m_t = \max s^{(t)} $$

更新全局最大值:

$$ m’ = \max(m, m_t) $$

旧分母重标定:

$$ l'

l, e^{m-m’} + \sum_j e^{s^{(t)}_j - m’} $$

旧输出重标定并累积:

$$ \tilde o'

\tilde o, e^{m-m’} + \sum_j e^{s^{(t)}_j-m’} v^{(t)}_j $$

然后更新:

$$ m\leftarrow m’, \qquad l\leftarrow l’, \qquad \tilde o\leftarrow \tilde o' $$

全部 Block 遍历结束后:

$$ o = \frac{\tilde o}{l} $$

这就得到了与标准 Attention 完全等价的结果。

5.3 第三步:Paged Attention 与标准 Attention 的关系

从数学上看,它仍然是:

$$ o

\mathrm{Softmax} \left( \frac{qK^T}{\sqrt d} \right)V $$

Paged Attention 做的只是把:

  • KV物理存储方式变成分页的;
  • KV读取方式变成通过 block_table 间接寻址;
  • Softmax实现方式变成更适合流式 Block 遍历的 Online / Partitioned 版本。

所以它不是改变模型,而是改变 Runtime


6. 一张图看清:静态显存分布与动态计算过程

下面这张图把 Paged Attention 的两个层面放在了一起:左侧是静态显存布局,右侧是 Decode 时动态计算流程

images/minipaged_qwen_paged_attention/paged_attention_static_dynamic.png

这张图可以分成两半理解。

6.1 左侧:静态显存分布

左边表示 Paged KV Cache 的物理存储

物理 Block Pool

显存中的 KV Cache 不是按请求连续存,而是先切成固定大小的 Physical Block:

physical block 0
physical block 1
physical block 2
...

每个 Block 内部有固定数量的 Slot,例如:

$$ block_size = 4 $$

那么一个 Block 就能容纳连续 4 个 Token 的 KV。

Block Table

对于某个 Sequence,其逻辑历史位置被分为:

logical block 0
logical block 1
logical block 2
...

通过:

block_table[logical_block] = physical_block

映射到物理块。

因此逻辑连续、物理离散。

Free List

未使用的 Physical Block 由:

free_list

统一管理。新请求增长到新的 Logical Block 时,从 Free List 分配一个 Physical Block;请求结束时,再把对应 Block 归还。

6.2 右侧:动态计算过程

右边表示单步 Decode 时,Paged Attention 如何访问历史 KV。

输入包括:

  • 当前 Query;
  • 当前 Sequence 的 block_table
  • 当前有效长度 seq_len

然后执行:

1)遍历逻辑历史位置

对:

$$ pos=0,1,2,\dots,L-1 $$

逐个处理。

2)先从 pos 计算逻辑块与偏移

$$ logical_block

\left\lfloor \frac{pos}{block_size} \right\rfloor $$

$$ off

pos \bmod block_size $$

3)再通过 Block Table 查到物理块

$$ pb

block_table[logical_block] $$

4)从分页后的 KV Cache 中读取 K/V

$$ k_{pos}

key_cache[pb,kvh,off,:] $$

$$ v_{pos}

value_cache[pb,kvh,off,:] $$

5)更新 Attention 计算状态

也就是:

  • 点积得到 Score;
  • 更新 Online Softmax;
  • 累积输出。

这一步并不会因为分页而改变数学结果,只是地址访问方式变了。


7. 进一步理解:Paged Attention 到底改变了什么

如果用一句话总结,我会说:

Paged Attention 把“Attention 计算”与“KV 的物理连续布局”解耦了。

标准连续 KV 的读取是:

base + pos

Paged Attention 的读取是:

pos
logical_block
block_table
physical_block
offset
KV address

它引入了一个额外的“地址翻译层”,用来换取更灵活的显存管理能力。


8. MiniPaged-Qwen 中我们是如何实现的

下面回到项目本身。

8.1 CPU 侧:Block Manager 管理元数据

MiniPaged-Qwen 先实现了一个最小可验证的 CPU 侧 Block Manager。

核心数据结构:

@dataclass
class SequenceState:
    sequence_id: int
    sequence_length: int = 0
    block_table: List[int] = field(default_factory=list)

以及:

class KVBlockManager:
    num_physical_blocks
    block_size
    free_blocks
    sequences

它负责:

  • create_sequence()
  • append_token()
  • release_sequence()
  • get_block_tables_tensor()
  • get_seq_lens_tensor()
  • invariants_ok()

其中 append_token() 会:

  1. 根据 sequence_length 算出当前 Token 所在的 logical_block
  2. 如果跨越 Block 边界,则申请新的 Physical Block;
  3. 返回这个新 Token 对应的线性 slot_mapping

这部分是 Paged KV Cache 的元数据平面

8.2 GPU 侧:Paged KV Cache 保存真正的 K/V

MiniPaged-Qwen 中每层的 Cache Tensor 布局是:

$$ [num_blocks, num_kv_heads, block_size, head_dim] $$

也就是:

key_cache.shape   = [num_blocks, kvh, block_size, dim]
value_cache.shape = [num_blocks, kvh, block_size, dim]

这意味着第 pb 个 Physical Block、第 kvh 个 KV Head、第 off 个 Offset 的向量就是:

$$ key_cache[pb,kvh,off,:] $$

$$ value_cache[pb,kvh,off,:] $$

Cache Write Kernel 根据 slot_mapping 把新 Token 的 K/V 写入这里。

8.3 Decode 侧:Paged Attention Kernel

项目中的 Decode Attention 实现分为:

  • CPU Reference;
  • CUDA Kernel。

CUDA Kernel 的设计是:

一个 CUDA Block 负责一个 (sequence, query_head) 输出。

其输入包括:

  • query
  • key_cache
  • value_cache
  • block_tables
  • seq_lens

计算过程大致是:

  1. 当前 Block 对应某个 (b, qh)
  2. 根据 GQA 关系得到对应 kvh
  3. 遍历历史位置 pos = 0...seq_len[b]-1
  4. 使用:

$$ logical_block = pos // block_size $$

$$ pb = block_tables[b, logical_block] $$

$$ off = pos % block_size $$

从分页 Cache 中读取:

$$ K[pb,kvh,off,:],\quad V[pb,kvh,off,:] $$

  1. 通过 Online Softmax 累积输出。

也就是说,MiniPaged-Qwen 当前的 Paged Attention 已经打通了:

Qwen3 Query
block table lookup
paged KV gather
online softmax
attention output

这个闭环。


9. 我们的实现与官方方案有哪些差异

这一部分很关键。因为“能跑通的学习实现”和“vLLM 官方生产级实现”之间一定有差距。

9.1 相同点

MiniPaged-Qwen 与官方 Paged Attention 在核心思想上是一致的:

一致点 1:分页式 KV 存储

都把 KV Cache 切成固定大小的 Block,由 block_table 完成逻辑到物理的映射。

一致点 2:Decode 时通过 Block Table 间接访问 K/V

都不是假设历史 KV 连续,而是先做地址转换,再读取分页后的 Cache。

一致点 3:Attention 数学结果不变

两者都保持:

$$ o

\mathrm{Softmax} \left( \frac{qK^T}{\sqrt d} \right)V $$

的语义不变。

一致点 4:都需要处理 GQA / 多 Head 映射

对于 Qwen3 这种:

$$ H_q \neq H_{kv} $$

的模型,Attention Kernel 都要考虑:

$$ kvh = \left\lfloor qh / (H_q/H_{kv}) \right\rfloor $$

9.2 当前 MiniPaged-Qwen 的主要差异

差异 1:我们更像“教学实现”

当前实现的重点是:

  • 结构清晰;
  • 数值正确;
  • 能解释全流程;
  • 方便与 Hugging Face 对齐验证。

因此在 Kernel 设计上更直白,尤其是 Decode Attention 里,采用:

  • 一个 CUDA Block 对一个 (sequence, query_head)
  • 按历史 Token 顺序遍历;
  • 用 Online Softmax 做流式归一化。

这非常适合理解,但不一定是吞吐最优方案。

差异 2:官方实现更强调高吞吐和大批量服务

官方 vLLM Paged Attention 的重点是 Serving Throughput,因此它更关注:

  • 多请求大批量并发;
  • 更复杂的线程块布局;
  • Warp/Thread Group 的向量化加载;
  • 更细粒度的并行归约;
  • 更复杂的调度与内存共享。

而我们的实现目前更像:

面向单机学习与验证的 Mini Runtime。

差异 3:我们还没有实现官方的 KV 共享能力

原论文里一个非常重要的能力是:

  • Shared Prefix
  • Parallel Sampling / Beam Search 的 Block 共享;
  • Copy-on-Write
  • Reference Counting

也就是说,多条请求如果前缀相同,可以让它们的 Block Table 指向同一批 Physical Block,而不是复制一份 KV。

MiniPaged-Qwen 当前实现里:

  • 每个 Sequence 拥有自己的 Block Table;
  • 但尚未实现 Block 级共享与引用计数;
  • 也没有 Copy-on-Write。

所以它已经实现了分页,但还没有实现“共享分页”。

差异 4:官方实现的 Memory Manager 更完整

官方系统要面对:

  • Continuous Batching;
  • 请求抢占与恢复;
  • Beam Search 分叉;
  • Prefix Reuse;
  • 分布式服务。

因此除了 Paged Attention Kernel,还包括完整的 Serving Runtime。

MiniPaged-Qwen 目前实现的是其中最核心的一条主线:

分页式 KV 管理
    +
Decode Paged Attention

10. 我们现在这个版本有哪些改进空间

如果从“下一步优化什么”来考虑,我会把方向分成三层。

10.1 内存管理层

1)Block 共享

支持:

  • 相同 Prompt 前缀复用;
  • Beam Search 分支共享;
  • 引用计数;
  • Copy-on-Write。

2)更丰富的元数据导出

例如:

  • 更适合 Batch 化的 Block Table Tensor;
  • 更直接的 GPU Metadata 格式;
  • 更低开销的 Sequence 生命周期管理。

10.2 Attention Kernel 层

1)更高效的加载与并行布局

当前的 Kernel 是“正确优先”的实现,之后可以继续优化:

  • 更好的 Memory Coalescing;
  • 更高效的 Head-Dim 并行;
  • 更好的 Shared Memory / Register 使用;
  • 更细致的 Warp Reduction。

2)Block 级并行或 Partitioned Reduction

目前我们按历史位置顺序扫描,后续可以向官方更高吞吐的分块并行思路靠近,例如:

  • 多个线程组并行处理不同 KV 分片;
  • 局部归约后做全局合并;
  • 更适合长上下文与大批量的执行模式。

10.3 Runtime 层

1)Continuous Batching

Paged Attention 真正的价值往往要放在:

动态请求进入 / 退出

的运行时系统里看。

2)与 Prefill / Decode 全链路更紧密耦合

也就是把:

Prefill
KV Cache Write
Multi-layer Decode
Token Generation

做成真正的完整 Runtime,而不只是独立模块。

3)Benchmark 与 Profiling

后续值得系统测量:

  • TTFT
  • TPOT
  • 吞吐
  • 峰值显存
  • 不同 Block Size 的影响
  • 不同上下文长度下的性能变化

11. 一句话总结 Paged Attention

如果让我用一句更偏工程的话来概括:

Paged Attention = 让 Attention 计算不再依赖“KV 在物理显存中连续存放”,从而把 LLM Decode 的内存管理问题转化为“固定大小 Block + Block Table + 分页式访问”的问题。

它的价值不在于改变 Transformer 的数学本质,而在于:

  • 让显存利用率更高;
  • 让请求生命周期管理更灵活;
  • 让高并发 LLM Serving 更容易做大批量调度;
  • 为 Prefix Sharing、Beam Search Sharing 等更高级优化提供基础。

12. 总结:MiniPaged-Qwen 当前已经做到什么程度

最后把项目当前状态收束一下。

MiniPaged-Qwen 已经完成了一个面向学习和验证的最小 Paged Attention 系统原型:

已实现

  • Qwen3 的 Decode Attention 路径;
  • Paged KV Cache;
  • CPU Block Manager;
  • slot_mapping 写入;
  • block_tables + seq_lens 驱动读取;
  • GQA-aware Paged Attention;
  • CPU 参考实现与 CUDA 实现;
  • 与 Hugging Face Attention 路径的数值对齐验证。

当前定位

它更适合被理解为:

一个“把 Paged KV Cache 与 Paged Attention 核心思想走通”的 Mini Runtime。

与官方相比

它已经抓住了最核心的设计点,但仍然缺少:

  • Block Sharing / Reference Counting;
  • Copy-on-Write;
  • 更高吞吐的 Kernel 布局;
  • 更完整的 Serving Runtime 与调度系统。

不过这恰好也是它的优点:结构清楚,适合逐层分析和继续扩展。


参考资料

  1. Woosuk Kwon, et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180.
  2. vLLM Documentation. Paged Attention.
  3. MiniPaged-Qwen source code: block_manager.py, paged_attention.py, integrate_qwen3_paged_attention.py.