资讯详情

vLLM重计算机制全解析:显存不足时KV Cache如何回退与重建

📅 2026/10/16 5:03:23 | 华诺云谱 👁 阅读
vLLM重计算机制全解析:显存不足时KV Cache如何回退与重建
跑大模型推理服务最怕的其实不是显存不够而是显存不够的时候系统把你已经算到一半的请求悄悄退回原点重新再算一遍。如果你在 vLLM 的日志里见过类似“preempted”或“recompute”的字样又不太确定它到底做了什么那么这篇文章就是为你准备的。vLLM 的重计算流程说穿了就是一件事当一个正在推理的序列因为显存不足被“请出”显卡时系统会选择把它的 KV cache 全部清空等它下次被调度时再从头重新走一遍 prefill 阶段。这个过程在推理框架里往往被归在“抢占preemption”这个大类下而 recompute 是其中一种处理策略。它看似只是一个简单的回退机制但背后牵扯到调度器、显存管理器、KV cache 的无障碍回收、前缀缓存命中甚至 chunked prefill 的交互逻辑。这篇文章不会只讲概念我会把从触发条件到执行细节的整条链路拆开再结合我实际部署和调参时踩过的坑给你一份可以直接拿去用的排查清单。1. 重计算到底在算什么1.1 先把 KV Cache 和物理块的概念对齐在聊重计算之前必须先把两个基础概念对齐KV cache 和物理块。LLM 的推理过程分成两个阶段。第一个阶段叫 prefill把用户输入的 Prompt 一次性送进模型计算出第一个输出 token同时把每一层计算出来的 Key 和 Value 矩阵保存下来。这套中间结果就叫做 KV cache它的作用是在后续生成每个新 token 时不需要重新把前面所有 token 的注意力再算一遍而是直接拿 cache 做增量计算。第二个阶段叫 decode也就是逐 token 自回归生成每生成一个 token就把新的 KV 追加到 cache 里。KV cache 的显存占用量非常大通常和一个序列的长度成正比也和模型层数、注意力头数成正比。对于长上下文的请求KV cache 往往比模型权重本身还占显存。vLLM 的核心创新之一就是把这个庞大的 KV cache 切成固定大小的块然后再把逻辑块映射到物理显存块上。这个东西就是它的 PagedAttention 策略你可以把它理解成操作系统里的分页机制每个序列的逻辑页是连续的但物理页可以散布在显存的各个角落由一张页表来记录映射关系。为什么要强调这一点因为重计算的本质就是破坏这种页表映射然后重新建立。不了解 KV cache 和物理块的关系你就很难理解为什么重计算会这么“贵”。1.2 重计算的本质是“回退”重计算这三个字听上去像是在说模型权重重新算一遍实际上完全不是。权重一直在显存里只有 KV cache 会被清掉。当一个序列被标记为 recompute 抢占时BlockManager 会把这个序列已经分配的所有物理块全部回收页表项删除KV cache 指向的那块显存被释放重新变成空闲块。这个序列在显存里等于“什么都不剩了”。等它下一次被调度到系统只能把它当成一个全新的请求从第一个 token 开始重新执行 prefill把 KV cache 一点一点再算出来。这个行为你可以类比成在图书馆抄书你抄到第 100 页管理员说座位要让给别人于是把你抄好的稿纸全部收走。等下次轮到你不是从第 101 页接着抄而是从第 1 页重新开始。这是理解 vLLM 重计算流程最关键的切入点——它不是“接着算”而是“重头算”。2. 什么情况下会触发重计算2.1 先看懂 vLLM 的调度困局vLLM 使用连续批处理机制也就是在一个 decode 步骤结束后调度器会立刻从等待队列里捞新的请求进来填补空出来的位置。这种调度方式能显著提高 GPU 利用率但它有一个前提显存里必须有足够的空闲物理块来分配新请求的 KV cache。问题在于物理块的总量是固定的。你配置gpu-memory-utilization之后vLLM 会预留一部分显存给模型权重剩下的基本都拿来建立物理块池。一旦正在运行的序列占满了这些物理块新来的请求就找不到可以分配的块了。这时候调度器面临一个困局要么把正在运行的序列“请出去”腾出物理块给新请求要么让新请求永远等待。显然后者会造成头部阻塞所以抢占机制就派上了用场。理论上触发抢占的根本原因只有一个物理块不足。但物理块不足可能是由很多因素引起的——并发序列太多、单个序列的上下文太长、显存利用率设置过保守、甚至显存碎片化都会导致块不够分。2.2 swap 与 recompute两种抢占手段值得注意vLLM 的抢占并不只有重计算一种处理方式。在它内部抢占时通常有两种策略可选swap 和 recompute。swap 的意思很好理解把一个序列的 KV cache 从 GPU 显存拷贝到 CPU 内存腾出 GPU 物理块。下次该序列回来时再把 KV cache 从 CPU 内存拷贝回 GPU。路径上数据没有丢只是换了存储位置。recompute 的意思是不拷贝直接丢弃 KV cache。下次需要时再重新计算。路径上数据彻底消失花的是算力省的是 CPU 内存和 PCIe 带宽。调度器在决定抢占某个序列时会优先尝试 swap。只有 swap 空间不足、或者你显式关掉了 swap 能力时才会落到 recompute 这条路上。这个决策顺序很重要很多人在排查问题时只盯着重计算却忽略了 swap 空间可能被调小了导致本该走 swap 的序列全都走了 recompute。下面这张表可以很直观地看到两者的差异对比项swap 策略recompute 策略KV cache 去处CPU 内存彻底丢弃释放 GPU 显存是但需要占用 CPU 内存是且不依赖 CPU 内存恢复成本由 CPU 往 GPU 拷贝准备时间短重新执行 prefill计算量大主要瓶颈PCIe 传输带宽GPU 算力适用场景CPU 内存充足、被抢占序列较长GPU 算力富余、共享前缀较多2.3 被抢占序列的状态机流转在 vLLM 的调度器里每个序列组会有一个状态。理解重计算流程最好把这条状态链路背下来请求到达进入 waiting 队列状态为 WAITING。调度器分配物理块成功序列组进入 running 队列状态变为 RUNNING。某个时刻物理块不足调度器选中该序列组作为抢占对象。如果走 recompute则释放它的全部物理块KV cache 被清空序列组状态被标记为 PREEMPTED。调度器把它重新插回 waiting 队列等待下一轮调度。再次被调度时由于 KV cache 已经被清空它必须从 prefill 阶段重新做起。这里有一个细节序列被抢占后并不是简简单单原地等待而是会被重新入队。重新入队时它的优先级一般仍然按照到达时间排队所以大多数情况下会回到等待队列里靠前的位置。但这个过程不是即时的它要等调度器下一轮循环才会被看到。3. 重计算在执行层是如何落地的3.1 KV cache 的清零与物理块回收重计算的第一个执行动作是释放物理块。这一步由 BlockManager 完成。每个序列组在分配 KV cache 时都会维护一组逻辑块到物理块的映射表。抢占发生时BlockManager 会遍历这个序列组的所有逻辑块把对应的物理块逐个标记为空闲同时清掉页表项。这个回收动作比很多人想象的要复杂因为 vLLM 的物理块并不是完全归一个序列独占的。某些物理块可能同时被多个序列引用典型的就是 prefix caching 命中的场景。如果一个物理块被其他序列共享那么抢占这个序列时该物理块不能被直接释放而是要检查引用计数。这也就是为什么说重计算并不总是“我把我那块算完的数据丢了就完事”它还会牵动块管理器的引用计数和共享关系。KV cache 清零之后序列在显存里等于一个新请求。它的token ids仍然保留在内存里但所有和 KV cache 相关的状态都会被重置。这一步做完重计算的“前半场”就结束了。3.2 重新 prefill 的执行路径序列重新进入 waiting 队列后调度器会在合适的时机重新分配物理块然后执行 prefill。这一段的执行路径可以用一段风格化的伪代码来描述def schedule(): for seq_group in waiting_queue: if block_manager.can_allocate(seq_group): block_manager.allocate(seq_group) running_queue.add(seq_group) else: victim running_queue.pick_preemption_candidate() if swap_space_available(victim): swap_out(victim) else: # 关键路径丢弃 KV cache重新排队 release_all_blocks(victim) victim.state PREEMPTED waiting_queue.add(victim) for seq_group in running_queue: runner.execute_one_step(seq_group)这段伪代码不是 vLLM 的真实源码但它把重计算在主循环里的位置表达得很清楚。注意看那个分支swap_space_available为假时才会走到release_all_blocks这一步。也就是说重计算并不是每次抢占的默认选择而是在 swap 不可行时的保底方案。真正执行 prefill 时模型会把输入的全部 token 一次性或分块地送进计算图重新计算出每一层的 Key 和 Value填充到新分配的物理块里。prefill 的耗时和输入长度直接相关所以一个被抢占的长上下文序列重新 prefill 的开销会非常可观。3.3 与 chunked prefill 的交互这里有一个非常容易踩坑的点启用 chunked prefill 之后很多人以为被抢占的序列可以从上次切分的位置继续算实际上不是这样。chunked prefill 的作用是把一个很长的 prefill 请求切分成多个小块每一块和 decode 请求交错执行避免长 prefill 长时间霸占 GPU。它确实能降低单次调度对显存块的需求也能降低触发抢占的概率。但如果一个序列在 prefill 中途被抢占它的整条 KV cache 依然会被全部释放等它重新被调度时还是会从头开始算第一个 chunk。换句话说chunked prefill 只是在执行层把 prefill 切碎了并没有给序列建立“断点续传”的能力。一旦被抢占之前的 chunk 计算成果全部作废。这个问题在实际生产环境里容易引发困惑明明我已经开了 chunked prefill为什么日志里还是有重计算而且重计算的时间一点没变短原因就在这。3.4 与 prefix caching 的关系如果说有什么机制能让重计算成本大幅缩水那一定是 prefix caching。prefix caching 会把计算过的 KV cache 以逻辑块为单位缓存在显存里。新请求到达时如果它的一部分前缀和已有缓存的逻辑块内容完全一致就可以直接复用这些 KV cache不需要重新计算。重计算和 prefix caching 的关系非常微妙。一个序列被抢占后它的物理块被释放了但其中的 KV cache 数据并不会立刻从显存里消失。如果此时开启了 prefix caching这些物理块会进入缓存池被记录下来。等被抢占的序列重新回来系统会比对它的输入 token 序列和缓存池中的记录找到一个最长的公共前缀只从公共前缀之后开始重新计算。这带来的收益是实打实的。比如你有一批请求都共享同一段很长的 system prompt其中一个请求被抢占。重算的时候system prompt 对应的那一大段 KV cache 可以直接从缓存池里恢复模型只需要重新计算这个请求独有的尾部内容。所以在长前缀场景下重计算的成本可能比想象中低一个数量级。这也是我每次调优都建议把 prefix caching 打开的原因。4. 重计算的成本账该怎么算4.1 重计算的时间花在哪里重计算的主要时间开销在于重新执行 prefill。prefill 的计算量近似和输入 token 数成正比一个粗略的公式是重计算耗时 ≈ 序列输入 token 数 × 单 token prefill 的平均耗时单 token prefill 的平均耗时取决于模型规模、GPU 算力、batch 大小。假设你在某张主流数据中心显卡上跑一个中等规模的模型单个 token 的 prefill 耗时可能在几十微秒到几百微秒之间。如果一个被抢占的序列有 1500 个输入 token那么重算一次 prefill 大概就是 1500 乘以系数耗时很容易达到上百毫秒。还有一个容易忽略的点重计算不只是多花一次 prefill 的时间它还会占用 GPU 算力。在重计算期间其他 decode 请求的吞吐会收到影响因为同一个 GPU 的算力是共享的。也就是说重计算的真实成本不应该只算“被抢占序列多花的时间”还要算“它拖慢了多少其他请求”。4.2 和 swap 的成本对比swap 策略的成本主要在拷贝GPU 到 CPU 一次后续 CPU 回 GPU 再一次。拷贝速度取决于 PCIe 带宽和传输的数据量。一段 1000 个 token 的 KV cache 可能对应数百 MB 的数据在 PCIe 4.0 x16 的带宽下一次拷贝大概需要几十毫秒。从时间上看swap 往往比重计算更快因为拷贝带宽再慢通常也比从头算一遍 prefill 要快。但 swap 并不是免费的午餐它会占用大量 CPU 内存。如果你的 CPU 内存被多个实例占满swap 空间不够那就只能退回 recompute。更麻烦的是swap 会把 CPU 内存带宽也拖进来。当多个序列同时被 swap 出去PCIe 链路会成为瓶颈调度器的整体吞吐会明显下滑。我在实际部署里观察到一个现象swap 用得太多时虽然重计算次数少了但整机延迟反而变得不稳定。CPU 内存的访问带宽和 GPU 显存带宽根本不在一个量级swap 数据量一大瓶颈就转移到了主机内存那边。所以严格来说swap 和 recompute 之间没有绝对谁优谁劣。需要根据你的硬件特性和负载特征做权衡。4.3 警惕“抢占风暴”重计算最怕的不是单次开销大而是连续触发。假设当前显存的物理块已经被占满你同时调度了多个新请求。调度器为了给这些新请求腾位置可能会把多个旧序列连续抢占。被抢占的序列重新入队后下一轮调度又会继续争抢物理块。如果请求长度都偏长系统就会陷入一种恶性循环抢占、重算、再抢占、再重算GPU 一直在做无效的 prefill 计算真正的 decode 产出反而很少。这种“抢占风暴”在高并发场景里并不少见。我自己在压测时就遇到过把并发数调高之后吞吐不升反降日志里全是重计算记录。排查到最后发现问题不在某个参数而是并发数量已经超过了这个显存容量能支撑的物理上限。这种情况下先别急着调参数先想想这个 GPU 到底能不能同时装下这么多长序列。5. 实战调参怎么让重计算少发生5.1 核心参数速查我把和重计算频率直接相关的几个参数整理成了一张速查表。这些参数在不同 vLLM 版本里的命名可能略有差别但核心逻辑是不变的。参数作用调参建议gpu-memory-utilization控制 vLLM 使用多少比例的显存用于 KV cache 池默认通常在 0.9 左右显存充足时可以适度调高但别顶满max-num-seqs限制同时调度的最大序列数量并发超限会直接触发抢占调低可以稳定占用但牺牲吞吐swap-space允许用于 swap 的 CPU 内存大小CPU 内存充足时调大让抢占优先走 swapblock-sizeKV cache 逻辑块的大小块越小碎片越少但索引开销越大默认值一般够用enable-prefix-caching开启基于逻辑块的前缀缓存长共享前缀场景建议开启对重计算成本影响很大enable-chunked-prefill把长 prefill 切块执行能减少长请求导致的显存峰值间接降低抢占概率5.2 我的调参顺序这几个参数虽然都可以调但如果一起乱调很难定位问题。我一般按下面的顺序来第一步先看gpu-memory-utilization。如果 GPU 显存还有富余把它从 0.9 调到 0.92 或者 0.95让 KV cache 池变大。注意这一步的前提是模型权重的预留空间已经足够否则调太高会直接 OOM。第二步再看max-num-seqs。如果业务允许限制并发降低这个值是最直接有效减少抢占的手段。它牺牲的只是潜在的吞吐上限但换来的是更平稳的延迟。第三步检查swap-space。如果 CPU 内存充足给 swap 多分一些空间让被抢占的序列优先走 swap 而不是 recompute。重计算次数会明显下降。第四步开启prefix-caching。这一步对重计算成本的影响往往被低估。你的请求只要共享较长前缀重计算就能变成一次“增量计算”。最后如果请求长度波动很大再考虑enable-chunked-prefill。它解决的不是重计算本身而是减少长 prefill 造成的显存峰压。5.3 从日志和指标定位重计算频率调参前先要能识别重计算到底发生得多不多。vLLM 的引擎日志里当序列被抢占时会出现类似 “preempted” 或 “recompute” 的关键字。如果你用的版本支持指标导出也可以关注序列被抢占次数的计数器。我在实际操作中习惯这样判断观察一段固定时间的日志统计重计算次数和总调度轮次的比例。如果重计算发生的次数非常多不要急着只看参数先回答几个问题是不是并发数超过了显存容量是不是很多请求都是一次性全部到达形成了调度峰值是不是某些请求的上下文特别长把物理块吃穿了把原因定位清楚再针对性地调参数否则就是在盲调。6. 常见问题与排查心得6.1 问题速查表下面的表格整理了我遇到过的和重计算相关的几个典型问题以及对应的排查思路现象可能原因排查方向重计算频繁吞吐骤降并发数超过显存承载上限调低 max-num-seqs观察吞吐恢复情况日志里全是 preempted但 swap 空间还有剩余被抢占序列优先走了 recompute检查 swap-space 是否被调小或禁用了 swap开了 prefix caching重计算没变快公共前缀太短或前缀存在微小差异检查输入格式是否完全一致比如空格字符差异显存利用率调高后 OOMgpu-memory-utilization 过大调回安全值优先通过控制并发来解决抢占chunked prefill 开启后重计算仍在没有断点续传能力接受“整体重算”的事实搭配 prefix caching 降低损失6.2 两个容易踩的坑第一个坑是盲目把gpu-memory-utilization调高。你可能会想KV cache 池越大越不容易触发抢占重计算自然就少了。事实是KV cache 池变大确实有帮助但如果你把它调到接近显存上限模型权重加载后剩余空间太紧运行时的显存波动很容易直接导致 OOM。OOM 的代价比重计算高得多。我见过不少案例把利用率从 0.9 调到 0.95 后系统稳定性反而变差。调这个参数一定要给显存波动留一点余量。第二个坑是 prefix caching 命中率不高。很多人开了 prefix caching 后发现重计算成本并没有明显下降原因往往是公共前缀没有对齐。LLM 的输入会经过 tokenizer 处理你看起来差不多的两段文本可能因为末尾多了个空格、换行符或者个别字符不同token 序列在很靠前的位置就分叉了。前缀缓存是按 token 序列完全一致来判断的一旦前缀在早期分叉后面的命中就无从谈起。排查方法很简单把发送给模型的原始输入打印出来逐字符对比看看是不是格式不一致。6.3 我的一个实战习惯最后分享一个调优时我常用的习惯部署前先做一轮压力测试而不是上线后让用户在真实流量里踩坑。用一个固定的并发脚本在目标 GPU 上跑 15 分钟把这期间的日志存下来重点统计重计算次数、平均延迟和吞吐。如果重计算次数占比过高就先调整max-num-seqs和swap-space再跑一轮直到数据指标稳定在一个合理的区间。建立这样一个基准之后后续每次改模型、改请求长度分布、换 GPU都可以拿它作为参照快速判断新配置是否正常。vLLM 的重计算流程本质上是一套取舍逻辑用算力换显存空间用重新计算换部署的灵活性。它不是一个需要消灭的 bug而是调度机制里必须具备的兜底能力。真正的问题永远只有一个你的配置是否让这个兜底机制过于频繁地登场。把这个链条上的每个环节都理解了再去调参你会从容很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑