大模型推理的显存瓶颈几乎是每个做推理服务的人绕不开的一道坎。我最早接触这块是在部署一个 7B 模型的时候单条请求跑得好好的一旦并发上来显存直接爆掉服务开始疯狂 OOM。当时第一反应是加卡但加卡之后发现吞吐并没有线性增长显存利用率依然很低。后来才慢慢搞明白问题不在算力而在 KV Cache 的管理方式上。传统做法给每个请求预分配一大块连续显存不管实际用多少先占着再说结果就是大量显存被浪费在预留上。PagedAttention 和前缀缓存这两个东西就是专门来解决这个问题的。这篇内容我会从 KV Cache 的本质讲起把 PagedAttention 的分页思路、前缀缓存的复用逻辑、以及实际落地时的参数调优和踩坑经验都摊开来说适合已经跑过推理服务、想进一步压榨显存吞吐的同行参考。1. KV Cache 到底吃掉了多少显存1.1 从自回归生成说起要理解显存优化得先搞清楚显存到底被谁吃了。大模型推理分两个阶段prefill 和 decode。prefill 阶段把整段 prompt 一次性喂进去算出所有 token 的 Key 和 Value 矩阵decode 阶段每生成一个新 token都要拿当前 token 的 Query 去和前面所有 token 的 Key 做注意力计算。问题来了如果每生成一个 token 都重新算一遍前面所有 token 的 K 和 V计算量会随序列长度平方增长完全不可接受。所以工程上的做法是把已经算过的 K 和 V 缓存下来这就是KV Cache。每生成一步只需要算当前 token 的 K、V追加到缓存里然后拿 Query 和整个缓存做注意力。这样每步的计算量从 O(n²) 降到 O(n)代价是显存里要常驻一份随序列长度线性增长的缓存。这份缓存有多大拿一个具体的例子算一下。假设模型是 7B 规模层数 32隐藏维度 4096注意力头数 32每个头的维度 128数据类型 FP162 字节。单个 token 的 KV Cache 大小是2 (K和V) × 32 (层) × 32 (头) × 128 (头维度) × 2 (字节) 524288 字节 ≈ 0.5 MB也就是说每存一个 token 的 KV就要花掉 0.5MB 显存。一条 2048 token 的请求光 KV Cache 就是 1GB。如果并发 32 条这样的请求KV Cache 直接吃掉 32GB这还没算模型权重本身占的显存。一张 80GB 的卡权重占 14GB 左右剩下的空间几乎全被 KV Cache 吞掉能支撑的并发数非常有限。1.2 传统预分配为什么浪费严重更让人头疼的是传统实现里的预分配策略。大多数推理框架在请求进来时会按照max_seq_len一次性给这个请求分配一块连续的显存比如最大长度设成 4096那就直接按 4096 个 token 的容量分配。但实际请求的平均输出长度可能只有几百个 token剩下的空间全程空着既不能用给别的请求也不能释放。我实测过一个场景把max_seq_len设成 4096实际平均输出长度 300 token 左右显存利用率只有 7% 到 10%。这意味着 90% 的 KV Cache 显存是纯浪费。而且这种浪费是碎片化的——每块连续显存大小固定请求长短不一长请求来了找不到足够大的连续块短请求又占着大块不放最后就是显存明明还有余量却分配不出连续空间只能排队或者 OOM。这里有个容易被忽略的点显存碎片不只是外部碎片还有内部碎片。内部碎片就是上面说的预分配但没用满的部分外部碎片是块与块之间因为大小不匹配留下的空隙。传统方案两个都占所以显存利用率才那么难看。1.3 优化方向的两个切入点搞清楚浪费在哪优化方向就清晰了。一是减少内部碎片别再按最大长度预分配而是按需分配、动态增长二是减少外部碎片让显存块可以灵活组合不要求连续。这两个方向对应到具体技术上就是 PagedAttention 解决分页管理前缀缓存解决跨请求复用。顺带说一句KV Cache 的量化比如 INT8、FP8也是省显存的路子但它和 PagedAttention 是正交的可以叠加使用。这篇主要聊分页和前缀缓存这两块量化后面有机会单独展开。2. PagedAttention 的分页思路拆解2.1 操作系统虚拟内存的类比PagedAttention 的核心灵感来自操作系统的虚拟内存分页。操作系统管理物理内存时不会给每个进程分配一整块连续内存而是把物理内存切成固定大小的页比如 4KB进程看到的是连续的虚拟地址实际映射到离散的物理页上。这样既避免了连续分配的压力又能按需加载。PagedAttention 把这套思路搬到了 KV Cache 上。它把 KV Cache 切成固定大小的block注意这里叫 block不是 page虽然思路一样每个 block 存固定数量 token 的 KV比如 16 个 token。一个请求的 KV Cache 不再是一整块连续显存而是由若干个 block 组成这些 block 在物理显存上可以离散分布通过一张block table记录逻辑顺序到物理 block 的映射。这样做的好处立竿见影请求需要多少 token 的缓存就分配多少个 block用多少给多少内部碎片最多浪费最后一个 block 里没用满的几个 token 位置浪费率从原来的 90% 降到 4% 以内block 越小浪费越少但管理开销越大后面会讲怎么权衡。2.2 block 大小怎么选block 大小是 PagedAttention 里最关键的参数之一直接决定显存浪费率和管理开销的平衡。block 越小最后一个 block 的浪费越少但 block table 越长调度器管理 block 的开销越大而且每个 block 的元数据比如引用计数、状态标记占比上升。我做过一组对比测试在 7B 模型、平均输出 300 token 的场景下block 大小内部碎片浪费率block table 条目数2048 token调度开销感受8约 1.3%256明显偏高16约 2.6%128适中32约 5.2%64较低64约 10%32低实测下来block 大小 16 是大多数场景的甜点区。8 的话调度开销能感觉到尤其是高并发时 block table 的查找和更新会成为瓶颈32 以上浪费率又上去了。当然这不是绝对的如果你的请求普遍很长比如都是几千 token 的长文本block 可以适当调大因为最后一个 block 的浪费被摊薄了。注意block 大小一旦确定运行中不能改因为它决定了 block table 的结构和显存池的切分方式。上线前一定要根据实际业务的请求长度分布来定别拍脑袋。2.3 显存池与 block 分配器PagedAttention 的显存管理通常是一个预切分的显存池。启动时先划出一大块显存按 block 大小切成固定数量的 block维护一个空闲 block 列表。请求来了从空闲列表里取 block请求结束block 归还。这个池子的大小决定了能同时支撑多少 token 的 KV Cache。分配器要处理几种情况新请求进来分配初始 blockdecode 过程中序列变长需要追加 block请求结束释放所有 block。追加 block 时如果空闲列表空了就得触发抢占或者排队。这里有个细节block 的分配和释放要保证线程安全高并发下多个请求同时申请 block锁的粒度太粗会成为瓶颈太细又容易出竞态。主流实现一般用无锁队列或者分段锁来优化。还有个容易踩的坑显存池大小不是越大越好。池子划太大留给模型权重和中间激活的显存就少了prefill 阶段可能因为激活显存不够而失败。一般建议显存池占剩余显存的 80% 到 90%留一点余量给临时张量。2.4 注意力计算怎么适配分页分页之后注意力计算不能再假设 KV 是连续的得改成按 block 遍历。具体来说计算某个 token 的注意力时要沿着 block table 找到它对应的所有历史 block逐个 block 加载 KV和 Query 做计算。这个过程在 CUDA 层面通常写成一个专门的 kernel叫paged attention kernel。这个 kernel 的性能很关键因为 decode 阶段每步都要调用。优化点包括把 block table 放进共享内存减少全局访存、对 block 内的 KV 做向量化加载、把多个 head 的计算合并到一次 kernel launch 里。我见过一些实现因为 kernel 写得不够好分页带来的显存收益被计算开销吃掉了吞吐反而下降。所以选框架的时候别只看它支不支持 PagedAttention要看它的 paged attention kernel 有没有做过优化。3. 前缀缓存让相同前缀的请求共享 KV3.1 前缀缓存的适用场景PagedAttention 解决的是单个请求内部的显存浪费前缀缓存解决的是跨请求的重复计算和重复存储。很多实际业务里大量请求共享同一段前缀。最典型的是系统提示词system prompt比如一个客服机器人所有请求前面都挂着一大段角色设定和知识背景这段内容每个请求都要重新 prefill 一遍算出来的 KV 还各存各的纯属浪费。前缀缓存的思路是把已经算过的前缀 KV 缓存起来新请求如果前缀命中直接复用缓存的 KV跳过这部分 prefill 计算也跳过这部分 KV 的重复存储。命中率高的场景下prefill 计算量能降一大截显存也能省下大量重复的 KV。我实测过一个场景system prompt 有 1500 token用户实际输入平均 100 token。不开前缀缓存时每条请求要 prefill 1600 token开了之后1500 token 的 prefill 被复用每条请求只 prefill 100 tokenprefill 计算量降到原来的 1/16首 token 延迟TTFT从 400ms 降到 80ms 左右。这个收益在长 system prompt 场景下非常可观。3.2 前缀匹配的粒度与哈希前缀缓存的核心是怎么判断两个请求的前缀相同。最直接的做法是对 token 序列做哈希按 block 粒度缓存。因为 PagedAttention 已经把 KV 切成 block 了前缀缓存天然可以按 block 来组织每个 block 对应一段固定长度的 token对这段 token 算一个哈希值哈希值相同就认为 block 内容相同可以复用。这里有个关键点哈希要基于 token 序列本身而不是请求 ID 或者别的。因为不同请求只要 token 序列一样KV 就应该一样。哈希函数要选抗碰撞好的比如用一段 token 的字节做 SHA256 或者更轻量的 xxHash。碰撞了会导致复用了错误的 KV输出直接错乱这个后果很严重。匹配的时候是从头开始逐 block 比对。第一个 block 哈希命中继续比第二个直到某个 block 不命中为止命中的部分就是可复用的前缀。这里要注意block 大小要和 PagedAttention 的 block 大小一致否则没法直接复用 block 级别的 KV。3.3 缓存淘汰与引用计数前缀缓存不能无限增长得有淘汰机制。常见的是LRU最近最少使用把最久没被命中的缓存 block 淘汰掉释放显存。但这里有个坑一个 block 可能被多个请求同时引用比如多个请求共享同一段 system prompt淘汰的时候不能直接释放得看引用计数。引用计数的逻辑是每个缓存 block 维护一个计数器有请求引用它时加一请求结束释放引用时减一。计数归零的 block 才进入可淘汰状态。这个机制保证了正在被使用的缓存不会被误删。实现上引用计数的增减要和 block 的分配释放放在同一个临界区里否则容易出现刚判断可以淘汰结果另一个请求又引用了的竞态。提示LRU 的淘汰策略在请求模式比较规律时效果好但如果请求前缀分布很散命中率低缓存反而占着显存不干活。上线前建议先统计一下实际请求的前缀重复率重复率低于 20% 的话前缀缓存的收益可能抵不过它带来的管理开销。3.4 前缀缓存和 PagedAttention 的协同这两个技术不是独立的而是深度协同的。前缀缓存复用的就是 PagedAttention 管理的 block命中前缀后直接把缓存 block 的引用挂到新请求的 block table 上引用计数加一不需要拷贝 KV 数据。这就是为什么前缀缓存能同时省计算和显存——它复用的是同一份物理 block。协同的关键在于block table 要支持共享。传统 block table 是一个请求独占一组 block现在要支持多个请求的 block table 指向同一批物理 block。这要求 block 的元数据里记录引用计数释放时按计数来。实现上block table 本身还是每个请求一份但里面的 block 指针可以重复。这个设计带来的一个额外好处是copy-on-write的潜力。如果两个请求共享了前缀但后续生成分叉了分叉点之后的 block 各自独立分配分叉点之前的继续共享。这样既最大化了复用又保证了各请求输出的独立性。4. 落地时的参数调优与实测数据4.1 显存池与 block 数的换算上线前第一件事是算清楚显存池能切出多少个 block。公式很简单可用显存 总显存 - 模型权重显存 - 激活显存预留 - 框架开销 block 数 可用显存 / (block 大小 × 单 token KV 大小)拿 80GB 卡、7B 模型举例权重约 14GB激活预留 8GB框架开销 2GB可用约 56GB。单 token KV 是 0.5MBblock 大小 16那单个 block 是 8MB。56GB / 8MB 7168 个 block也就是能同时缓存 7168 × 16 114688 个 token 的 KV。这个数字决定了理论最大并发。如果平均每条请求占 500 token那理论并发上限约 229 条。实际因为请求长度不均、前缀缓存占用等因素能到 150 到 200 条就不错了。建议留 10% 到 20% 的 block 余量应对突发长请求避免频繁触发抢占。4.2 抢占策略的选择当 block 池耗尽新请求或者需要追加 block 的请求就得等。这时候有两种策略swap和recompute。swap 是把被抢占请求的 KV 换出到 CPU 内存等有空间了再换回来recompute 是直接丢掉被抢占请求的 KV等它重新调度时重新 prefill 一遍。swap 的好处是不丢计算坏处是 CPU-GPU 带宽成为瓶颈换出换入很慢。recompute 的好处是省了换出换入的开销坏处是重新 prefill 要花算力。实测下来短序列用 recompute 更划算长序列用 swap 更划算因为长序列重新 prefill 的代价太高。有些框架支持根据序列长度自动选择这个策略值得关注。我踩过的一个坑是抢占策略配错了导致长请求被反复 recomputeTTFT 飙到几秒。后来改成按序列长度动态选长请求走 swap短请求走 recompute延迟才稳定下来。4.3 实测吞吐对比在一个 7B 模型、单张 80GB 卡的环境下我用相同的请求集平均输入 200 token平均输出 300 token并发 64做了对比配置吞吐token/s显存利用率平均 TTFT传统预分配820约 12%380msPagedAttention2100约 68%210msPagedAttention 前缀缓存2900约 75%95ms可以看到PagedAttention 单独用就把吞吐提了 2.5 倍加上前缀缓存又提了 38%。TTFT 的改善主要来自前缀缓存跳过了 system prompt 的 prefill。这个数据不是绝对的跟模型、请求分布、硬件都有关但趋势是明确的分页解决显存浪费前缀缓存解决重复计算两者叠加收益最大。4.4 几个容易忽略的调优细节第一个细节是block 池的预热。服务刚启动时block 池是空的第一批请求会触发大量 block 分配延迟偏高。可以在启动时预分配一部分 block或者用几个 warmup 请求把池子填起来让正式流量进来时更平稳。第二个细节是前缀缓存的哈希计算开销。每个请求进来都要对前缀做哈希如果前缀很长哈希本身也要花时间。优化方法是增量哈希每来一个 block 就更新一次哈希状态而不是每次从头算。这个优化在长前缀场景下能省不少 CPU 时间。第三个细节是block table 的内存布局。block table 本身也占显存如果条目太多它的访存会成为瓶颈。把 block table 设计成紧凑的数组配合共享内存缓存热点条目能明显降低访存延迟。这个属于 kernel 层面的优化用现成框架的话一般已经做好了但自己改 kernel 时要注意。5. 从 nano-vllm 看关键功能的实现骨架5.1 为什么建议读 nano-vllm想真正搞懂 PagedAttention 和前缀缓存光看论文和文档不够得看代码。vLLM 是工业级实现代码量大、抽象层多新手直接啃容易迷路。nano-vllm 是个很好的学习起点它把核心逻辑抽出来代码量小结构清晰PagedAttention 的 block 管理、前缀缓存的哈希匹配、调度器的抢占逻辑都能在里面找到对应实现。读 nano-vllm 的时候建议按这个顺序先看 block 分配器理解 block 池和空闲列表怎么维护再看 block table理解逻辑到物理的映射然后看调度器理解请求怎么被调度、block 怎么被分配和释放最后看前缀缓存理解哈希匹配和引用计数怎么和 block 管理结合。这个顺序是从底层到上层符合依赖关系。5.2 block 管理器的核心数据结构nano-vllm 里 block 管理器的核心是几个数据结构一个空闲 block 列表通常用队列实现一个 block 元数据数组记录每个 block 的引用计数、哈希值、状态一张 block table每个请求一份记录它用了哪些 block。这几个结构配合起来就实现了 block 的分配、释放、共享。分配逻辑是从空闲列表取 block初始化元数据挂到请求的 block table 上。释放逻辑是引用计数减一归零则归还空闲列表。共享逻辑是命中前缀缓存时直接把缓存 block 挂到新请求的 block table引用计数加一不拷贝数据。这三段逻辑是 PagedAttention 的心脏理解了它们整个机制就通了。5.3 调度器怎么和 block 管理配合调度器负责决定哪些请求这一轮能跑、能跑多少 token。它和 block 管理的配合点在于调度前要先检查 block 池够不够不够的话要么抢占已有请求要么把新请求排队。调度时按请求的优先级和到达顺序分配 block分配完更新 block table。这里有个设计选择是调度前一次性分配所有需要的 block还是边跑边分配。一次性分配简单但请求实际用不了那么多 block 时会浪费边跑边分配灵活但调度逻辑复杂。nano-vllm 用的是边跑边分配decode 每步检查是否需要追加 block需要就分配分配不到就触发抢占。这个设计更省显存但调度器的状态管理要更小心。5.4 自己动手改一个功能读完代码最好的巩固方式是改一个功能。比如给 nano-vllm 加一个简单的统计记录 block 池的峰值使用率、前缀缓存的命中率、抢占发生的次数。这几个指标能帮你直观感受不同参数下的行为差异。改的时候注意别破坏原有的引用计数逻辑统计代码要放在临界区外面避免影响性能。再进阶一点可以试着改 block 大小观察显存利用率和吞吐的变化验证前面说的甜点区。或者改前缀缓存的淘汰策略从 LRU 换成 LFU看命中率有没有变化。这些实验做下来对机制的理解会比单纯读代码深得多。6. 踩过的坑和几条实战经验6.1 block 大小设错导致的性能悬崖我最早把 block 大小设成 4想着浪费最小。结果高并发下 block table 条目爆炸调度器的查找和更新成了瓶颈吞吐不升反降。后来改成 16吞吐直接翻倍。这个坑的教训是别只盯着浪费率管理开销同样重要。block 太小元数据占比上升调度开销吃掉收益block 太大浪费率上升。16 到 32 之间是安全区具体看请求长度分布。6.2 前缀缓存哈希碰撞的隐蔽性哈希碰撞这个问题很隐蔽因为碰撞概率低测试时不容易复现但一旦发生就是输出错乱。我遇到过一次某个特定前缀的请求偶尔输出乱码排查了很久才发现是哈希函数选得太弱两个不同的 token 序列算出了同一个哈希。换成抗碰撞更好的哈希函数后问题消失。哈希函数别图省事用简单的取模或者异或用 xxHash 或者 SHA 系列开销可控安全性高得多。6.3 引用计数泄漏导致显存不释放引用计数泄漏是另一个难查的问题。表现是服务跑一段时间后显存占用越来越高但请求量并没有增加。原因是某些路径下 block 的引用计数加了没减block 永远归不了零也就永远不释放。排查方法是加日志记录每个 block 的引用计数变化找出只增不减的路径。常见泄漏点是异常路径——请求中途出错时释放逻辑没走到计数就漏了。异常处理里一定要保证 block 释放用 try-finally 或者类似的机制兜底。6.4 抢占策略和业务延迟的权衡抢占策略直接影响尾延迟。如果配置不当长请求被反复抢占尾延迟会非常难看。我的经验是对延迟敏感的业务宁可多留 block 余量也别频繁触发抢占对吞吐敏感的业务可以激进一点让抢占来平滑负载。这个权衡没有标准答案得根据业务 SLA 来定。上线前压测一下不同抢占阈值下的 P99 延迟心里有数再定。6.5 监控指标要盯哪几个最后说几个必须监控的指标block 池使用率判断显存是否吃紧、前缀缓存命中率判断缓存是否有价值、抢占次数判断负载是否过载、TTFT 和 TPOT判断用户体验。这几个指标配合起来能快速定位大部分问题。比如命中率突然下降可能是请求模式变了抢占次数飙升可能是并发超了容量。监控做扎实排查问题能省一半时间。这套东西我前后折腾了小半年从最开始被 OOM 追着跑到后来能把显存利用率稳定在 70% 以上中间踩的坑基本都写在这了。PagedAttention 和前缀缓存不是什么黑魔法核心就是把操作系统的分页和缓存思想搬到了 KV Cache 管理上理解了这一点剩下的都是工程细节。真正难的是把这些细节调对而这只能靠实测和踩坑积累。