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 为什么成为大模型推理瓶颈
设每层有 个 KV head,head dim 为 ,batch 为 ,上下文长度为 ,元素字节数为 ,层数为 。K 和 V 两份缓存总容量是
以 、FP16、 为例:
这还没有包含模型权重、激活、allocator 保留页和 attention workspace。若把 8 个 KV head 物理展开到 32 个 query head,容量会再乘 4。GQA 的意义不只是少几个参数,它直接降低 decode 每步必须读取的缓存字节数。
单 token decode 的矩阵很“瘦”: 只有一行,Tensor Core 很难像 prefill 大 GEMM 一样饱和;随着 增大,它越来越接近显存带宽和访存延迟受限。因此要同时优化两件事:页面管理不能制造额外复制,kernel 也不能先 gather 成连续大张量再做 attention。
2. 连续缓存、分页缓存与精度语义
连续缓存通常布局为
它的地址计算简单,但每个请求必须预留最大长度;请求结束或增长时可能留下不可复用空洞。Paged KV Cache 把物理显存切成固定 token 数的 block:
其中 是物理页数, 是 page size。每个请求只保存逻辑页到物理页的 block_table。逻辑 token 的地址为
最后一页最多浪费 个 token,而不是浪费到 。
图 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 都额外读写整个历史缓存,流量至少又增加
并产生大临时 buffer。分页的正确边界是把地址翻译融合进 attention kernel。
3.3 把 GQA 展开成 MHA
若 ,repeat_interleave 会把 K/V 物理复制 份。它让普通 MHA kernel 可以运行,却消除了 GQA 的容量与带宽收益。正确映射应是
多个 query head 在读取时共享同一个 KV head,而不是事先复制。
4. 向量化 paged append
每个 decode step 先把当前层的新 K/V 写进最后一页。标量 kernel 每个线程写一个 half,还可能让 K 和 V 分成两个 launch。对 且地址 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 持有 个 query 和输出 fragment,沿 token 循环:
- 通过
block_table得到 K/V page; - 合并读取对应 KV head 的 K,warp reduce 得到一个 score;
- 用在线 Softmax 更新 ;
- 每个 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。
图 2:32 个 query head 映射到 8 个 KV head;warp 沿逻辑 token 前进,每跨 page size 才更新物理页,FP32 的 状态跨页保留。
6. 从基础实现到极致优化
6.1 page id 不要每个元素都查
page table 查询应位于 token tile 或 page 边界,而不是每个 元素。一个 warp 广播 page id,随后 lane 连续读取 head dim。PageSize=16 时,单次表查询可以服务 16 个 token。
6.2 packed load 与布局
对 NHD 布局 [page, token, head, dim],同一 head 的 连续,适合 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 很小、 很长时,batch * query_heads 仍可能不足以填满所有 SM。把 K 维分成 段,每段 CTA 产生 ,再按在线 Softmax 的合并律归约:
split 增加临时状态和第二个归约 kernel,只有并行度收益大于额外流量时才启用。应按 和 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 固定为 、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。
| Context | FlashInfer paged GQA | Expanded contiguous Flash SDPA | 延迟比 | 原生 KV Cache | 展开后 K/V |
|---|---|---|---|---|---|
| 1024 | 0.131 ms | 0.838 ms | 6.42x | 32 MiB | 128 MiB |
| 4096 | 0.457 ms | 3.283 ms | 7.19x | 128 MiB | 512 MiB |
| 8192 | 0.953 ms | 6.683 ms | 7.01x | 256 MiB | 1024 MiB |
| 16384 | 1.785 ms | 13.275 ms | 7.44x | 512 MiB | 2048 MiB |
| 32768 | 3.623 ms | 跳过 | - | 1024 MiB | 需要 4096 MiB |
32K 对比路径在缓存之外只剩约 3914 MiB 可用,却需要 4096 MiB 展开 K/V,因此按预先设定的安全阈值跳过,没有把系统推入不受控 OOM。分页 GQA 路径完成了 1 GiB 单层缓存的 decode。
在可比 shape 上,最大绝对误差从 降到 ,相对 误差约 到 ,符合 FP16 输出和不同归约顺序。
8.2 Fused paged append
append 对比 FlashInfer fused kernel 与两次 PyTorch advanced-index store。每次写入 8 个请求的一枚 K/V token,时间主要受 launch 和索引开销控制。
| Context | Fused append | 两次 indexed store | 加速 |
|---|---|---|---|
| 1024 | 0.0344 ms | 0.1266 ms | 3.67x |
| 4096 | 0.0312 ms | 0.1159 ms | 3.71x |
| 8192 | 0.0340 ms | 0.1138 ms | 3.35x |
| 16384 | 0.0278 ms | 0.1102 ms | 3.96x |
| 32768 | 0.0271 ms | 0.1043 ms | 3.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. 参考资料
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!













