☰
分块Attention省显存却慢70倍:KV Cache性能陷阱与调优
2026/10/19 14:34:54 网站建设 项目流程

1. 一个反直觉的性能现象:省了显存,却慢了70倍

第一次在压测环境里看到这个数据的时候,我盯着监控面板看了很久,怀疑是不是采集脚本写错了。显存占用确实降下来了,从原来的接近打满降到了六成左右,但吞吐量直接塌方,单步延迟从几十毫秒飙到了秒级,换算下来慢了将近70倍。这个数字不是夸张修辞,是实打实跑出来的对比。

分块 attention(chunked 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 的收益和你的并发请求数强相关。并发低的时候,显存根本不是瓶颈,分块带来的开销纯属负担。并发高到显存吃紧的时候,分块才有意义。所以要不要用分块、用多大的块,得结合你的实际并发水平来定,没有放之四海皆准的答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询