一个显存打满的在线服务现场
假设你维护一个 7B 模型的在线推理服务,用 vLLM 提供 OpenAI 兼容接口。白天流量平稳,傍晚突然涌入一批长上下文请求:每个请求的 prompt 有几千个 token,输出也要上千 token。GPU 显存利用率很快逼近 gpu_memory_utilization 设定的上限,日志里开始出现 Sequence group ... is preempted by PreemptionMode.SWAP mode because there is not enough KV cache space。
直觉上,已经解码到一半的请求应该继续解码,毕竟它只差最后几个 token。但 vLLM 可能把其中一些请求的 KV Cache 释放掉,等显存腾出来后再从头重算。这个行为看起来像倒退:明明已经算过的内容,为什么要再算一遍?
要回答这个问题,需要先理解 vLLM 把 KV Cache 当作什么资源来管理。
KV Cache 是分页管理的物理块
自回归解码时,每生成一个 token,模型都要为每一层缓存该 token 的 key 和 value 向量。序列越长,KV Cache 越大。vLLM 的 PagedAttention 借鉴操作系统虚拟内存分页,把 KV Cache 切成固定大小的块(block),每个块存放若干 token 的 KV。论文《Efficient Memory Management for Large Language Model Serving with PagedAttention》(Kwon 等,SOSP 2023)指出,这种做法让 KV Cache 接近零浪费,并支持请求内和跨请求共享。
对调度器来说,一个请求能否继续解码,取决于它能否拿到足够的空闲物理块来存放新 token 的 KV。块表(block table)记录逻辑块到物理块的映射。显存打满意味着空闲块池为空,此时新请求无法进入,已有请求也无法再分配新块。
这里的关键约束是:解码是逐 token 推进的,每一步都可能需要新块。如果调度器不主动腾出空间,整个批次会一起卡住。
抢占:把物理块从部分请求手里收回来
vLLM 调度器在无空闲块时执行抢占。它从当前运行批次中选择一些请求,释放它们占用的物理块,让其他请求继续推进。被选中的请求进入等待队列,等资源可用时再恢复。
选择谁被抢占,涉及调度策略。工程上通常优先保留已经解码较多、接近完成的请求,因为丢弃它们的进度代价更高;也可能按到达顺序或优先级选择。具体策略随版本和配置变化,这里不展开。
被抢占请求的 KV 块有两种处理方式:
- 直接释放:块表清空,物理块归还空闲池。恢复时请求需要重新计算 prompt 和已生成 token 的 KV。
- 换出到 CPU:把块内容拷贝到主机内存,物理块归还,恢复时再拷贝回 GPU。
官方文档中的警告信息显示,vLLM 会区分 PreemptionMode.SWAP 等模式。不同模式下恢复路径不同,代价也不同。
下面的流程图展示了一个请求从正常解码到被抢占、再到恢复的完整路径。
flowchart TD
A[请求进入运行批次] --> B{需要新物理块?}
B -- 否 --> C[继续解码]
B -- 是 --> D{空闲块池有块?}
D -- 有 --> E[分配块并解码]
D -- 无 --> F[调度器选择牺牲请求]
F --> G{恢复策略}
G -- 换出 --> H[KV 块拷贝到 CPU]
G -- 重算 --> I[释放 KV 块并清空块表]
H --> J[物理块归还空闲池]
I --> J
J --> K[其他请求继续解码]
K --> L{资源再次可用?}
L -- 是 --> M[恢复被抢占请求]
M -- 换出路径 --> N[CPU 块拷回 GPU]
M -- 重算路径 --> O[重新执行 prefill]
N --> C
O --> C
图中两条恢复路径的差异,是理解抢占代价的核心。
为什么重算有时比换出更划算
换出看起来更“保进度”:KV 块还在,只是搬了个地方。但搬运本身有成本。KV Cache 的大小与序列长度、层数、隐藏维度成正比。一个长序列的 KV 块可能达到数百 MB 甚至更多。通过 PCIe 在 GPU 和 CPU 之间搬运这些数据,需要占用主机到设备带宽。
重算的成本是重新执行 prefill。prefill 是计算密集型操作,但 GPU 算力通常比 PCIe 带宽充裕。对于较短的序列,重算的耗时可能低于换出再换入的拷贝耗时。论文和官方文档都没有给出统一的阈值,因为最优选择取决于序列长度、模型规模、GPU 型号和 PCIe 代际。
可以推断:当被抢占序列的 KV 数据量相对其重算计算量较小时,重算更划算;当序列很长、重算代价高,而 PCIe 带宽又足够时,换出更划算。这个判断需要结合具体部署测量。
另一个因素是块表回收的确定性。重算路径释放块后,块表直接清空,恢复时重新分配,逻辑简单。换出路径需要维护 CPU 侧的块副本和映射关系,恢复时还要处理拷贝失败、CPU 内存不足等边界情况。实现复杂度更高。
两条恢复路径的对比
下表从延迟、带宽、吞吐和实现复杂度几个维度对比重算与换出。表中结论为定性判断,具体数值需以实际部署测量为准。
| 维度 | 重算(recomputation) | 换出到 CPU(swap) |
|---|---|---|
| 恢复延迟构成 | 重新 prefill 的计算时间 | PCIe 拷贝时间 + 可能的排队等待 |
| 对 PCIe 带宽的依赖 | 低 | 高,拷贝量与 KV 大小成正比 |
| 对 GPU 算力的依赖 | 高 | 低 |
| 长序列表现 | 重算代价随序列长度增长 | 拷贝量随序列长度增长,但可流水 |
| 短序列表现 | 通常较快 | 拷贝固定开销可能占主导 |
| 吞吐影响 | 抢占期间释放算力给其他请求,恢复时占用算力 | 抢占期间释放算力,恢复时占用带宽 |
| 实现复杂度 | 低,块表清空即可 | 高,需维护 CPU 块副本和映射 |
| 失败模式 | 重算期间再次被抢占 | CPU 内存不足、拷贝超时 |
这张表不能直接告诉你该选哪个模式,因为 vLLM 的抢占模式选择由调度器根据配置和运行时状态决定。它的价值在于帮你判断:当服务出现抢占时,应该优先调整哪个资源。
如果日志显示大量 SWAP 模式抢占,说明调度器认为换出更合适,此时 PCIe 带宽和 CPU 内存是瓶颈候选。如果观察到重算频繁,说明序列长度或并发量超出了 KV Cache 容量,需要从显存分配入手。
块表回收、水位阈值与并发上限的调参边界
官方文档给出了几条减少抢占的建议:提高 gpu_memory_utilization、减少 max_num_seqs 或 max_num_batched_tokens、增加 tensor_parallel_size 或 pipeline_parallel_size。这些参数各自影响不同的资源,调参时需要理解边界。
gpu_memory_utilization 控制 vLLM 预分配 GPU 显存的比例。提高它可以让更多显存用于 KV Cache,但会压缩模型权重之外的其他开销空间。设得太高,可能触发显存不足或与同卡其他进程冲突。这个参数不是越大越好,需要留出安全余量。
max_num_seqs 限制同时运行的请求数。减少它可以降低峰值 KV Cache 需求,但会直接限制并发吞吐。在线服务中,这个值需要根据 P99 延迟目标和显存容量反推,不能只按吞吐最大化设置。
max_num_batched_tokens 控制单批次 token 预算。官方文档提到,分块预填充场景下默认值 512 针对 ITL 优化,建议设置大于 2048 以提高吞吐。这个参数影响预填充和解码的混合比例,进而影响 KV Cache 的增长速度。
块表回收的时机也值得注意。被抢占请求释放块后,空闲池恢复,但调度器不一定立即恢复被抢占请求。它可能优先服务新到达的请求,导致被抢占请求等待更久。工程上通常需要观察等待队列长度和抢占计数,判断是否存在饥饿。
需要观察的信号与定位顺序
官方文档提到,可以通过 Prometheus 指标监控抢占请求数量,并设置 disable_log_stats=False 记录累计抢占数。生产环境中,建议至少观察以下信号:
- 累计抢占计数及其增长率,判断抢占是偶发还是持续。
- GPU KV Cache 使用率,确认是否长期贴近上限。
- 等待队列长度,判断被抢占请求是否在排队。
- TTFT 和 ITL 的 P99 分位,区分预填充压力和 Decode 压力。
- PCIe 带宽利用率,判断换出路径是否成为瓶颈。
定位顺序可以这样走:先看抢占计数是否持续增长;如果是,再看 KV Cache 使用率是否长期饱和;饱和则检查并发上限和输入长度分布;如果使用率不高但仍有抢占,检查是否存在长序列请求集中到达或块表碎片问题。
一个常见误区是只盯 GPU 利用率。GPU 利用率高不代表 KV Cache 充足,解码阶段是内存带宽密集型,算力利用率可能不高,但 KV Cache 已经满了。
何时这套机制会失效
抢占机制保证系统不崩溃,但它不是免费的。以下情况会让抢占代价超出预期:
输入长度分布剧烈变化。如果服务平时以短请求为主,突然涌入大量长请求,KV Cache 需求会在短时间内翻倍,调度器来不及平滑调整。
CPU 内存不足。换出路径需要 CPU 侧有足够空间存放 KV 块。如果主机内存紧张,换出可能失败或触发交换,延迟急剧上升。
PCIe 带宽被其他任务占用。多卡训练或数据加载与推理共享主机带宽时,换出拷贝时间会被拉长。
被抢占请求反复被抢占。如果恢复后再次遇到显存不足,请求可能多次重算,端到端延迟不可预测。
这些情况下,单纯调大 gpu_memory_utilization 可能不够,需要考虑请求准入控制、长度限制或扩容。
与替代方案的比较
除了抢占,还有几种应对显存不足的思路。
请求排队:让新请求在入口等待,不进入运行批次。这避免了抢占,但会增加 TTFT,且无法解决已运行请求增长导致的显存压力。
限制最大序列长度:直接拒绝超长请求。这保护了显存,但牺牲了长上下文场景的可用性。
分离式部署:把预填充和解码放到不同实例,各自管理 KV Cache。这能缓解单实例压力,但增加网络传输和部署复杂度。
抢占机制的优势在于不需要额外基础设施,由调度器在运行时动态平衡。代价是延迟抖动和重算开销。选择哪种方案,取决于服务对延迟确定性的要求和对长尾请求的容忍度。
仍未解决的问题
抢占策略的最优选择依赖运行时测量,而 vLLM 的调度器参数与具体版本相关。不同版本对抢占模式、块大小和调度优先级的实现可能有差异,本文不假设固定行为。
另一个开放问题是:如何在在线服务中自动调整并发上限,使抢占频率保持在可接受范围,同时不浪费显存。这需要结合请求长度分布预测和反馈控制,目前更多依赖运维经验而非通用方案。
如果你的服务已经出现抢占,先确认它是偶发还是持续,再决定是调参还是扩容。日志里的那一行警告,是调度器在告诉你:KV Cache 的账已经算不平了。