显存不足时的重算与换出:--swap-space 与抢占路径选择
在一组 8 卡 **** ****(单卡 64GB HBM,卡间互联 392 GB/s)上,用 **** 的基础软件栈配合 vLLM 0.4.x 的 v0 引擎起 OpenAI 兼容服务,入口是 python -m vllm.entrypoints.openai.api_server --model <model-path>。压测并发从 16 提到 64 后,vllm:gpu_cache_usage_perc 长期贴近 1,调度日志里出现抢占,吞吐越过某点后不再上升。默认配置下抢占全部落在重算上:被抢占的序列退回等待队列,从头重算已处理过的 token。把 --swap-space 从默认的 4 调到 16,vllm:cpu_cache_usage_perc 仍然是 0,重算量没有下降。原因是这一版引擎的抢占路径由请求形态决定,不由 --swap-space 决定:只有 best_of > 1 或 beam search 的多序列组才走换出,普通单序列请求一律走重算。
KV 块不足时发生什么
v0 引擎把 KV cache 切成固定大小的块,由 block manager 分配。调度一步开始前,先为将要并入本批的序列申请块:新序列的 prefill 需要与 prompt 长度相称的块数,已在运行的序列每步追加一个解码位置的块。申请不到足够的块,调度器就从运行中的序列里挑一个抢占,释放它的块,腾给本步要排入的序列。
被抢占的序列不结束,它只是让出显存,稍后重新被调度。区别在于让出显存之后,它已经算过的 KV 怎么处理:丢弃重算,还是搬到主机内存里存起来。这两条路分别对应内部枚举 PreemptionMode.RECOMPUTE 与 PreemptionMode.SWAP。
路径由请求形态决定,不由参数决定
抢占走哪条路由调度器内部判定,命令行没有开关。判据是「该序列组可能同时运行的最大序列数」:
# vllm/core/scheduler.py,_preempt 的选路逻辑(原文)
if preemption_mode is None:
if seq_group.get_max_num_running_seqs() == 1:
preemption_mode = PreemptionMode.RECOMPUTE
else:
preemption_mode = PreemptionMode.SWAP
get_max_num_running_seqs() 的取值为:beam search 时返回 best_of;否则若 best_of 大于当前序列数(prompt 阶段尚未展开),返回 best_of;否则返回尚未结束的序列数。合起来只有一条分界:best_of > 1 或 beam search 的请求会展开成多个序列,被判为多序列组,走换出;best_of = 1、n = 1 的普通请求只有一个序列,走重算。
源码注释给出的理由有两条:重算的开销低于换出,所以默认选重算;而重算在当时不支持多序列组,多序列组只能用换出。
| 路径 | 触发条件 | 动作 | 代价落在 |
|---|---|---|---|
PreemptionMode.RECOMPUTE |
get_max_num_running_seqs() == 1 |
释放 KV 块,序列退回 WAITING,下次从头重算 |
算力(重复计算已处理 token) |
PreemptionMode.SWAP |
get_max_num_running_seqs() > 1(best_of > 1 / beam search) |
KV 块拷到主机内存,序列置 SWAPPED,恢复时拷回 |
主机内存与主机↔设备带宽 |
两条路各自的动作
重算路径 _preempt_by_recompute 做三件事:把序列状态置为 SequenceStatus.WAITING,调用 free_seq 释放它的 KV 块,再调用 reset_state_for_recompute()。后者把 _num_computed_tokens 归零、把阶段退回 SequenceStage.PREFILL。该序列重新被调度时,整段 prompt 按未计算状态重跑一遍 prefill。
换出路径 _preempt_by_swap 转调 _swap_out。换出前先做容量检查 block_manager.can_swap_out(seq_group),实现是「该组占用的块数不超过 CPU 空闲块数」。通过后用 block_manager.swap_out() 生成 GPU 块到 CPU 块的映射,计入 blocks_to_swap_out(键为 GPU 块号,值为 CPU 块号),并把序列状态置为 SequenceStatus.SWAPPED。恢复时 _swap_in 反向生成 blocks_to_swap_in,状态回到 RUNNING。块的实际搬运由 CacheEngine.swap_out / swap_in 调用 attn_backend.swap_blocks,底层是 vllm_cache_ops.swap_blocks。
代价的方向正好相反:重算用算力换掉一次主机↔设备往返;换出用一次主机↔设备往返换掉重算。一条序列被抢占时,重算要重跑已算 token 的 prefill,换出要把这部分 KV 从设备搬到主机、恢复时再搬回,字节数等于该序列已占用的 KV 字节数。
--swap-space 只约束换出路径
--swap-space 是全局参数,默认值 4,单位是每卡 GiB。它经 CacheConfig 折算:swap_space_bytes = swap_space * _GB(_GB = 1 << 30)。CPU 块数不是直接配的,而是按换出空间与单块字节数折算:
# vllm/worker/worker.py,初始化缓存时的块数计算(原文)
cache_block_size = self.get_cache_block_size_bytes()
num_gpu_blocks = int(
(total_gpu_memory * self.cache_config.gpu_memory_utilization -
peak_memory) // cache_block_size)
num_cpu_blocks = int(self.cache_config.swap_space_bytes //
cache_block_size)
因此 --swap-space 决定的是「最多能换出多少块」,它不改变哪条路径被选中。单序列负载下抢占全走重算,num_cpu_blocks 再大也不会有块被换出,vllm:cpu_cache_usage_perc 恒为 0。换出空间不足时,_swap_out 抛 RuntimeError,原文是 Aborted due to the lack of CPU swap space. Please increase the swap space to avoid this error.,此时中止的不是单个序列组而是整个引擎。
参数与取值
| 参数 | 默认值 | 作用 |
|---|---|---|
--swap-space |
4 | 每卡可用于换出的 CPU 空间(GiB),决定 num_cpu_blocks |
--gpu-memory-utilization |
0.90 | 设备内存中划给模型执行器的比例,决定 num_gpu_blocks |
--max-num-seqs |
依卡而定 | 同时驻留序列上限,压低后 KV 压力下降 |
--max-model-len |
依模型而定 | 单序列 token 上限,压低后单序列占用下降 |
num_gpu_blocks 由设备总显存乘 gpu_memory_utilization 再减去峰值激活后、除以单块字节数得到;num_cpu_blocks 由换出空间除以同一单块字节数得到。两个池子的块大小相同,只是所在设备不同。
触发条件
- 并发高于 KV 池可容纳的序列数,或单序列长度大,池子被提前占满。
- 请求带
best_of > 1、n > 1或use_beam_search,展开成多序列组。 --swap-space偏小,多序列组换出时撞上can_swap_out检查。
前两条决定是否发生抢占与走哪条路,第三条只决定换出能否成功。
现象
vllm:gpu_cache_usage_perc长期贴近 1,vllm:cpu_cache_usage_perc恒为 0:抢占全走重算,池子满而主机池空。vllm:cpu_cache_usage_perc非零、vllm:num_requests_swapped上升:有序列被换出到主机,恢复时出现换入延迟尖峰。- 吞吐曲线越过某并发点后转平再下降,同时 TTFT 与 TPOT 的尾延迟变差:重算的 token 挤占了本可产生新输出的算力。
- 日志出现
Aborted due to the lack of CPU swap space:换出空间不足,引擎中止。
重算与换出的现象不同向:重算把成本压在算力上,表现为有效吞吐下降;换出把成本压在主机↔设备带宽上,表现为周期性延迟尖峰与主机内存占用上升。
定位步骤
- 同时读两个池子:
vllm:gpu_cache_usage_perc与vllm:cpu_cache_usage_perc。前者贴 1 后者为 0,说明抢占在重算路径上;两者都非零说明有换出发生。 - 读
vllm:num_requests_swapped,确认是否有序列实际被换出,而不是只看换出空间配了多大。 - 核对请求形态:从调用侧确认是否使用了
best_of > 1、n > 1或 beam search。若全部是单序列请求,则换出路径不会被触发,调整--swap-space无收益。 - 查是否有换出空间不足的报错:搜关键字
lack of CPU swap space。命中则按--swap-space扩容。 - 做单变量对照:固定请求集与并发,只改
--max-num-seqs或--gpu-memory-utilization,看抢占是否减少、吞吐拐点是否右移。
# 字段名示意:与抢占路径有关的指标(v0 引擎口径)
vllm:gpu_cache_usage_perc # 设备 KV 池占用比例,贴 1 表示池满
vllm:cpu_cache_usage_perc # 主机换出池占用比例,恒 0 表示未走换出
vllm:num_requests_swapped # 当前被换出的请求数
处置
| 动作 | 作用 | 代价 / 前提 |
|---|---|---|
增大 --swap-space |
多序列组换出时不再中止;换出空间更充裕 | 只在多序列组负载下有效;占主机内存 × 卡数,换入换出引入延迟 |
减小 --max-num-seqs |
降低并发,减少抢占次数 | 峰值并发下降 |
增大 --gpu-memory-utilization |
加大设备 KV 池,容纳更多序列 | 挤占激活与工作区,prefill 峰值上升时可能失败 |
减小 --max-model-len |
压小单序列占用,降低池子压力 | 上下文窗口变小 |
减小 best_of / 关闭 beam search |
请求退回单序列组,抢占走重算 | 采样候选数下降,影响输出质量与用途 |
处置顺序:先确认请求是否含多序列组,没有就不要动 --swap-space;再压 --max-num-seqs 或 --max-model-len,把并发或单序列占用降到池子可容纳的范围;--gpu-memory-utilization 与 --swap-space 放在最后。重算是这一版的默认路径,单序列负载下它不可切换,只能靠减少抢占来缓解。
记录字段
# 一条抢占路径记录(字段名示意)
preemption:
path: # recompute / swap,由 get_max_num_running_seqs() 判定
mode_enum: # PreemptionMode.RECOMPUTE / PreemptionMode.SWAP
trigger: # 竞争 KV 块 / 换出空间不足
request_shape:
best_of: # > 1 时进入多序列组
n:
use_beam_search:
cache:
swap_space_gib: # --swap-space,默认 4
num_gpu_blocks: # 设备池块数
num_cpu_blocks: # 主机池块数 = swap_space_bytes // cache_block_size
gpu_cache_usage_perc: # vllm:gpu_cache_usage_perc
cpu_cache_usage_perc: # vllm:cpu_cache_usage_perc
num_requests_swapped: # vllm:num_requests_swapped
workload:
model_name:
concurrency:
input_len_distribution:
output_len_distribution:
perf:
throughput_curve: # 并发-吞吐曲线(含拐点)
ttft_p95:
tpot_p95:
status: # 受多序列组换出约束 / 受单序列重算约束 / 不成立
小结
v0 引擎在显存不足时的抢占路径由请求形态决定:best_of > 1 或 beam search 的多序列组走换出,普通单序列请求走重算,命令行没有开关切换。--swap-space 默认 4 GiB,只决定换出池的块数(swap_space_bytes // cache_block_size),不改变选路;单序列负载下它不起作用。重算的代价是重复计算已处理 token 的算力,换出的代价是 KV 的一次主机↔设备往返加主机内存。定位时先同时读 vllm:gpu_cache_usage_perc 与 vllm:cpu_cache_usage_perc,再核对请求是否含多序列组,最后按减少抢占的顺序处置。