2026/10/19 14:35:04

分块Attention省显存却慢70倍:KV Cache性能陷阱与调优

分块Attention省显存却慢70倍:KV Cache性能陷阱与调优 1. 一个反直觉的性能现象省了显存却慢了70倍第一次在压测环境里看到这个数据的时候我盯着监控面板看了很久怀疑是不是采集脚本写错了。显存占用确实降下来了从原来的接近打满降到了六成左右但吞吐量直接塌方单步延迟从几十毫秒飙到了秒级换算下来慢了将近70倍。这个数字不是夸张修辞是实打实跑出来的对比。分块 attentionchunked attention / paged attention 这一类把 KV Cache 切块管理的思路在推理框架里几乎是标配。它的核心卖点很明确把原本需要连续大块显存存放的 KV Cache 拆成固定大小的块按需分配、按需回收从而大幅降低显存碎片和峰值占用。理论上这是纯赚的买卖——显存省了能塞下更长的上下文、更大的 batch吞吐应该更高才对。但现实给了我一巴掌。省显存和跑得快在这套机制下并不是同一件事甚至在很多配置下是互相打架的。这篇文章就把这个省了显存反而变慢的链路完整拆开讲清楚从源码层面看分块 attention 到底做了什么为什么它会引入额外的开销以及这些开销在什么条件下会被放大到70倍这种量级。适合已经跑过推理服务、对 KV Cache 有基本概念、但被性能问题卡住的同学。先说结论方向免得你带着错误预期往下读分块 attention 本身不是慢的根源慢的是分块带来的间接寻址、kernel 启动次数暴增、以及块调度与计算重叠失败这三件事的叠加。省显存是空间维度的收益慢是时间维度的代价两者能不能平衡取决于你的序列长度分布、batch 形态和硬件带宽。2. 分块 attention 到底在源码里做了什么2.1 从连续 KV Cache 到块表映射要理解慢在哪得先看清楚它把原本简单的事情复杂成了什么样。传统的连续 KV Cache 思路很直白一个请求预分配一段连续的显存第 i 个 token 的 key 和 value 就存在base i * stride的位置。取的时候一次偏移计算就够地址是连续的访存是线性的GPU 最喜欢这种模式。分块方案把这套逻辑整个换掉了。它维护一张块表block table逻辑上连续的 token 序列被切成固定长度的块常见是16或32个 token 一块每个块在物理显存里的位置是离散的。逻辑块号到物理块号的映射存在块表里取第 i 个 token 的 KV 时得先算逻辑块号 i / block_size再查块表拿到物理块号再算块内偏移i % block_size最后才能拼出真实地址。# 简化后的寻址逻辑真实实现里这层在 kernel 内部完成 def get_kv_addr(token_idx, block_table, block_size, base_ptr): logical_block token_idx // block_size offset_in_block token_idx % block_size physical_block block_table[logical_block] # 一次间接查表 return base_ptr physical_block * block_size offset_in_block单看一次寻址多了一次查表而已好像没什么。但 attention 计算里每个 token 都要对前面所有 token 做一次这样的寻址序列长度是 N 的话寻址次数是 O(N²) 量级。间接寻址意味着访存不再连续GPU 的合并访存coalesced access被打散cache 命中率下降这些在长序列下会被急剧放大。2.2 块表本身也是有成本的块表不是免费的。每个请求都要维护自己的块表块表要存在显存里调度器还要在每一步更新它。当 batch 里请求数很多、每个请求序列又很长的时候块表本身占的显存和更新块表的开销都不能忽略。更麻烦的是块表的生命周期管理。请求结束要回收块新请求进来要分配块块不够了要抢占preemption甚至换出swap。这套逻辑在调度器里是一堆分支判断和状态机。我见过的一个典型问题是块分配器在高并发下频繁触发碎片整理每次整理都要暂停调度、搬移数据这个停顿在监控上表现为周期性的延迟尖刺。提示如果你在 profiling 里看到延迟曲线是平稳 周期性尖刺的形态优先怀疑块分配器的整理或抢占逻辑而不是 attention kernel 本身。2.3 分块改变了 kernel 的调用形态连续 KV Cache 下一个 batch 里所有请求的 KV 可以拼成一个大张量attention kernel 一次调用就能处理完。分块之后每个请求的块在物理上不连续不同请求的块长度还不一样kernel 要么退化成按请求逐个处理要么得用 gather 把分散的块先收集起来。按请求逐个处理意味着 kernel 启动次数从 1 次变成 batch_size 次。kernel 启动本身有固定开销几微秒到几十微秒batch 一大这个开销就线性堆上去了。gather 方案则是用额外的访存换连续性同样有代价。这两条路都不如本来就是连续的来得省事。3. 70倍变慢的完整排查链路3.1 第一步确认慢在计算还是慢在调度遇到性能塌方第一件事是分清瓶颈在 GPU 计算还是在 CPU 调度。我的做法是同时看 GPU 利用率和 CPU 侧调度线程的占用。如果 GPU 利用率很低比如30%以下但延迟很高基本可以判定是调度或数据搬运卡住了GPU 在等活干。当时的数据是 GPU 利用率只有两成多SM 大部分时间空闲。这就排除了attention 计算本身变重的可能——如果是计算变重GPU 应该跑满才对。问题出在 GPU 拿不到连续的活或者每次拿到的活太小。3.2 第二步数 kernel 启动次数接着上 profiler看单位时间内的 kernel 启动次数。连续 KV Cache 版本下一个 decode step 大概启动个位数到十几次 kernel。分块版本下同样的 step 启动了上百次。每次启动都有固定开销而且启动之间还有间隙GPU 在这些间隙里就是空转。这里有个容易被忽略的点kernel 启动开销在单次看是微秒级不起眼但 decode 是自回归的每个 token 都要走一遍。序列越长累积的启动开销越吓人。70倍这个量级很大一部分就是这么一次次攒出来的。3.3 第三步检查块大小配置块大小block_size是个关键参数但很多人直接用默认值不动它。块太小块表变长间接寻址次数变多块管理开销上升块太大内部碎片严重省显存的效果打折而且大块在 gather 时搬运的数据量也大。我实测过一组对比在同样的序列长度分布下块大小显存占用单步延迟说明8最低最高块表过长寻址开销主导16较低中等多数场景的平衡点32中等较低碎片开始明显64偏高最低接近连续方案省显存效果弱当时出问题的配置块大小是8属于把省显存做到极致、把性能牺牲到底的那一档。改成16之后延迟直接降了一个数量级虽然还是比连续方案慢但已经不是70倍那种灾难了。3.4 第四步看块调度和计算有没有重叠理想情况下块调度分配、回收、整理应该和 GPU 计算重叠进行调度在 CPU 侧跑GPU 在算互不耽误。但实际实现里如果调度逻辑和计算在同一个线程里串行或者调度需要等 GPU 同步点重叠就失败了。判断方法很简单看 GPU 时间线上有没有大段空白。有空白就说明 GPU 在等调度。当时的时间线上每个 decode step 之间都有明显的空隙空隙长度和块分配器的整理耗时对得上。这就是省显存和跑得快打架的直接证据——为了省显存引入的块管理反过来把 GPU 饿着了。4. 为什么省显存和跑得快会互相打架4.1 空间换时间还是时间换空间计算机系统里有个老规律空间和时间往往不能同时最优。连续 KV Cache 是用空间换时间——预留大块连续显存换来寻址简单、访存连续、kernel 调用少。分块 attention 是用时间换空间——牺牲寻址效率和 kernel 效率换来显存占用的下降。这个 trade-off 本身没问题问题在于很多人只看到了省显存这一面默认它没有代价或者代价小到可以忽略。在短序列、小 batch 的场景下代价确实小感知不到。但一旦序列变长、batch 变大时间维度的代价就会指数级放大而空间维度的收益是线性的。两条曲线一交叉就出现了省了显存反而慢70倍。4.2 长序列是放大器分块 attention 的额外开销几乎每一项都和序列长度正相关甚至超线性相关。间接寻址次数是 O(N²)块表长度是 O(N/block_size)kernel 启动次数随 step 数线性增长。序列越长这些开销越突出。反过来连续 KV Cache 的开销基本和序列长度无关就是一次偏移计算。所以序列越长两者的性能差距越大。如果你的业务场景是长上下文比如几万 token 的文档问答分块 attention 的性能代价会比你想象的严重得多。4.3 硬件特性也在推波助澜GPU 的显存带宽和访存模式强相关。连续访存能跑满带宽离散访存可能只能用到带宽的一小部分。分块 attention 的间接寻址天然是离散的块表查出来的物理块号是随机的访存模式对 GPU 极不友好。再加上 L2 cache 的命中率问题。连续 KV Cache 下相邻 token 的 KV 在 cache line 里是挨着的一次加载能命中多个。分块之后逻辑相邻的 token 物理上可能隔了很远cache line 利用率暴跌。这些硬件层面的惩罚在 profiler 里表现为 memory throughput 上不去但你又找不到明显的计算热点。5. 把性能找回来的实操方案5.1 块大小调优从默认值开始往上试块大小是最容易调、见效最快的参数。我的建议是从16起步往上试到32、64观察显存和延迟的曲线。多数场景下16到32之间能找到平衡点。如果你的显存本来就够用直接上64甚至更大让分块方案尽量接近连续方案的访存特性。调的时候别只看平均值要看 P99 延迟。块大小对平均延迟的影响可能不明显但对尾延迟影响很大因为块管理开销在请求数多的时候才会集中爆发。5.2 让块调度和计算真正重叠如果确认瓶颈在调度和计算没重叠就得从实现层面解决。核心思路是把块分配、回收、整理这些操作放到独立的线程或流里和 GPU 计算并行。调度器要能预判下一步需要哪些块提前准备好而不是等计算要用了才去分配。这块涉及框架内部改动不是配置能解决的。但有些框架提供了异步调度的开关先确认这个开关有没有打开。我遇到过默认关闭异步调度、打开之后延迟直接降一半的情况。5.3 混合策略短序列走连续长序列走分块不是所有请求都值得用分块。短序列的 KV Cache 本来就占不了多少显存用连续方案又快又省心。可以按序列长度做分流预估长度低于阈值的走连续 KV Cache超过阈值的才走分块。这样既拿到了长序列的显存收益又避免了短序列被分块的开销拖累。实现上需要在调度器里加一层判断但收益通常很可观尤其是请求长度分布双峰大量短请求 少量长请求的场景。5.4 用 prefix caching 减少重复计算如果业务里有大量共享前缀的请求比如同一个系统提示词、同一份文档的多次问答prefix caching 能直接跳过前缀部分的 attention 计算。这和分块 attention 是正交的优化可以叠加使用。前缀部分只算一次后续请求复用既省显存又省计算。注意prefix caching 的收益高度依赖前缀的复用率。如果每个请求的前缀都不一样这个优化基本没用别白费力气。6. 几个我踩过的坑和实测心得第一个坑是盲目相信默认配置。框架给的默认块大小是针对通用场景调的不一定适合你的负载。我那次70倍变慢根因就是默认块大小太小加上序列特别长两个因素叠加。换配置之前我一度以为是框架有 bug查了半天源码才发现是自己没调参。第二个坑是只看显存不看延迟。省显存是个很直观的指标监控面板上一眼就能看到很容易让人满足于显存降下来了就收工。但推理服务的核心指标是延迟和吞吐显存只是约束条件。为了省显存把延迟搞崩是典型的捡了芝麻丢了西瓜。第三个心得是profiling 要分层做。先看 GPU 利用率定位大方向再看 kernel 时间线找具体热点最后看调度线程确认有没有串行等待。一上来就钻 kernel 细节很容易在错误的方向上浪费时间。我现在的习惯是先花五分钟看全局指标再决定往哪个方向深挖。第四个心得是性能问题要复现不要猜。同一个配置在不同序列长度分布下表现可能完全相反。我习惯准备几组固定的压测负载短序列为主、长序列为主、混合每次改配置都跑一遍用数据说话。凭感觉调参调到最后自己都不知道哪个配置好了。最后说个容易被忽略的点分块 attention 的收益和你的并发请求数强相关。并发低的时候显存根本不是瓶颈分块带来的开销纯属负担。并发高到显存吃紧的时候分块才有意义。所以要不要用分块、用多大的块得结合你的实际并发水平来定没有放之四海皆准的答案。