显存不足时的重算与换出:--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 由换出空间除以同一单块字节数得到。两个池子的块大小相同,只是所在设备不同。

触发条件

前两条决定是否发生抢占与走哪条路,第三条只决定换出能否成功。

现象

重算与换出的现象不同向:重算把成本压在算力上,表现为有效吞吐下降;换出把成本压在主机↔设备带宽上,表现为周期性延迟尖峰与主机内存占用上升。

定位步骤

  1. 同时读两个池子:vllm:gpu_cache_usage_perc 与 vllm:cpu_cache_usage_perc。前者贴 1 后者为 0,说明抢占在重算路径上;两者都非零说明有换出发生。
  2. 读 vllm:num_requests_swapped,确认是否有序列实际被换出,而不是只看换出空间配了多大。
  3. 核对请求形态:从调用侧确认是否使用了 best_of > 1、n > 1 或 beam search。若全部是单序列请求,则换出路径不会被触发,调整 --swap-space 无收益。
  4. 查是否有换出空间不足的报错:搜关键字 lack of CPU swap space。命中则按 --swap-space 扩容。
  5. 做单变量对照:固定请求集与并发,只改 --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,再核对请求是否含多序列组,最后按减少抢占的顺序处置。