先交代一个背景。前一阵我在一台 24G 显存的卡上折腾 Llama 系列的对话服务,单请求跑得好好的,一旦并发上来就反复 OOM。一开始我以为是权重太大,查了才发现模型权重只占几个 GB,真正把显存吃干抹净的是 KV Cache。从那时候起,我开始认真啃大模型推理的显存优化,核心就两条主线:PagedAttention 和前缀缓存(Prefix Caching)。这篇文章算是我一边读 vLLM 源码、一边用 nano-vllm 动手复现的一个总结,目标是把这两套机制的原理、实现和调参经验一次性讲清楚。
这篇内容适合两类人:一类是想从应用层面理解推理引擎为什么省显存的工程师,另一类是打算自己动手实现一个迷你推理框架、想搞懂底层机制的学习者。如果你只是想在部署时配置一下推理框架的参数,前面几节可以帮你建立直觉;如果你想自己写推理代码,后面第 4 节的实现思路可以当模板。我会先讲 KV Cache 为什么是显存大头,再拆 PagedAttention 的分块机制,然后是前缀缓存的复用原理,最后给出基于 nano-vllm 的复现代码和踩坑记录。
1. 推理显存账本:KV Cache 是怎么把显存吃光的
1.1 一个 7B 模型的计算,看清每个 Token 的代价
KV,全称 Key-Value。Transformer 做自回归生成的时候,每生成一个新 token,Attention 层需要拿当前 query 去跟历史上所有 token 的 key 和 value 做计算。如果每次都重新算历史 token 的 key 和 value,计算量会随着序列长度平方级增长,完全不可接受。所以推理框架会把每个 token 的 K、V 存下来,随生成过程不断追加。这坨缓存就叫 KV Cache。
KV Cache 的大小只跟模型结构和数据类型有关,跟总参数量没有直接关系。公式很简单:
单个 token 的 KV 显存 = 2(K 和 V 两份)× 层数 × KV 头数 × 头维度 × 每个元素字节数
我用 LLaMA-2-7B 的配置来算:32 层、32 个 attention head、head_dim=128,FP16 每个元素 2 字节。
2 × 32 × 32 × 128 × 2 = 524,288 字节,约 512KB也就是每生成一个 token,就要多吃 512KB 显存。看起来不多?4096 个 token 就是 2GB,如果并发 8 个请求,就是 16GB。在 24G 的卡上,模型权重量化后能压到个位数 GB,但 8 路并发的 KV 加起来直接把你逼到 OOM。
如果换成 13B 模型:40 层、40 头,单 token 就到 800KB,4096 上下文单路 3.2GB,32 路并发超过 100GB,一块 80G 的 A100 都不够。这就是为什么大模型推理服务要拼命优化 KV Cache 的显存效率。顺带提一句,如果你做 FP8 量化,每个元素从 2 字节降到 1 字节,KV 整体减半,但代价是长上下文的精度漂移,后面踩坑部分我会细说。
1.2 同一批请求长度不同,预分配就是浪费
更麻烦的是,请求的长度和生成速度完全不同。有人问一句话就结束,有人能连续生成 2000 token。传统做法是给每个并发槽位预分配一个"最大长度"的显存缓冲区——按最坏情况分。
我举个例子:你有 32 个槽位,每个按 2048 个 token 的最大上下文预分配 KV 空间。7B 模型下,每个槽位预分配 1GB,32 个槽位就把 32GB 显存吃掉了。但如果这批请求实际平均只跑了 500 token,就只用了 8GB,剩下的 24GB 全部空转。这是第一种浪费,我管它叫"按上限预分配的固定开销"。
还有第二种浪费,是动态分配连续内存时产生的碎片。请求 A 生成了 1000 token 后结束,它占的连续空间被释放,但这个空档的大小不一定能塞进下一个请求。连续分配模式下,新请求必须找一整块连续显存,空档太碎就用不上。久而久之,可用显存的总量够,但连续的大块没有了,照样 OOM。
这两个问题指向同一个根子:KV Cache 需要的是细粒度的、按需分配的内存管理,而不是连续大块预分配。PagedAttention 就是冲着这个去的。
2. PagedAttention:把操作系统内存分页那套搬过来
2.1 核心思想:逻辑连续,物理离散
PagedAttention 是 vLLM 论文里提出的核心机制,思路直接借了操作系统虚拟内存的分页设计。进程看到的地址空间是连续的逻辑地址,实际映射到物理内存里离散的页框;操作系统维护一张页表记录映射关系。KV Cache 也这么管:
把 KV Cache 切成固定大小的"块"(block),每块装固定数量的 token,vLLM 默认 16 个 token。每个请求持有一张 Block Table,记录它的逻辑块对应哪些物理块。生成的时候按需分配新块,用完的块随时释放给别的请求。
这样一改,两个问题同时解决:不需要按最大长度预分配,实际生成了多少 token 就占多少块;物理上不需要连续,逻辑块散落在显存各处,通过 Block Table 找得到就行。这里有个容易忽略的隐喻:操作系统的分页解决的是"进程地址空间膨胀"的问题,PagedAttention 解决的是"KV Cache 随生成动态增长"的问题,本质都是把一个动态增长的连续资源切成可重定位的小单元。
2.2 Block 与 Block Table 的数据结构
用一个最小实现来说明。KV Cache 池是一块扁平的大显存,切成 num_blocks 个大小相等的块,每个块能存 block_size 个 token 的 KV 数据。块内数据布局大概是 block_size × num_kv_heads × head_dim,K 和 V 各一份。逻辑块是请求自己看到的"第 0 块、第 1 块……",Block Table 是一个数组,下标是逻辑块号,值是物理块号。
# 每条序列的 Block Table # logical_block_index -> physical_block_id block_table = [4, 15, 7, 23] # 意思是:第 0 个逻辑块在物理块 4,第 1 个在物理块 15 # 第 2 个逻辑块在物理块 7,第 3 个在物理块 23分配逻辑很简单。生成到第 16 个 token,第 0 块满了,就向空闲块列表要一个物理块,把块号追加到 Block Table 末尾。请求结束,归还它的所有物理块。这些物理块之后可能被完全不同的请求以任意顺序拿回去用,位置不固定,但没关系——Block Table 在,注意力计算就不会迷路。
KVCacheManager 的核心就两个动作:分配和释放。我在 nano-vllm 里写的版本大概长这样:
class KVCacheManager: def __init__(self, num_blocks: int, block_size: int): self.num_blocks = num_blocks self.block_size = block_size self.free_list = list(range(num_blocks)) self.ref_count = [0] * num_blocks def allocate(self, n: int) -> list[int]: if n > len(self.free_list): raise RuntimeError("KV cache block pool exhausted") blocks = self.free_list[:n] del self.free_list[:n] for b in blocks: self.ref_count[b] = 1 return blocks def free(self, blocks: list[int]) -> None: for b in blocks: self.free_list.append(b) self.ref_count[b] = 0实际生产代码还要处理引用计数、copy-on-write(beam search、并行采样时会共享前缀块)、CPU 换出(抢占时把块的数值拷到 CPU 内存)等逻辑,但核心骨架就是上面这几行。ref_count字段虽然在这个简化版里没怎么派上用场,但它是最重要的地基:后面讲前缀缓存共享块的时候,全靠引用计数决定一个块能不能释放。
2.3 离散存储下注意力计算怎么工作
分块之后,写 Attention 内核就不能假设 K 和 V 在显存里连续了。标准 attention 是把整段 K 拿出来跟 Q 做矩阵乘,分块之后是一个个物理块轮流参与计算。nano-vllm 因为是教学目的,用纯 PyTorch 实现了简化的 paged attention:
def paged_attention(query, block_table, kv_cache, block_size, valid_len): # query: [num_heads, head_dim] # kv_cache: [num_blocks, block_size, num_kv_heads, head_dim] scores = [] for logical_idx, phys_idx in enumerate(block_table): k_block = kv_cache[phys_idx] # [block_size, num_heads, head_dim] scores_block = torch.einsum("hd,bhd->bh", query, k_block) if logical_idx == len(block_table) - 1: # 最后一个逻辑块不一定装满,只保留有效的部分 tail_len = valid_len - logical_idx * self.block_size scores_block = scores_block[:tail_len] scores.append(scores_block) scores = torch.cat(scores, dim=1) # [num_heads, total_kv_len] probs = torch.softmax(scores, dim=-1) # 同样的方式逐块累加 V真正的 vLLM 是把这个循环写进 CUDA kernel 里,避免逐块启动 kernel 的开销。但核心逻辑是一样的:外层遍历 Block Table,块内做连续访问,块间靠指针跳转。
提示:最后一个逻辑块一定要记录真实 token 数。如果不做掩蔽,残次 token 的 KV 会被算进去,结果就错了。这是我复现时踩的第一个坑。
另外,FlashAttention 那种 tiling 思路和 PagedAttention 并不冲突。FlashAttention 切的是计算维度,PagedAttention 切的是存储维度。vLLM 后续把两者融合进了统一的 kernel,一个物理块内部依然可以用 tile 的方式做分块矩阵乘,块的物理地址不连续也不影响。
3. 前缀缓存:把重复计算的 KV 直接省掉
3.1 聊天、Few-shot、RAG 场景里到处都是重复前缀
PagedAttention 解决的是"怎么省着用"的问题,前缀缓存解决的是"怎么不重复算"的问题。
真实业务里,大量请求的 prompt 前缀是完全一样的。最典型的是多轮对话:每轮的完整上下文 = 系统提示 + 前面的对话轮次 + 本轮输入。第 10 轮请求的 prompt 里,前 9 轮的内容跟上一轮完全一致,只有最后新输入的几句话是新的。我算过一笔账:如果每轮各 500 token,第 10 轮请求的 prompt 有 5000 token,其中 4500 token 上一轮已经算过 KV 了。不开前缀缓存,这 4500 token 的 prefill 就是纯浪费。
Few-shot 场景同理。你给模型的示例和指令通常固定不变,变的是末尾那条用户输入。RAG 场景也常见——多轮追问同一份文档时,文档内容作为上下文前缀反复出现在每个请求里。请求体量越大、前缀占比越高,前缀缓存的收益就越夸张。它做的事很简单:把算好的前缀 KV 块缓存起来,新请求到达时,在 prompt 里从前往后找最长匹配的前缀,直接复用缓存块,只对不匹配的尾部重新计算。
3.2 按 Block 粒度做哈希匹配
前缀缓存的实现关键是"怎么判断两个前缀一样"。不能真的逐 token 比对整串 prompt,那样太慢。业界通用做法是对每个块做哈希,用哈希值做键查缓存。
nano-vllm 里的简化实现:
import hashlib import struct class PrefixCache: def __init__(self, block_size: int = 16): self.block_size = block_size self._blocks = {} # block_hash -> physical_block_id self._model_version = "llama-2-7b-fp16-v1" def _hash(self, tokens: list[int]) -> str: payload = struct.pack(f">{len(tokens)}I", *tokens) payload += self._model_version.encode() return hashlib.sha256(payload).hexdigest() def match(self, prompt_tokens: list[int]) -> tuple[int, list[int]]: hit_len = 0 cached_blocks = [] for start in range(0, len(prompt_tokens), self.block_size): block_tokens = prompt_tokens[start:start + self.block_size] h = self._hash(block_tokens) phys = self._blocks.get(h) if phys is None: break cached_blocks.append(phys) hit_len += len(block_tokens) return hit_len, cached_blocks流程是:新请求到达,把 prompt 按 block_size 切块,逐块算哈希,在缓存字典里找。命中第 0 块,继续找第 1 块;找到第 5 块的时候没命中,就停止。命中的块直接共享物理块,剩下没命中的部分才走正常 prefill 流程分配新块。
vLLM 实际用的是 RadixTree(前缀树)而不是简单字典。树的结构能让不同请求的最大公共前缀都被复用,比如 A 请求前缀是 "system + 对话1",B 请求前缀是 "system + 对话2",两者共享 "system" 这一段公共子串。加上引用计数和 LRU 淘汰,缓存块不够时把最久没用的前缀块释放掉。
注意:哈希碰撞虽然概率极低,但在生产环境必须考虑。最稳妥的做法是把模型版本、tokenizer 版本也混进哈希因子,或者在命中后对原始 token 做一次抽样比对。vLLM 还支持把 hidden states 也纳入哈希的选项,代价是哈希计算变重,但能彻底避免"相同 token 串因上下文不同导致语义不同"造成的错误复用。
3.3 缓存命中省下的不只是显存,还有 prefill 算力
前缀缓存的好处要分开算。第一是省显存:同一时刻大量请求共享前缀块,KV 池里只有一份,而不是每个请求各存一份。第二是省算力:复用前缀的请求只需要 prefill 尾部新增 token 的 KV,其余部分直接跳过 prefill。
我实测过一个调度场景。系统提示 1500 token,7B 模型,单请求 KV 约 768MB,prefill 1500 token 占一次请求大约 60% 的耗时。开了前缀缓存以后,新请求只有尾部 100~300 token 需要 prefill,时延直接下降一半以上。这里有个关键认知:decode 阶段的 attention 依旧会从头读取前缀 KV 块,所以前缀缓存省的是 prefill 的计算,不是 decode 的计算。很多人刚接触时把这两件事混为一谈,结果发现吞吐没有想象中提升那么多。省 prefill 带来的直接收益是首 token 时延(TTFT)大幅下降,同时因为新请求占用的新块极少,单位时间内能并发跑更多请求,整体吞吐是间接提升的。
4. 用 nano-vllm 动手走一遍完整流程
4.1 nano-vllm 是个什么项目
nano-vllm 是一个教学性质的最小推理框架,核心目标就是让人用几百行代码看懂 vLLM 的关键机制:连续批处理(continuous batching)、PagedAttention、前缀缓存、抢占(preemption)。它不追求性能极限,但把调度器和 KV 管理器的骨架留得非常干净,特别适合对照学习。
我建议的学习路径是:先不看 vLLM 主仓库,因为里面的 CUDA kernel 和分布式逻辑太多,新手很容易迷失。nano-vllm 把同样的机制用 Python 加少量 GPU 算子搭出来,读起来舒服得多。整个推理主循环可以抽象成四步:调度、分配、执行、回收。
class NanoVLLM: def __init__(self, model, tokenizer, block_size=16, num_blocks=4096): self.model = model self.tokenizer = tokenizer self.block_size = block_size self.kv_manager = KVCacheManager(num_blocks, block_size) self.prefix_cache = PrefixCache(block_size) self.scheduler = Scheduler() def step(self): # 1. 调度:决定这轮跑哪些序列 batch = self.scheduler.schedule(self.kv_manager) # 2. 分配:每个序列看看还缺几个块,从空闲列表拿 new_blocks = {} for seq in batch: need = seq.need_more_blocks(self.block_size) if need > 0: new_blocks[seq.id] = self.kv_manager.allocate(need) seq.block_table.extend(new_blocks[seq.id]) # 3. 执行模型前向,把新 token 的 KV 写入物理块 outputs = self.model.forward(batch, self.kv_manager) # 4. 终止序列归还块 for seq in batch: if seq.finished: self.kv_manager.free(seq.block_table) self.scheduler.remove(seq)第 3 步是耗时大头,但也是最好理解的一步:模型前向跑完,每个 token 的 K、V 按它在序列中的位置,写入对应逻辑块对应的物理块偏移处。写的位置由 Block Table 决定,跟逻辑顺序无关。第 4 步看似简单,却是最容易出 bug 的地方——请求异常终止、超时、客户端断开,这些分支都要确保走归还逻辑。
4.2 调度器如何与 KV 池联动
调度器的作用是决定每一轮把哪些请求放进批次。关键约束是:KV 池的总块数有限,running 状态的请求占用的块数不能超过池容量。
nano-vllm 的 Scheduler 维护三个队列:waiting(prompt 还没开始 prefill)、running(正在 decode)、swapped(被踢出去、KV 在 CPU 上)。调度时按优先级把 waiting 里的请求拉进 running,同时检查池容量;超了就执行抢占,把最不活跃的请求换出到 CPU。
"按优先级拉请求"这一步,就是前缀缓存和调度器的联动点。请求进来先查前缀缓存,命中部分不分配新块,只有未命中部分需要新块。所以同样一个池子,前缀命中率越高,能同时 running 的请求就越多。这是前缀缓存带来吞吐提升的根本原因。
一个我做实验时印象很深的对比:同样 24G 显存、固定 4096 个 block、块大小 16,无前缀缓存时,8 路 4096 上下文并发就触顶;开前缀缓存且命中率 80% 时,能同时跑 40 路都不崩,因为大部分请求只占五分之一的块。这个数字差异不是模型变快了,而是共享消除了重复占用。
4.3 前缀缓存和 Block Table 的组装顺序
新请求的完整处理流程,我整理成下面这个顺序,照着写基本不会漏:
- 把 prompt 切成 token id 列表
- 按 block_size 逐块查前缀缓存,得到 hit_len 和命中的物理块列表
- 把命中的物理块挂到新请求的 Block Table 上,引用计数 +1
- 对尾部未命中的 token,走正常 prefill 流程,边算边分配新物理块
- prefill 完成进入 decode 循环;每生成一个新 token 就往最后一个逻辑块里写 KV,写满了再分配新块
第 4 步有个容易忽略的边界:如果 prompt 的总长度恰好不是 block_size 的整数倍,最后一个逻辑块只装了不到 block_size 个 token。后面 decode 新 token 时,先往这个没满的块里追加,而不是随手分配一个新块。这个逻辑写错的话,要么浪费一个块,要么 attention 的时候越界。
第 3 步的共享也有讲究。多个请求共享同一物理块时,任何一个请求都不能随意改写这个块的内容——因为别的请求还在用。所以共享块的写入必须发生在块被创建的那一次 prefill 里,后续请求只能读。如果框架支持 beam search 之类的分支生成,共享块在需要分叉时要做 copy-on-write:只有真正被修改的路径才复制新块,其余路径继续共享。
5. 块大小、池容量与显存预算的调参实操
5.1 block_size 不是越大越好也不是越小越好
块大小是 PagedAttention 里最值得调的参数。vLLM 默认 16,但不同场景差异不小。块太小,Block Table 变长,Attention kernel 需要遍历更多块,启动开销和地址跳转的开销变大;块太大,内部碎片化变严重。
内部碎片化公式很简单:平均每条序列,最后一个逻辑块平均浪费 block_size/2 个 token 的空间。以 7B、512KB/token 为例:
| block_size | 平均每条浪费 | 1000 条并发浪费总量 |
|---|---|---|
| 16 | 8 token × 0.5MB = 4MB | 4GB |
| 32 | 16 token × 0.5MB = 8MB | 8GB |
| 64 | 32 token × 0.5MB = 16MB | 16GB |
| 128 | 64 token × 0.5MB = 32MB | 32GB |
但块也不是越小越好。块大小 8 时,Attention 内核对每块的开销占比明显上升,吞吐下降约 10%。我实际测下来,16 是吞吐和碎片之间很好的平衡点;请求上下文普遍很长(超过 4K)时,可以试 32,能减少块表开销;短请求占多数(少于 512 token)时,16 甚至 8 更合适。
实操建议:不要只盯着碎片率。把 block_size 和吞吐画一张曲线,边界条件(最大并发、最长上下文)下测一遍再定。默认 16 起步,永远不要在生产环境直接用 128 这种大块,除非你的业务请求长度极其统一。
5.2 KV 池容量与显存预算的账本
部署时最常配错的参数是给 KV 池留多少显存。推理框架的 gpu_memory_utilization 默认 0.9,意思是 GPU 显存的 90% 会被框架纳入管理(权重、激活、KV 池全算在内),剩下 10% 给 CUDA context、临时激活和碎片。
算一遍 KV 池容量:以 7B FP16 权重占约 14GB、A100 80GB 为例。权重 14GB,激活和临时缓冲留 2GB,再留 4GB 安全余量,KV 池可以占到 60GB。单块(block_size=16)能装 16 个 token,每个 token KV 512KB,单块就是 8MB。60GB / 8MB = 7680 块。再换算成并发:7680 块 × 16 token = 122,880 个 token 的 KV 容量。如果平均每条请求上下文 2048 token,那么大约可以支撑 60 条并发。
注意这个数字是同时在下文所有 running 请求上分摊的,不是每人一份独立预算。这也是连续批处理相对传统静态批处理的核心优势:总池子统一分配,谁缺块给谁。前面说的抢占机制也在这里起作用:当池子快满时,调度器把低优先级请求的块换到 CPU 内存,腾出 GPU 块给新请求。换出和换入的耗时不小,频繁触发说明池子容量给得太少了,应该考虑增大 KV 池占比或限制最大并发。
5.3 怎样实测验证优化效果
优化做得对不对,不能靠感觉,我一般固定三个指标:峰值显存、每秒生成 token 数(吞吐)、首 token 时延(TTFT)。用 PyTorch 自带的统计就能测峰值显存:
torch.cuda.reset_peak_memory_stats() # 跑一段稳定负载 peak = torch.cuda.max_memory_allocated() / 1024**3 print(f"peak: {peak:.2f} GiB")如果要用 nvml 看整卡占用,也可以:
import pynvml pynvml.nvmlInit() handle = pynvml.nvmlDeviceGetHandleByIndex(0) info = pynvml.nvmlDeviceGetMemoryInfo(handle) print(f"used={info.used/1024**3:.2f} GiB free={info.free/1024**3:.2f} GiB")对比实验的基准很容易设计:同一模型、同一批请求、同一份显存,分别跑"无 paged KV(连续预分配)""有 paged KV 但无前缀缓存""paged KV + 前缀缓存"三档。我实测的 7B 结果是:无 paged 时 24G 卡只能扛 4 路并发,paged KV 后能扛 12 路,加上前缀缓存(命中 80%)能干到 30 路以上。吞吐从 800 token/s 涨到 3800 token/s。测 TTFT 的时候记得统计分位数,不要只看均值——前缀缓存对长尾时延的改善往往比均值更明显。
6. 踩坑记录:我在这套机制上翻过的车
6.1 问题一:显存明明还有很多,却提示 KV 池耗尽
症状:推理服务跑着跑着报 "KV cache block pool exhausted",但用 nvml 看显存还有 40% 空闲。
原因:KV 池容量是启动时就定死的,空闲的显存没有纳入管理。这和显存总量是两码事。池子按固定大小创建,之后无论显存多空,池子外的内存都用不了。
排查思路:先算一下池里到底有多少块、每个块多大、running 请求占了总块的多少比例。如果 90% 的块都被长期前缀缓存占着,问题可能是缓存淘汰策略太保守。把 LRU 的阈值调低,或者对命中率低的块做主动清理。另一个隐蔽原因:某些请求上下文很长,它们的块表引用计数一直不释放。检查有没有请求完成但块没归还的路径——多半是异常分支写得不完整。我在教学实现里加的兜底:请求结束统一走 free_all,不做零散释放。
6.2 问题二:前缀缓存命中率怎么调都上不去
症状:配了 PrefixCache,日志里命中率一直在 10% 以下。
原因排查按优先级排序:
- prompt 模板不稳定。最常见的坑是代码里把时间戳、随机数、session id 拼进了前缀。哪怕只差一个字符,哈希就完全不匹配。
- tokenization 不一致。同一个字符串,不同 tokenizer 版本或者加了不同的 special token 设置,切出来的 token id 不一样,前缀自然不匹配。
- 块边界问题。缓存是按固定 block_size 对齐的,如果两条请求前缀长度不同,块划分的对齐方式就不同,即使前 N 个 token 完全一样,第 0 块的边界可能错位。
第 3 点值得多说一句。A 请求前缀 20 个 token,B 请求前缀 30 个 token,两份前缀有 20 个 token 相同,但因为块边界对齐方式不同,缓存无法直接复用。解决方法是在应用层对 prompt 做规范化:固定系统提示、固定 few-shot 顺序、固定对话轮次的拼接规则,保证所有请求的公共前缀以相同的块边界开头。vLLM 的 RadixTree 对这类边界问题更宽容,因为它按 token 粒度组织前缀树;nano-vllm 的简单字典实现则要求严格块对齐。
6.3 问题三:开了前缀缓存以后,输出质量偶尔异常
症状:某些请求复用了缓存后,生成内容跟不开缓存时不一致。
原因一般不是前缀缓存本身,而是缓存命中的时候哈希碰撞或者共享块被意外改写。模拟一个场景:一个物理块被多个请求共享,如果某个请求在 decode 阶段对 KV 块做了 in-place 更新(比如写入了不该写的位置),其他请求复用的就是脏数据。
排查方法:用同一 prompt,分别开、关前缀缓存跑一遍,逐 token 比对输出。正常情况应该完全一致。如果有差异,先把 token id 列表和模型版本一起参与哈希;再检查 KV 写入逻辑是否严格按物理块偏移写入,有没有越界覆盖相邻块。还有一个容易被忽视的:KV 缓存块的精度。如果为了省显存把 cache dtype 降到 FP8,而模型本身是 FP16,长上下文的累积误差可能让输出漂移。推荐 KV cache 保持与模型权重相同精度,除非你能明确接受质量损失。
6.4 问题四:开前缀缓存后吞吐不升反降
症状:同一批请求,前缀缓存命中率挺高,但吞吐反而下降 10%。
原因:前缀缓存省的是 prefill 计算,但 attention 阶段还是要读前缀块的 KV。如果命中率高,decode 阶段访问的块数量大,块遍历的开销抵消了 prefill 的节省。
排查方法:看首 token 时延有没有下降。如果 TTFT 降了但整体吞吐降了,说明瓶颈在 decode 的 attention 遍历开销。解决方向有两个:一是把 block_size 调大,减少块遍历次数;二是确认 KV 块的读取是不是有多次寻址拷贝——教学实现里如果每次 attention 都从 KV 池按物理块索引取值,块分散得厉害时会有额外开销。生产环境用 CUDA kernel 内置 Block Table 遍历,能把这个开销压到很低。
7. 一些个人实践体会
把这套机制搞通之后,我最大的感受不是算法有多精巧,而是工程问题往往不是靠堆显存解决的。PagedAttention 本质上就是操作系统内存管理思想的一次移植,前缀缓存则是典型的用空间换时间的缓存思想。两件事单独看都不神秘,但组合起来的效果是数量级的。
如果你也想自己复现一遍,我给三个具体建议。第一,不要一上来就啃 vLLM 的 CUDA 内核,先跑通 nano-vllm 的纯 Python 版,把 Block Table、自由块列表、引用计数画成图,每一步分配和释放对着显存占用曲线看,比读十篇论文都管用。第二,调试时把 KV 池的占用率打印到日志里,每个请求记录"命中多少块、新分配多少块、释放多少块",很快就能发现异常路径。第三,做实验一定要先固定一个基准版本,别在还没跑通无缓存版本的时候就叠加一堆优化,否则出问题根本分不清是哪一环引入的。
最后分享一个小技巧:我给 nano-vllm 加过一个 debug 模式,每步打印每个 running 请求的 Block Table 和对应物理块的引用计数。这个打印帮我在 20 分钟内定位过一个"块泄漏"问题——某个异常结束的请求没有归还块。生产框架里这种问题会被内存池的复杂度掩盖,但在最小实现里,一张纸条就能看穿。这种机制层面的直觉,才是学大模型推理显存优化最有价值的部分。