CANN 的推理源码笔记写到第四篇,终于要碰整条流水线里我认为最绕的两个模块:Paged KV Cache 和 Packed Sequence。如果你已经在用 vLLM 这套思路,看到这两个名字应该不陌生;但如果你是从头读 cann-recipes-infer 源码,我建议把这篇当成“显存编排与序列排布的双人舞”来读——一个管显存怎么省,一个管算力不浪费。分开看都明白,合起来才会发现中间那层索引映射才是真正的难点。
这篇笔记适合正在 CANN 上做大模型推理部署、想把请求吞吐再往上顶一截的人。尤其是你已经被显存碎片、padding 浪费、动态长度请求搞得焦头烂额,想看看参照 vLLM 的 PagedAttention 在昇腾侧到底怎么落地。我尽量把源码里那些绕来绕去的结构讲清楚,同时给出可以直接抄的数值估算方法和调试思路。
1. 为什么 KV Cache 和 Sequence 放在同一篇笔记里讲
先说结论:Paged KV Cache 解决的是“显存怎么不浪费”,Packed Sequence 解决的是“算力怎么不浪费”。但这两个问题并不是独立的——它们都源于同一个事实:LLM 推理时序列长度是动态的、未知的、每时每刻都在变。不把这个前提刻进脑子里,读源码就会觉得每一步都多此一举。
1.1 KV Cache 的显存成本到底高在哪
很多人对 KV Cache 的体量没有直觉,直到自己画图估一遍才吓一跳。对于 decoder-only 模型,每生成一个新 token,都要把当前 token 的 K 和 V 向量缓存下来,供后续 token 计算 attention 时反复读取。这部分显存跟 batch 大小、序列长度成正比,而且是一路涨上去的,不像权重那样静态不变。
估算公式很简单:模型每层每个 token 的 KV 字节 = 2(K、V 两个矩阵)× hidden_size × 精度字节数。以 7B 规模的 Llama 结构为例,hidden_size 通常 4096,FP16 占 2 字节,那么一层一个 token 就要 2×4096×2 = 16KB。模型一般 32 层,所以单个 token 在全模型范围内要吃掉 32×16KB = 512KB。一个请求生成 2048 个 token,光 KV Cache 就是 1GB。
这个数字还有个更扎心的含义:在动态生成场景里,序列是边解码边变长的。你没法在请求进来时承诺它一定只生成 64 个 token,于是传统方案只能在显存里预留一块“足够长”的连续空间。预留太多,并发一高显存立刻爆;预留太少,序列一长就得整块迁移或者直接 OOM。读 cann-recipes-infer 这类推理 recipe 时,你会看到它花了大量代码去管理这块空间,而不是直接 memcpy,原因就是这个显存成本太夸张,一点点碎片和浪费都会被放大。
1.2 Padding 便宜,但便宜的是算力还是显存
Packed Sequence 面对的则是另一个浪费:静态 batching 下,一个 batch 里 8 个请求长度分别是 50、200、1000、3000……你要么按最长序列 padding,要么按最大容量 padding,然后所有短的请求也陪着做矩阵乘。假设 batch 内平均长度只有最长长度的三分之一,那么约三分之二的乘法和 Attention 计算都在乘“0”或者算“mask 后的空气”。
这就是 Packed Sequence 的动机:不 padding,把多条序列直接在 token 维度上首尾相接,变成一个更长的连续序列,让所有有效 token 一起进算子。真正接上之后你才会发现,它把问题从“怎么凑齐一个矩形”变成了“怎么让 Attention 算子知道谁跟谁是一段”。后者比前者麻烦得多,却值得麻烦,因为现在即便 batch 内长度差异极大,算力也只花在真实 token 上。
从源码实现角度看,Packed Sequence 通常在 host 侧生成三样东西:拼接后的 token 序列、每个序列的起始偏移、重新生成的 position id。这三样东西会一路传递到 Attention kernel 里,替代传统 padding 版本的 attention mask。这跟 Paged KV Cache 的分页逻辑天然咬合:分页是按物理块管理历史 K/V,拼包是按逻辑段管理当前请求的所有 token,两者都需要一张“映射表”才能把看似连续的索引还原成真实数据位置。
2. Paged KV Cache 的骨架:Block Table、Slot 映射与引用计数
如果你翻过 vLLM 的 PagedAttention 源码,再看 cann-recipes-infer 里这部分的 manager,会发现思路高度一致:把 KV Cache 切成固定大小的物理块,给每个请求维护一张“逻辑块到物理块”的映射表。整段代码我看下来,核心就三件事:分配物理块、维护映射表、管理引用计数。
2.1 源码里的核心数据结构,长什么样
不贴整段工程代码,把核心逻辑剥出来大概是这样:
class KVBlockManager: def __init__(self, num_blocks, block_size): self.block_size = block_size self.free_blocks = list(range(num_blocks)) # 空闲物理块池 self.ref_count = [0] * num_blocks # 每个物理块的引用数 self.block_table = {} # seq_id -> [物理块id列表] def allocate_slots(self, seq_id, num_tokens): need_blocks = (num_tokens + self.block_size - 1) // self.block_size blocks = [] for _ in range(need_blocks): if not self.free_blocks: raise RuntimeError("kv cache full") block_id = self.free_blocks.pop() self.ref_count[block_id] += 1 blocks.append(block_id) self.block_table[seq_id] = self.block_table.get(seq_id, []) + blocks物理块本身是一块预先分配好的显存,形状可以理解成[num_blocks, block_size, num_heads, head_dim]。每个块固定装block_size个 token 的 K/V。源码里通常会有一个独立的 memory pool 在初始化阶段一次性申请,后面所有请求都从里面按块取用,不再碰 cudaMalloc / aclrtMalloc 这种重操作。
这里的 key point 是:逻辑上每个请求拥有连续递增的 block 列表,物理上这些 block 可能散落在显存任意位置。比如序列长度 37,block_size 16 时,逻辑需要 3 个块,物理块可能是 [5, 17, 3]。后续 Attention 计算时不能假设 5、17、3 地址连续,必须显式传入 block table。
2.2 从逻辑块到物理块:一次 decode 步内的分配路径
看懂了数据结构,再看一次典型的 decode 步。假设一个请求已经生成了 37 个 token,block_size 16,那么 block_table 是 3 个物理块,前两个块满,第三个块只有 5 个有效 slot。当前要写第 38 个 token 的 K/V:
- 算逻辑块号:38 // 16 = 2,slot 号:38 % 16 = 6;
- 查 block_table[2] 拿到物理块 id;
- 把新的 K/V 写入物理块内偏移 6 的位置;
- 如果 38 % 16 正好是 15,说明下一个 token 需要新块,此时才从 free_blocks 里取块并 append 到 block_table。
所以绝大部分 decode 步骤并不会触发块分配,只有跨块边界时才需要一次轻量操作。真正的开销在于显存写入位置变成了“物理块 id × block_size + slot”,而不是简单的seq_len * kv_stride。源码里所以你经常看到一串这样的索引计算逻辑:
uint32_t logical_block = token_pos / block_size; uint32_t slot = token_pos % block_size; uint32_t physical_block = block_table[seq_id * max_blocks_per_seq + logical_block]; uint32_t flat_offset = physical_block * block_size + slot;这个 flat_offset 就是最终读写 KV cache tensor 的下标。因为每条序列的 block_table 长度不一样,源码里一般会给每条序列维护max_blocks_per_seq的定长表,或者用block_table + seq_start_idx的方式切片,避免动态二维 list 带来的 host 开销。
2.3 block_size 选多少合适:显存碎片与页表开销的平衡
读源码时最直接的困惑是:block_size 到底取多少?代码里如果写死,你一定想知道依据。答案不是某个魔法数字,而是“平均浪费”和“页表大小”的权衡。
假设每个 token 的 KV 字节是T,block_size 为B。均匀分布的序列长度下,每个请求最后一个块平均浪费B / 2个 slot 的显存,也就是浪费B / 2 × T。以 7B 模型 T = 512KB 为例:
| block_size | 每个请求平均浪费 | 1 万请求浪费总量 | 页表行数(1k token 请求) | 适合场景 |
|---|---|---|---|---|
| 16 | 4MB | 40GB | 63 | 追求低浪费,显存极紧张 |
| 64 | 16MB | 160GB | 16 | 多数推理场景 |
| 128 | 32MB | 320GB | 8 | 页表开销敏感、kernel 大块取数 |
工程上,vLLM 在 GPU 上默认偏好 16 或 32,因为 GPU kernel 对细粒度块的随机访问还算友好,而且显存单价高,浪费不起。昇腾侧我看到的实践更偏向 64 或 128,原因是 CUBE 算子做矩阵乘时,块内能一次搬进多少连续数据会影响实际吞吐,块太小反而让寻址开销占比上升。源码里如果看到 block_size 定义为可配置项,不用惊讶,它本来就应该是配置项。
补充一个容易被忽略的点:页表本身也在占显存和带宽。每条序列max_blocks_per_seq定长表的 host 侧拷贝、device 侧读取,都会随并发请求数线性增长。block_size 取 128 时,1k token 请求只需要 8 行页表,整体很轻;取 16 时需要 63 行,decode 每步都要把这些索引传到 kernel,开销不可小视。所以我的建议是先按“平均浪费可接受”定下限,再按“页表读取带宽”定上限,不要盲目抄 GPU 默认值。
3. Packed Sequence 拼包后如何喂给 Attention 算子
Packed Sequence 乍一看只是把 token 拼起来,但真正写成代码时,你会面对一个比分页更隐蔽的问题:Attention 算子怎么知道哪些 token 属于同一个序列?同一个序列内 mask 怎么做?拼接后的位置编码怎么保持每个序列从 0 开始?
3.1 拼包三件套:tokens、seq_offset、position_ids 的生成
在 cann-recipes-infer 这类代码里,输入处理阶段通常会有个小函数,把当前 batch 里所有请求的 input token 拼成长向量,同时记录每条序列的边界。核心代码逻辑基本长这样:
lens = [req.input_len for req in requests] # 比如 [7, 3, 12] seq_offset = [0] for l in lens: seq_offset.append(seq_offset[-1] + l) # [0, 7, 10, 22] tokens = np.concatenate([req.tokens for req in requests]) position_ids = np.concatenate([np.arange(l) for l in lens])这里有个关键细节:position_ids 不能直接沿用拼接后的全局索引 0..21,否则第二个序列的第一个 token 会拿到位置 7,RoPE 旋转角度直接错乱。必须让每个序列内部从 0 重新编号,正如np.arange(l)对每个序列单独生成。
这个三件套中,seq_offset是 Attention 算子的命根子。FlashAttention 的 varlen 接口需要的就是类似cu_seqlens的前缀和数组,它告诉算子第 i 条序列从哪个 token 开始、到哪个 token 结束。有了它,算子内部就不用为每条请求单独 launch kernel,而是把整块拼接数据一次性处理。
3.2 Attention Mask 在连续空间里的重新解释
传统 padding 版本里,mask 是二维的[batch, max_len]或[batch, max_len, max_len],记的是“哪个位置是合法 token”。Packed Sequence 合并后,mask 的维度变了:它变成了一个关于“序列内因果性”的规则——第 i 个 token 只能看到同一个序列里排在它前面的 token,跨序列的 token 一律不可见。
如果算子不支持 varlen,你得自己构造一个[total_tokens, total_tokens]的 mask,把不同序列之间的三角区域全部置为 -inf。这在拼接长度几万时会非常浪费,而且矩阵一下子变得巨大。所以真正高效的实现都依赖 FlashAttention 类算子的 varlen 能力:算子内部只关心seq_offset,在计算每个 score 时自动判断两个位置是否同属一个序列并施加因果 mask。
源码里你如果有兴趣验证,可以看拼包后传给 kernel 的参数列表:除了 QKV,通常还多出cu_seqlens、seq_offset或sequence_mask这类字段,而不是传统attn_mask。这就是拼包模式与 padding 模式最直观的差别。
3.3 合并进 KV Cache 索引:逻辑到物理再到连续
Packed Sequence 和 Paged KV Cache 的交叉点在这里:拼包后 Q 是连续的,但历史的 K/V 仍然按物理块散落着。Attention 算子计算当前 token 的 attention 输出时,需要按每个序列的 block_table 去取对应物理块里的 K/V。
如果把整个流程拉直,你会发现存在三层索引:
| 层级 | 含义 | 生成方式 |
|---|---|---|
| 连续 token 索引 | 拼包后的位置 0..total_len-1 | 由 seq_offset 映射回具体序列和序列内偏移 |
| 逻辑块索引 | 某个序列内 offset // block_size | 序列内偏移整除块大小 |
| 物理块索引 | 逻辑块对应的真实显存块 | block_table 查表 |
所以源码里常见的组合拳是:先根据需要计算的 token 在拼接流中的位置,反推出它属于哪个请求、在这个请求里的偏移,再用这个偏移计算逻辑块,最后查 block table 拿到物理块地址。这一串索引操作如果放在 Python 层写,decode 阶段每一步都会卡到不行;所以工程实现里通常把seq_offset、block_table都提前搬到 device 侧,kernel 内部自己查。
4. 两条机制在 infer 流水线中的真实协作场景
单独看数据结构还不够,必须看它们在一次真实推理迭代里怎么串起来。我建议你把一次 decode 想象成一条流水线:请求进来 → 更新页表 → 拼包 → 计算 attention → 采样 → 写回 KV Cache。
4.1 数据流:从请求进入到最后的 Attention 计算
用四个请求举例,它们的当前长度分别是 7、3、12、5。传统方式下你要 pad 到 12,总 token 数是 48。Packed Sequence 下总 token 数是 27,刚好少掉一半。
具体步骤拆开看:
- 收集本轮 batch 的请求,汇总每个请求的有效 token 长度,生成
seq_offset = [0, 7, 10, 22, 27]; - 根据各自长度拼接本轮新增 token,生成
position_ids; - 对每个请求,检查当前长度是否触及所在物理块末尾;若触及,从 KVBlockManager 申请新物理块,追加到对应 block_table;
- 把
block_table、seq_offset、拼接后的 tokens 一并传给执行模型和 Attention kernel; - Attention kernel 用 Q 和已缓存的 K/V 计算输出;K/V 的缓存位置全靠
block_table定位; - 采样出新 token,写入对应的 KV Cache slot,更新请求长度。
这里面步骤 3 和步骤 5 是最容易出错的。步骤 3 出错通常表现为显存泄漏或数据覆盖;步骤 5 出错往往表现为 attention 结果对不上,但单测又不触发。
4.2 实际 kernel 的输入形状对比
很多读者第一次看分页时最不适应的是 tensor 形状变化。我把非分页和分页版本的输入列个对比:
| 数据 | 非分页(padding + 连续 cache) | 分页(packed + block table) |
|---|---|---|
| Q / K / V 当前步 | [batch, num_heads, seq_len, head_dim] | [total_tokens, num_heads, head_dim] |
| KV Cache | [batch, max_seq, num_heads, head_dim] | [num_physical_blocks, block_size, num_heads, head_dim] |
| 序列边界信息 | 靠 padding mask | cu_seqlens / seq_offset |
| 物理位置信息 | 连续,不需要额外参数 | page_table |
最核心的变化是 KV Cache 不再按 batch 组织,而是按物理块组织。Attention kernel 遍历每个 query token 时,先通过 page_table 定位自己所属序列的物理块区间,再在块内顺着 slot 取 K/V。这也是为什么这类 kernel 叫 PagedAttention——它的内层循环其实是“物理块级”寻址。
4.3 一个两请求的具体推演例子
为了让你彻底理解,我手推一个极简例子。设 block_size = 4,请求 A 当前长度 7,请求 B 当前长度 3,物理块初始空闲列表 [0, 1, 2, 3]。
请求 A 需要 2 个块(7 token 占 2 块),请求 B 需要 1 个块。分配后:
- block_table[A] = [0, 1],block_table[B] = [2]
- 请求 A 的 token 0..3 放在物理块 0 的 slot 0..3,token 4..6 放在物理块 1 的 slot 0..2
- 请求 B 的 token 0..2 放在物理块 2 的 slot 0..2
拼包后 token 流是[A0,A1,A2,A3,A4,A5,A6,B0,B1,B2],但 K/V 的位置完全离散。Attention 计算第 7 个 token(A6)时,它只能 attend 到 A0..A5,也就是物理块 0 全部 + 物理块 1 的 slot 0..1。此时 Attention kernel 需要跨两个物理块取数。如果 block_table 传错了或引用计数没加对,kernel 可能读到 B 请求写入物理块 2 的数据,结果直接崩或静默出错。
这个例子揭示了一个很容易被忽略的事实:block_table不只是“索引表”,它同时还是 Attention 的合法性边界。kernel 内循环必须精确知道某条序列占用了哪些物理块、每个块用到了第几个 slot,否则拼接得越紧凑,错误就越隐蔽。
5. 调这几段代码我踩过最深的坑
源码读完后一定得上手调,我在这个环节栽过好几次。下面这些坑不一定每个都能在文档里看到,但它们绝对是分页和拼包实现里最常见的。
5.1 以为页表是“连续大数组”,结果跨块边界把 mask 算错
第一次改分页版 Attention 时,我脑子里天然把它当成一个[max_len, ...]的大矩阵,拿着 token 连续索引就想去取 K/V。结果一旦序列跨了多个物理块,取到的数据是断裂的,attention 分数错得离谱,但 loss 又不是完全 NaN,非常难查。
定位这类问题,我建议第一时间打印block_table和每个物理块的 slot 占用,手工恢复出每条序列的“逻辑 K/V 矩阵”,和连续版 cache 的黄金结果做对比。对比时特别注意跨块边界的那个 token,检查它 attend 到上一块和下一块的 k/v 是否和自己预期一致。
5.2 ref_count 先减后加:共享前缀块到底属于谁
beam search 或者并行采样时,多个序列会共享同一个前缀。分页实现里,同一个物理块可能同时被多条序列引用,ref_count 必须大于 1。但如果你在一个序列结束时把它的物理块直接放回 free list,另一个还在用这个块的序列会读到被覆盖的数据,推理结果在某个瞬间开始突变。
这类 bug 隐蔽在:单序列推理一切正常,beam search 一开就偶发异常。解决办法是每次释放序列前先遍历它的 block_table,对每个物理块执行ref_count -= 1,归零才回收。源码里如果看到 ref_count 的增减被包装在 acquire/release 语义里,请不要简化掉,这个设计就是为了共享前缀。
5.3 Paged KV Cache + 图模式:host 侧 list 更新不能直接塞进计算图
CANN 的图模式很诱人,想把整个 decode 封成一张静态图,但 block_table 是每步都可能变化的动态结构。如果你把 Python 里的 list 追加操作直接写进图构建脚本,很容易出现图构建时拿到的 block_table 是空表,或者图固化后不再感知新分配块。
我的经验是:把需要动态更新的小结构放到 host 侧同步更新,然后以 tensor 形式整体拷进 device;KD 或者图模式的捕获阶段只负责搬数据,不负责做分配决策。如果这部分性能吃紧,可以考虑用双缓冲——copy 下一轮的 block_table,同时计算本轮 attention,把 host 侧开销藏起来。
5.4 和 vLLM 的分页差异:别把 GPU 默认值直接搬过来
我在调参初期直接沿用 vLLM 的 block_size=16,结果吞吐没上去,反而页表传输开销变大。后来对比才发现,昇腾侧算子更在乎块内连续搬移,用 64 或 128 时 CUBE 利用率明显更稳。另外 vLLM 的 page table 是在 kernel 里逐 block 读取的,而这边很多实现更喜欢把页表铺平成[batch, max_blocks]传入,二者对 block_table 的预留宽度要求不一样。
迁移建议很简单:先跑一个纯 host 侧 benchmark,统计不同 block_size 下页表拷贝耗时和 kernel 耗时,找一个拐点,不要按惯性选值。
最后再分享一个调试技巧:双轨验证。我在开发阶段会保留一份线性 KV Cache 的简单实现,输出一个 golden log;Paged+Packed 版本每跑几步就对比一次 attention 中间结果。两边对不上的时候,用二分法逐渐缩窄到具体 token、具体 block、具体 slot,问题基本马上现形。等你把这段跑顺了,再回头看源码里那些看似绕路的 index 计算,每一条都有它的理由。