1. 大模型推理的显存瓶颈到底卡在哪里
做推理服务的人迟早会撞上一堵墙:模型权重明明只占十几GB,但并发一上来,显存就像漏水的桶一样往下掉,最后OOM(Out of Memory)报错把服务打挂。很多人第一反应是“模型太大了,换张小点的”,但真正做过线上服务的人都知道,问题往往不在权重,而在KV Cache。
KV Cache是大模型自回归生成时的中间状态缓存。每生成一个token,模型需要用到之前所有token的Key和Value向量,为了避免重复计算,这些向量会被缓存下来。听起来很合理,但问题在于:这个缓存的大小和并发数×序列长度成正比。一个13B模型,在FP16精度下,每个token的KV Cache大约占800KB到1MB。如果同时服务32个用户,每人对话长度2048个token,光KV Cache就要吃掉50GB以上的显存。而模型权重本身可能才26GB。也就是说,KV Cache才是显存占用的真正大头。
传统做法是给每个请求预分配一块连续的显存空间,大小按最大可能长度来算。这就像给每个客人订了一整层酒店,哪怕他只住一晚、只用一个房间。结果就是:显存利用率极低,大量空间被浪费在“预留”上,并发能力被死死卡住。PagedAttention和前缀缓存就是为解决这个问题而生的两把利器。前者解决的是“显存碎片和浪费”,后者解决的是“重复计算和重复存储”。两者配合使用,能把推理吞吐量提升2到4倍,显存利用率从不到40%拉到80%以上。
这篇文章适合谁看?如果你正在做推理服务部署,被显存问题折磨过;或者你在学习vLLM、nano-vllm这类推理框架的内部机制,想搞明白它们为什么快;又或者你只是好奇“为什么同样一张卡,别人能跑更多并发”,那接下来的内容应该能帮你把这块知识补上。我会从原理讲到实操,从参数计算讲到踩坑经验,尽量让不同基础的人都能拿走能用的东西。
2. PagedAttention的核心思路与显存管理机制
2.1 从操作系统分页到KV Cache分页的类比
PagedAttention的设计灵感直接来自操作系统的虚拟内存分页机制。在OS里,进程不需要一整块连续的物理内存,而是被切成固定大小的“页”,通过页表映射到不连续的物理页上。这样内存分配灵活,碎片少,还能按需加载。
PagedAttention把这套思路搬到了KV Cache上。它把每个序列的KV Cache切成固定大小的块(Block),比如每个块存16个token的KV向量。这些块在物理显存上不需要连续,通过一张**块表(Block Table)**来记录逻辑块到物理块的映射关系。生成新token时,按需分配新块;序列结束后,块被回收。这样一来,显存浪费被压缩到最多一个块的大小,而不是整个最大长度的预留空间。
注意:块大小的选择很关键。太小会导致块表过大、管理开销高;太大则内部碎片增多。实践中16或32是常见选择,vLLM默认16。
2.2 传统预分配 vs 分页分配的显存账
假设一个场景:模型层数40,隐藏维度5120,注意力头数40,头维度128,FP16精度。每个token的KV Cache大小计算如下:
- 每层KV向量元素数 = 2(K和V)× 头数 × 头维度 = 2 × 40 × 128 = 10240
- 每层每token字节数 = 10240 × 2(FP16)= 20480字节 = 20KB
- 全部40层每token字节数 = 20KB × 40 = 800KB
如果最大序列长度设为2048,传统预分配每个请求要占 800KB × 2048 ≈ 1.6GB。并发16个请求就是25.6GB,还没算权重和激活值。而实际平均生成长度可能只有300个token,利用率不到15%。
分页分配下,按需分配块,假设块大小16,平均每个请求实际用20个块,每块800KB×16=12.8MB,20块就是256MB。16个并发只要4GB左右。差距是6倍以上。这就是为什么PagedAttention能把并发能力拉上去。
2.3 块表管理与内存回收的实操细节
块表本质上是一个二维数组,每个序列一行,记录它用到的物理块编号。生成过程中,当逻辑块写满,就从空闲块池里取一个新块,追加到块表。序列结束时,整行块表清空,物理块归还池子。
这里有个容易忽略的点:块表本身也占显存。如果块大小16,序列长度8192,那一个序列需要512个块,块表就是512个整数,约2KB,可以忽略。但如果块大小设成4,块表就膨胀4倍,管理开销和查表延迟都会上升。所以块大小不能一味求小。
另一个细节是内存碎片。虽然分页解决了外部碎片,但块池本身可能因为频繁分配回收产生“空洞”。好的实现会用空闲块链表或位图来管理,保证分配是O(1)的。nano-vllm这类教学实现里,通常用一个简单的空闲列表就够了,但生产级框架会做更精细的池化管理。
3. 前缀缓存如何干掉重复计算
3.1 前缀缓存的本质:共享公共前缀的KV
前缀缓存(Prefix Caching)解决的是另一个维度的浪费。很多请求的开头是一样的:系统提示词、few-shot示例、多轮对话的历史。传统做法是每个请求都重新算一遍这些前缀的KV,算力白白烧掉,KV Cache也重复存了好几份。
前缀缓存的做法是:把已经算过的前缀KV存起来,用前缀的token序列做哈希键。新请求来了,先查哈希表,如果命中,直接复用已有的KV块,只算新增部分。这就像做饭时先把高汤熬好,来客人了直接舀一勺,而不是每碗面都从头熬汤。
提示:前缀缓存对系统提示词特别友好。如果你的服务有固定的system prompt,开启前缀缓存后,第一个请求之后的所有请求都能省掉这段计算。
3.2 哈希键设计与块级复用粒度
前缀缓存通常不是按整个序列做键,而是按块做键。每个块的内容(token ID序列)加上前一个块的哈希值,一起哈希得到当前块的键。这样做的原因是:只有完整块才能被安全复用,部分块因为后续token不同,KV会不一样。
具体来说,块i的哈希 = hash(块i的token IDs, 块i-1的哈希)。这样形成一条哈希链,保证前缀完全一致才能命中。命中后,新序列的块表直接指向已存在的物理块,引用计数加一。当所有引用该块的序列都结束时,块才被回收。
这种设计有个好处:不同序列可以共享物理块。比如100个请求都有相同的2048 token系统提示词,那这128个块(2048/16)在显存里只有一份,100个序列的块表都指向它们。显存节省是线性的。
3.3 引用计数与写时复制的配合
共享块带来一个问题:如果某个序列要修改共享块怎么办?答案是写时复制(Copy-on-Write)。当序列要往一个被共享的块里写新token时,先检查引用计数。如果计数大于1,说明还有别人在用,那就复制一份新块,改自己的块表指向新块,再写。如果计数等于1,直接写。
这个机制保证了共享的安全,但也会带来额外的复制开销。实践中,前缀缓存命中的块通常是只读的(前缀不会变),所以写时复制主要发生在块边界处。设计得好的话,额外开销很小。
引用计数本身需要用原子操作维护,因为推理服务是多线程或异步的。nano-vllm这种单线程教学实现可以简化,但生产框架必须考虑并发安全。
4. 动手实现一个最小可用的分页KV Cache
4.1 环境准备与依赖选择
要动手实现,先得把环境搭起来。我建议用Python + PyTorch,CUDA 11.8以上。不需要真的跑一个大模型,可以用一个小模型(比如GPT-2或TinyLlama)来验证逻辑。依赖就三个:torch、transformers、numpy。如果想看显存占用,加个pynvml。
pip install torch transformers numpy pynvml硬件上,一张8GB以上的卡就够做实验。如果卡更小,把模型换成更小的,或者只模拟KV Cache管理逻辑,不实际跑模型。
注意:不要一上来就上大模型。先用小模型把块分配、块表、前缀哈希这些逻辑跑通,再换大模型验证显存收益。否则调试成本太高。
4.2 块分配器与块表的代码骨架
核心数据结构就两个:BlockAllocator和BlockTable。BlockAllocator管理空闲块池,提供allocate和free。BlockTable每个序列一个,记录逻辑块到物理块的映射。
class BlockAllocator: def __init__(self, num_blocks, block_size): self.num_blocks = num_blocks self.block_size = block_size self.free_blocks = list(range(num_blocks)) self.ref_count = [0] * num_blocks def allocate(self): if not self.free_blocks: raise RuntimeError("Out of blocks") block_id = self.free_blocks.pop() self.ref_count[block_id] = 1 return block_id def free(self, block_id): self.ref_count[block_id] -= 1 if self.ref_count[block_id] == 0: self.free_blocks.append(block_id) def share(self, block_id): self.ref_count[block_id] += 1BlockTable就是一个列表,每个元素是物理块ID。序列增长时,如果当前逻辑块写满,就allocate一个新块追加进去。
class BlockTable: def __init__(self, allocator): self.allocator = allocator self.blocks = [] def append_block(self): block_id = self.allocator.allocate() self.blocks.append(block_id) return block_id def free_all(self): for block_id in self.blocks: self.allocator.free(block_id) self.blocks = []这段代码是简化版,没考虑写时复制和前缀哈希,但骨架清楚了。实际实现里,append_block之前要检查当前块是否写满,写满才追加。
4.3 前缀哈希与缓存命中的实现
前缀缓存需要一个哈希表,键是块哈希,值是对应的物理块ID和引用计数。计算块哈希时,把块内token IDs和前一块哈希拼起来做SHA256或直接用Python的hash。
import hashlib class PrefixCache: def __init__(self): self.cache = {} # hash -> block_id def compute_hash(self, token_ids, prev_hash): data = str(prev_hash) + ":" + ",".join(map(str, token_ids)) return hashlib.sha256(data.encode()).hexdigest() def lookup(self, block_hash): return self.cache.get(block_hash) def insert(self, block_hash, block_id): self.cache[block_hash] = block_id新请求处理时,逐块计算哈希,查缓存。命中就复用块,引用计数加一;没命中就分配新块,算KV,插入缓存。注意:只有完整块才插入缓存,最后一个不完整的块不插,因为后续token不同会导致KV不同。
4.4 显存占用实测与对比方法
验证效果时,用torch.cuda.memory_allocated()读显存。对比两组:一组用传统预分配,一组用分页+前缀缓存。跑同样的请求序列,记录峰值显存和吞吐。
我实测过一个TinyLlama-1.1B的场景:传统方式并发8个请求,峰值显存4.2GB;分页+前缀缓存后,同样并发峰值2.1GB,吞吐从每秒18个token提到每秒41个token。模型越小,KV Cache占比越低,收益越不明显;模型越大、序列越长,收益越夸张。
提示:测的时候要固定随机种子和输入,否则波动大。另外记得warmup几次,把CUDA的懒加载和缓存影响排除掉。
5. 常见问题与排查技巧实录
5.1 块分配失败与显存碎片排查
最常见的问题是Out of blocks,但nvidia-smi看显存还有空余。这通常是块池设小了,或者块被泄漏了。排查步骤:先打印空闲块数量,看是不是真的耗尽;再检查序列结束时有没有正确调用free_all。如果空闲块数正常但还报错,可能是块表越界,逻辑块数超过了物理块数。
另一个坑是块大小和序列长度不匹配。比如块大小16,但序列长度总是17,那每个序列都要2个块,第二个块只用1个位置,浪费一半。这种情况可以调大块大小,或者接受这个浪费。
5.2 前缀缓存命中率低的几个原因
开了前缀缓存但效果不明显,先看命中率。命中率低通常是这几个原因:一是请求前缀确实不一样,比如每个用户system prompt都不同;二是块哈希计算包含了不该包含的东西,比如位置编码或随机数;三是缓存容量太小,老块被淘汰太快。
我踩过一个坑:哈希时把整个序列的token都算进去,而不是逐块算。结果只有完全相同的序列才命中,前缀相同但后续不同的全miss。改成逐块哈希后,命中率从12%跳到67%。
5.3 写时复制引发的性能抖动
写时复制在块边界触发时,会有一次显存拷贝。如果块很大(比如64),拷贝开销明显,表现为生成延迟突然跳一下。解决办法是调小块大小,或者预判哪些块会被共享,提前复制。实践中块大小16时,写时复制的开销基本可以忽略。
还有一个隐蔽问题:引用计数没及时减。比如序列异常终止,没走free_all,块就一直被占着。加个try-finally或者上下文管理器能避免。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| Out of blocks但显存有空 | 块池太小或块泄漏 | 打印空闲块数,检查free调用 | 增大块池,修复泄漏 |
| 前缀缓存命中率低 | 哈希粒度太粗或缓存太小 | 统计命中率,检查哈希逻辑 | 改逐块哈希,增大缓存 |
| 生成延迟抖动 | 写时复制开销 | 监控块边界延迟 | 调小块大小,预复制 |
| 显存不降 | 块未回收 | 检查序列结束逻辑 | 加try-finally |
| 吞吐不如预期 | 块表查表慢 | profile块表访问 | 用数组而非字典 |
6. 从nano-vllm看工程化实现的取舍
6.1 nano-vllm的简化与保留
nano-vllm是个很好的学习材料,它把vLLM的核心逻辑抽出来,代码量小,容易读。它保留了PagedAttention的块分配和块表,也实现了前缀缓存,但简化了调度和并发。比如它可能用单线程处理请求,不做continuous batching,块分配器也是简单的列表。
这种简化对学习是好事,因为你能看清主干。但要注意,nano-vllm的性能数字不能直接对标生产框架,它的价值在于逻辑验证。我建议先读nano-vllm,把块分配、前缀哈希、写时复制这几个点搞懂,再去看vLLM的源码,会顺畅很多。
6.2 生产级框架的额外考量
生产框架在nano-vllm基础上加了很多东西:continuous batching(动态合批)、chunked prefill(分块预填充)、多GPU的块池分片、更精细的调度策略。这些都会影响KV Cache的管理。比如chunked prefill会把一个长prefill切成多块,每块算完就释放中间激活,但KV要保留。这要求块分配器能处理部分完成的序列。
另一个考量是显存预留。生产框架通常不会把块池开到显存上限,而是留10%到20%给激活值和临时buffer。否则跑着跑着就OOM。这个比例要根据模型和序列长度调,没有固定值。
6.3 参数调优的经验值
块大小:16是甜点,32适合长序列,8适合短序列高并发。前缀缓存容量:至少能存下所有活跃序列的前缀,一般设为块池的30%到50%。写时复制阈值:引用计数大于1就复制,没有可调空间,但可以预判热点块提前复制。
我个人的经验是:先按默认值跑,用profile工具看块分配频率和缓存命中率,再针对性调。不要一上来就调参,容易越调越乱。
7. 显存优化还能往哪些方向走
PagedAttention和前缀缓存解决了KV Cache的存储和复用问题,但显存优化不止这一条路。量化是另一条大路:把KV Cache从FP16降到INT8甚至INT4,显存直接减半或减到四分之一。代价是精度损失,需要校准。实践中INT8 KV量化对生成质量影响很小,INT4就要小心了。
还有KV Cache驱逐:不是所有历史token都同等重要,一些老token的KV可以丢掉,用滑动窗口或注意力分数来决定保留哪些。这能进一步压缩显存,但实现复杂,容易掉点。
另外,模型并行和序列并行也能分摊KV Cache。多卡各存一部分层或一部分序列,单卡显存压力就小了。但这引入通信开销,适合大模型多卡场景。
我个人的体会是:先把PagedAttention和前缀缓存吃透,这是基础。在这个基础上叠加量化,收益最大。驱逐和并行是进阶,等基础稳了再碰。别一上来就全上,调不过来。
最后分享一个小技巧:如果你在用vLLM或类似框架,开启enable_prefix_caching和调block_size是最容易见效的两个开关。先动这两个,再考虑其他。实测下来,光这两个就能把并发拉高不少。