CUDA KV Cache:分页存储与长上下文 Decode 极致优化

3014 字
15 分钟
CUDA KV Cache:分页存储与长上下文 Decode 极致优化

自回归大模型每次只生成一个 token,却必须让新 query 访问此前所有 token 的 key/value。KV Cache 把过去 token 的 K/V 保留下来,避免每步重算整个前缀;代价是缓存容量随 batch、层数和上下文线性增长,而且 decode 每步都要重新流式读取这些历史数据。

当服务端同时处理变长请求时,单纯把缓存预留成连续的 max_seq_len 数组,会把显存浪费、碎片、拷贝和 attention kernel 寻址绑在一起。本文从 CUDA 地址计算出发,实现分页 append 与 paged GQA decode,再讨论长上下文下的 warp 划分、split-KV、量化和调度边界。

1. KV Cache 为什么成为大模型推理瓶颈#

设每层有 HkvH_{kv} 个 KV head,head dim 为 DD,batch 为 BB,上下文长度为 LL,元素字节数为 ss,层数为 NlN_l。K 和 V 两份缓存总容量是

MKV=2BNlLHkvDs.M_{KV}=2BN_lLH_{kv}Ds.

Nl=32,Hkv=8,D=128,L=32768N_l=32,H_{kv}=8,D=128,L=32768、FP16、B=1B=1 为例:

MKV=2×1×32×32768×8×128×2=4 GiB.M_{KV}=2\times1\times32\times32768\times8\times128\times2 =4\ \text{GiB}.

这还没有包含模型权重、激活、allocator 保留页和 attention workspace。若把 8 个 KV head 物理展开到 32 个 query head,容量会再乘 4。GQA 的意义不只是少几个参数,它直接降低 decode 每步必须读取的缓存字节数。

单 token decode 的矩阵很“瘦”:QQ 只有一行,Tensor Core 很难像 prefill 大 GEMM 一样饱和;随着 LL 增大,它越来越接近显存带宽和访存延迟受限。因此要同时优化两件事:页面管理不能制造额外复制,kernel 也不能先 gather 成连续大张量再做 attention。

2. 连续缓存、分页缓存与精度语义#

连续缓存通常布局为

[B,Lmax,2,Hkv,D].[B,L_{max},2,H_{kv},D].

它的地址计算简单,但每个请求必须预留最大长度;请求结束或增长时可能留下不可复用空洞。Paged KV Cache 把物理显存切成固定 token 数的 block:

cache[P][2][T][Hkv][D],\text{cache}[P][2][T][H_{kv}][D],

其中 PP 是物理页数,TT 是 page size。每个请求只保存逻辑页到物理页的 block_table。逻辑 token tt 的地址为

pl=tT,o=tmodT,p=block_table[b,pl].p_l=\left\lfloor\frac{t}{T}\right\rfloor,\qquad o=t\bmod T,\qquad p=\mathrm{block\_table}[b,p_l].

最后一页最多浪费 T1T-1 个 token,而不是浪费到 LmaxL_{max}

Paged KV logical-to-physical mapping
Paged KV logical-to-physical mapping

图 1:请求看到连续的逻辑 token,物理页可以离散。append 和 decode 直接通过 page table 寻址,不生成连续 gather buffer;共享前缀可以让不同请求的逻辑页指向同一只读物理页。

本文缓存和 query 为 FP16,点积、在线 Softmax 和输出 accumulator 为 FP32。BF16 可扩大数值范围但流量不变。FP8/INT8 cache 可以近似减半流量和容量,代价是每页或每组 scale、dequant 指令和精度校准;不能只改存储类型却继续按 FP16 解释地址。FP64 不属于在线大模型 decode 的合理默认路径。

3. 朴素路径的三个隐藏成本#

3.1 按最大长度预留#

如果 100 个请求都按 32K 预留,而多数只生成几百 token,逻辑空闲空间仍占物理显存。连续 batching 能提高计算吞吐,却会放大不同生命周期带来的外部碎片。

3.2 先 gather 再 attention#

一种容易实现的 paged attention 是:根据 page table 把 K/V gather 成连续张量,再调用 GEMM/SDPA。这样每个 decode step 都额外读写整个历史缓存,流量至少又增加

2×MKV,active layer,2\times M_{KV,\mathrm{active\ layer}},

并产生大临时 buffer。分页的正确边界是把地址翻译融合进 attention kernel。

3.3 把 GQA 展开成 MHA#

Hq/Hkv=gH_q/H_{kv}=grepeat_interleave 会把 K/V 物理复制 gg 份。它让普通 MHA kernel 可以运行,却消除了 GQA 的容量与带宽收益。正确映射应是

hkv=hqg,h_{kv}=\left\lfloor\frac{h_q}{g}\right\rfloor,

多个 query head 在读取时共享同一个 KV head,而不是事先复制。

4. 向量化 paged append#

每个 decode step 先把当前层的新 K/V 写进最后一页。标量 kernel 每个线程写一个 half,还可能让 K 和 V 分成两个 launch。对 D=128D=128 且地址 16-byte 对齐的常见 shape,可以把 8 个 FP16 打成一个 uint4,一个 block 同时完成地址翻译和连续写入。

template <int HeadDim, int PageSize>
__global__ void append_paged_kv(
const half* __restrict__ new_kv, // [B, 2, Hkv, D]
half* __restrict__ cache, // [P, 2, T, Hkv, D]
const int* __restrict__ block_table,
const int* __restrict__ sequence_len,
int max_pages,
int num_kv_heads) {
const int batch = blockIdx.x;
const int kind_head = blockIdx.y;
const int kind = kind_head / num_kv_heads;
const int head = kind_head % num_kv_heads;
const int token = sequence_len[batch] - 1;
const int logical_page = token / PageSize;
const int page_offset = token % PageSize;
const int physical_page = block_table[batch * max_pages + logical_page];
constexpr int kHalfPerVector = sizeof(uint4) / sizeof(half);
const int vector_index = threadIdx.x;
if (vector_index < HeadDim / kHalfPerVector) {
const size_t source =
(((batch * 2 + kind) * num_kv_heads + head) * HeadDim);
const size_t target =
((((static_cast<size_t>(physical_page) * 2 + kind) * PageSize
+ page_offset) * num_kv_heads + head) * HeadDim);
reinterpret_cast<uint4*>(cache + target)[vector_index] =
reinterpret_cast<const uint4*>(new_kv + source)[vector_index];
}
}

这个 kernel 适用于 HeadDim % 8 == 0、输入和 cache 都满足 16-byte 对齐的路径。否则应 dispatch 到 half2 或 masked scalar 尾部,而不是越界。batch 很小时,append 只有几十 KiB,瓶颈通常是 launch latency,不应拿有效带宽低就断言 global memory 很慢;融合 K/V 和多请求才是主要收益。

5. Paged GQA decode 的 warp 数据流#

一个直接的 CUDA 映射是每个 warp 负责一个 (request, query_head)。lane 持有 D/32D/32 个 query 和输出 fragment,沿 token 循环:

  1. 通过 block_table 得到 K/V page;
  2. 合并读取对应 KV head 的 K,warp reduce 得到一个 score;
  3. 用在线 Softmax 更新 m/lm/l
  4. 每个 lane 读取自己的 V fragment 并更新 FP32 输出。
template <int HeadDim, int PageSize>
__global__ void paged_decode(
const half* __restrict__ q,
const half* __restrict__ cache,
const int* __restrict__ page_table,
const int* __restrict__ lengths,
half* __restrict__ out,
int query_heads,
int kv_heads,
int max_pages) {
const int lane = threadIdx.x & 31;
const int warp = threadIdx.x >> 5;
const int query_head = blockIdx.y * (blockDim.x / 32) + warp;
const int batch = blockIdx.x;
if (query_head >= query_heads) return;
const int group_size = query_heads / kv_heads;
const int kv_head = query_head / group_size;
float q_fragment[HeadDim / 32];
float o_fragment[HeadDim / 32] = {};
load_query_fragment(q, batch, query_head, lane, q_fragment);
float running_max = -CUDART_INF_F;
float running_sum = 0.0F;
for (int token = 0; token < lengths[batch]; ++token) {
const int logical_page = token / PageSize;
const int offset = token % PageSize;
const int page = page_table[batch * max_pages + logical_page];
const half* key = cache_ptr(cache, page, 0, offset, kv_head);
const half* value = cache_ptr(cache, page, 1, offset, kv_head);
float score = dot_fragment<HeadDim>(q_fragment, key, lane);
score = warp_sum(score) * rsqrtf(static_cast<float>(HeadDim));
const float next_max = fmaxf(running_max, score);
const float old_scale = __expf(running_max - next_max);
const float weight = __expf(score - next_max);
#pragma unroll
for (int i = 0; i < HeadDim / 32; ++i) {
o_fragment[i] = o_fragment[i] * old_scale
+ weight * __half2float(value[lane + 32 * i]);
}
running_sum = running_sum * old_scale + weight;
running_max = next_max;
}
store_output(out, batch, query_head, lane, o_fragment, running_sum);
}

这段代码表达了正确的页寻址和在线归一化,但生产实现还会让多个 warp/CTA 协作、使用 packed load、减少整数除法、缓存 page id,并对长上下文做 split-KV。

Paged GQA decode pipeline
Paged GQA decode pipeline

图 2:32 个 query head 映射到 8 个 KV head;warp 沿逻辑 token 前进,每跨 page size 才更新物理页,FP32 的 m/l/Om/l/O 状态跨页保留。

6. 从基础实现到极致优化#

6.1 page id 不要每个元素都查#

page table 查询应位于 token tile 或 page 边界,而不是每个 DD 元素。一个 warp 广播 page id,随后 lane 连续读取 head dim。PageSize=16 时,单次表查询可以服务 16 个 token。

6.2 packed load 与布局#

对 NHD 布局 [page, token, head, dim],同一 head 的 DD 连续,适合 half2/128-bit load。HND 布局 [page, head, token, dim] 则让同一 head 跨 token 更连续,具体选择取决于 CTA 是并行 token 还是并行 head。不要只凭名字判断,应该从 warp 实际地址验证 transaction。

6.3 GQA 共享与 CTA 组织#

同组 query head 读取相同 K/V。若每个 warp 独立读取,L2 可能复用但 HBM 流量不稳定;更激进的实现让一个 CTA 同时处理一组 query head,把 K/V tile 放进 shared memory,再由多个 warp 消费。代价是 shared memory、寄存器和同步增加,小 batch 或短上下文可能不划算。

6.4 长上下文 split-KV#

当 batch 很小、LL 很长时,batch * query_heads 仍可能不足以填满所有 SM。把 K 维分成 SS 段,每段 CTA 产生 (ms,ls,os)(m_s,l_s,o_s),再按在线 Softmax 的合并律归约:

m=maxsms,m=\max_s m_s,l=slsemsm,o=sosemsm,O=o/l.l=\sum_s l_s e^{m_s-m},\qquad o=\sum_s o_s e^{m_s-m},\qquad O=o/l.

split 增加临时状态和第二个归约 kernel,只有并行度收益大于额外流量时才启用。应按 B,Hq,L,DB,H_q,L,D 和 SM 数量 dispatch,而不是固定切段。

6.5 page size、碎片与 TLB 式权衡#

小 page 降低最后一页内部碎片,却增加 page table、allocator 元数据和地址切换;大 page 相反。16 token 是常见折中,不是定理。超长上下文、prefix cache 和 beam search 还要考虑只读页共享与 copy-on-write:共享前缀不能被后续 append 原地覆盖。

6.6 量化、RoPE 与 CUDA Graph#

FP8/INT8 KV cache 应让 dequant 与 attention 融合,scale 最好按 page/head 或更细粒度存储;粒度越细精度越好,scale 流量和指令越多。RoPE 通常在写 K 前完成,或在读取时按位置融合,二者影响 cache 是否可跨位置复用。CUDA Graph 要求 workspace、page table buffer 和 kernel dispatch 稳定,不能每步 cudaMalloc 或改变 host 控制流。

7. 大规模 serving 的分层边界#

CUDA kernel 只负责给定 page table 的高效读写。真正的大模型系统还需要上层 runtime:free-list/block allocator、continuous batching、请求抢占、prefix hash、跨请求共享、CPU/NVMe offload 和张量并行。把这些策略塞进一个 kernel 会失去可测试性。

合理分层是:scheduler 决定 active requests,block manager 维护逻辑页,append kernel 写当前 token,paged attention 只消费不可变的本轮 page table。OneFlow 的系统设计同样强调 Kernel 只向 CUDA stream 提交异步工作,资源生命周期和调度由上层边界管理。

8. 独占 GPU benchmark#

测试环境为 RTX 3060 Laptop 6 GiB、SM86、driver 581.57、PyTorch 2.11.0+cu130、FlashInfer 0.6.12。shape 固定为 B=8,Hq=32,Hkv=8,D=128B=8,H_q=32,H_{kv}=8,D=128、FP16、page size 16,context 从 1K 到 32K。每项 warmup 后做 7 组 CUDA Event 测量,报告组均值的中位数。

测试前后均无其他 compute PID;运行监控达到 100% SM、7000 MHz 显存、1965 MHz 核心、102 W、76°C,临时 130 W power limit 在结束后恢复到 100 W。

8.1 Paged GQA decode#

分页路径使用 FlashInfer BatchDecodeWithPagedKVCacheWrapper。对比路径把 8 个 KV head 预先展开成 32 个连续 head,再强制调用 PyTorch Flash SDPA。这个对比代表两种可运行的大模型数据路径,但 6 到 7 倍差距主要同时包含 GQA 不复制和专用 decode 调度收益,不能单独归因于 paging

ContextFlashInfer paged GQAExpanded contiguous Flash SDPA延迟比原生 KV Cache展开后 K/V
10240.131 ms0.838 ms6.42x32 MiB128 MiB
40960.457 ms3.283 ms7.19x128 MiB512 MiB
81920.953 ms6.683 ms7.01x256 MiB1024 MiB
163841.785 ms13.275 ms7.44x512 MiB2048 MiB
327683.623 ms跳过-1024 MiB需要 4096 MiB

32K 对比路径在缓存之外只剩约 3914 MiB 可用,却需要 4096 MiB 展开 K/V,因此按预先设定的安全阈值跳过,没有把系统推入不受控 OOM。分页 GQA 路径完成了 1 GiB 单层缓存的 decode。

在可比 shape 上,最大绝对误差从 1.22×1041.22\times10^{-4} 降到 3.05×1053.05\times10^{-5},相对 L2L_2 误差约 3.86×1043.86\times10^{-4}4.00×1044.00\times10^{-4},符合 FP16 输出和不同归约顺序。

8.2 Fused paged append#

append 对比 FlashInfer fused kernel 与两次 PyTorch advanced-index store。每次写入 8 个请求的一枚 K/V token,时间主要受 launch 和索引开销控制。

ContextFused append两次 indexed store加速
10240.0344 ms0.1266 ms3.67x
40960.0312 ms0.1159 ms3.71x
81920.0340 ms0.1138 ms3.35x
163840.0278 ms0.1102 ms3.96x
327680.0271 ms0.1043 ms3.84x

append 延迟几乎不随 context 线性增长,因为每步只写新 token;context 影响的是目标页地址和后续 decode 读取量。这里有效带宽不到显存峰值并不矛盾:写入量只有 32 KiB,kernel launch 和索引固定成本占主导。

9. 局限与回退#

本文只测单 GPU、单层 cache 数据和无 RoPE/ALiBi 的 FP16 GQA decode,没有测完整模型 tokens/s。32 层 32K cache 的容量可以按公式外推,但不能把单层 kernel 延迟简单乘层数后当作端到端延迟,因为层间还有 GEMM、通信和调度。

page table 本身不能保证更快;请求长度接近且显存充足时,连续 cache 地址更简单。极小 batch、短 context 可回退到 contiguous decode;长 context 或并行度不足才启用 split-KV。量化 cache 必须以任务精度验证为前提,OOM 时的正确回退是降低 batch/context、启用 GQA/量化/offload,而不是越界复用物理页。

10. 参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Profile Image of the Author
橙子派
你好,我是橙子派。
公告
橙子派的博客!
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
8
分类
3
标签
22
总字数
30,358
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
chengzipai v6.13.3
文章许可
None

文章目录