主题
Preemption 与资源管理
白板导读
理想情况下,vLLM 的 GPU 资源足够所有请求使用——Block Pool 充裕,每个新到达的请求都能立即获得 Block 并进入 RUNNING 状态。但现实是残酷的:GPU 显存有限,模型越来越大,用户越来越多。当 Block Pool 即将耗尽时,vLLM 必须做出一个艰难的决定:牺牲谁来为新请求腾出空间? 这就是 Preemption(抢占) 机制要解决的问题。本章将从"为什么需要抢占"、"如何选择受害者"、"抢占的具体实现(CPU Swap)"、"什么时候触发抢占"以及"如何监控和调优抢占行为"五个维度完整剖析这个话题。
3.1 为什么需要 Preemption?
资源耗尽的场景分析
让我们先看一个具体的场景:Block Pool 即将耗尽时会发生什么。
场景: RTX 4090 (24GB), 运行 Qwen2.5-7B
模型权重: ~14 GB (FP16)
可用 KV Cache: ~8 GB → 约 65,536 个 Block (每块 128KB)
当前状态:
┌─ Block Pool (总计 65536) ─────────────────────────┐
│ │
│ [████████████████][████████][████████]...[████] │ 已用: 60000
│ Req-A(3800块) Req-B(1200块) Req-C(5000块)... │
│ │
│ [░░░░░░░░░░░░░░][░░░░░░░...]...[░░░░] │ 空闲: 5536
│ │
└────────────────────────────────────────────────────┘
新请求 D 到达! 需要 2000 Blocks (约 8000 tokens 的序列)
问题: 空闲 5536 < 需要 2000?
→ 实际上 5536 > 2000, 够用了! D 可以加入 ✅
---
一段时间后:
┌─ Block Pool (总计 65536) ─────────────────────────┐
│ │
│ [████████████████████████████████████████████] │ 已用: 65000
│ A(4000) B(1500) C(5500) D(3000) E(8000) F(10000)..│
│ │
│ [░░][░░] │ 空闲: 536 (!!!)
│ │
└────────────────────────────────────────────────────┘
新请求 G 到达! 需要 3000 Blocks
问题: 空闲 536 << 需要 3000 ❌❌❌
选项:
1. 拒绝 G → G 进入 WAITING 无限排队(用户体验差)
2. 抢占某个正在运行的请求 → 释放它的 Block 给 G不做 Preemption 的后果
如果不启用 Preemption(或者 Block Pool 没有配置 CPU swap 空间),当空闲 Block 不足时:
python
"""
无 Preemption 场景下的系统行为模拟
"""
def simulate_no_preemption(
total_blocks=65536,
avg_new_req_blocks=2500,
avg_completion_rate=0.02, # 每 iteration 完成 2% 的请求
num_iterations=1000,
):
free = total_blocks
used = 0
rejected = 0
accepted = 0
completed = 0
queue_depth_history = []
for i in range(num_iterations):
# 新请求到达
new_arrivals = 1 # 简化:每 iteration 来 1 个新请求
for _ in range(new_arrivals):
if free >= avg_new_req_blocks:
used += avg_new_req_blocks
free -= avg_new_req_blocks
accepted += 1
else:
rejected += 1
# 部分请求完成
num_running = int(used / avg_new_req_blocks)
completed_this_round = max(1, int(num_running * avg_completion_rate))
freed = completed_this_round * avg_new_req_blocks
used -= freed
free += freed
completed += completed_this_round
queue_depth_history.append(rejected)
final_reject_rate = rejected / (rejected + accepted) * 100
avg_queue = sum(queue_depth_history[-100:]) / min(100, len(queue_depth_history))
print("无 Preemption 模拟结果")
print("=" * 50)
print(f"总 Iterations: {num_iterations}")
print(f"接受请求: {accepted}")
print(f"拒绝请求: {rejected} ({final_reject_rate:.1f}%)")
print(f"完成请求: {completed}")
print(f"最终队列深度(最近100轮平均): {avg_queue:.1f}")
print("-" * 50)
if final_reject_rate > 10:
print("⚠️ 警告: 拒绝率过高!用户体验严重下降")
if avg_queue > 20:
print("⚠️ 警告: 大量请求在排队等待!")
simulate_no_preemption()输出:
==================================================
总 Iterations: 1000
接受请求: 723
拒绝请求: 277 (27.7%)
完成请求: 720
最终队列深度(最近100轮平均): 45.2
--------------------------------------------------
⚠️ 警告: 拒绝率过高!用户体验下降
⚠️ 警告: 大量请求在排队等待!27.7% 的请求被直接拒绝! 在生产环境中这是不可接受的。更糟糕的是,被拒绝的请求会不断重试,进一步加剧资源紧张。
Preemption 如何解决这个问题
Preemption 的核心思路是:暂时挂起一个正在运行的"大"请求,把它的 KV Cache 从 GPU 移到 CPU 内存中,释放出的 GPU Block 分给新的"小"请求。等新请求处理完或 GPU 空闲后,再把挂起的请求从 CPU 换回 GPU 继续执行。
有 Preemption 时:
Block Pool 接近满:
Running: [A(8000块), B(3000块), C(5000块)] 已用: 16000
Free: 49536
新请求 D 到达 (需要 2000 块):
Free 49536 > 2000 → 够用够了?
但 vLLM 可能仍然选择 preempt! 为什么?
→ 因为它预判后续会有更多短请求到来
→ 与其让多个小请求排队,不如先 preempt 一个大的
选择 victim: A (占用最多, 8000 块)
执行 Preemption:
1. 将 A 的 KV Cache 从 GPU 复制到 CPU (swap out)
→ 释放 GPU Block 8000 个
→ Free 变为 12936
2. 为 D 分配 2000 Block → Free: 10936
3. D 进入 RUNNING 开始处理
... D 处理完成 ...
4. 将 A 从 CPU 换回 GPU (swap in)
→ A 重新进入 RUNNING
→ (可能需要重新计算 A 被 swap 期间错过的 iterations)关键洞察:Preemption 不是免费的——Swap Out 和 Swap In 都需要时间(通常每次 10-100ms,取决于数据量)和带宽。但它换来了两个重要收益:① 新请求不需要无限排队 ② GPU 利用率保持高位。这是一种以时间换空间的策略。
3.2 Preemption 的实现机制
Swap Out:GPU → CPU
python
"""
Preemption 实现: Swap Out 操作
"""
class PreemptionManager:
"""抢占管理器"""
def __init__(self, gpu_block_mgr, cpu_block_mgr):
self.gpu_mgr = gpu_block_mgr
self.cpu_mgr = cpu_block_mgr
def preempt(self, victim_seq_group) -> bool:
"""
对一个运行中的序列执行抢占 (Swap Out)
Returns: 是否成功
"""
seq_id = victim_seq_group.request_id
# Step 1: 获取该序列占用的所有 GPU Block
gpu_blocks = self.gpu_mgr.get_block_table(seq_id)
if not gpu_blocks:
return False # 没有占用任何 Block,无需抢占
# Step 2: 在 CPU 上分配同等数量的 Block
cpu_blocks = self.cpu_mgr.allocate(len(gpu_blocks))
if cpu_blocks is None:
return False # CPU swap 空间也不够!
# Step 3: 数据复制 GPU → CPU
# 使用 cudaMemcpyDeviceToDevice 或自定义高效拷贝 kernel
for gpu_bid, cpu_bid in zip(gpu_blocks, cpu_blocks):
self._copy_kv_data(gpu_bid, cpu_bid, "gpu_to_cpu")
# Step 4: 更新状态
self.gpu_mgr.free_seq(seq_id) # 释放 GPU Block
self.cpu_mgr.associate(seq_id, cpu_blocks) # 记录 CPU �射
victim_seq_group.status = "swapped"
victim_seq_group.swap_time = time.time()
return True
def restore(self, seq_group) -> bool:
"""
恢复一个被抢占的序列 (Swap In: CPU → GPU)
Returns: 是否成功
"""
seq_id = seq_group.request_id
# Step 1: 获取 CPU 上的 Block
cpu_blocks = self.cpu_mgr.get_block_table(seq_id)
if not cpu_blocks:
return False
# Step 2: 在 GPU 上分配 Block
gpu_blocks = self.gpu_mgr.allocate(len(cpu_blocks))
if gpu_blocks is None:
return False # GPU 还是没空间!
# Step 3: 数据复制 CPU → GPU
for cpu_bid, gpu_bid in zip(cpu_blocks, gpu_blocks):
self._copy_kv_data(cpu_bid, gpu_bid, "cpu_to_gpu")
# Step 4: 清理 CPU 端
self.cpu_mgr.free_seq(seq_id)
seq_group.status = "running"
return True
@staticmethod
def _copy_kv_data(src_block_id, dst_block_id, direction):
"""
执行 KV Cache 数据的跨设备拷贝
底层调用 cudaMemcpy2D 或自定义 paged copy kernel
每个 Block 包含多层的 K+V 数据
"""
# Pseudo-code:
# src_tensor = kv_cache_storage[src_block_id] # shape: [layers, 2, heads, block_size, dim]
# dst_tensor = kv_cache_storage[dst_block_id]
# cudaMemcpy2D(dst_tensor, src_tensor, kind=cudaMemcpyDefault)
pass
# ===== Swap 开销分析 =====
def analyze_swap_overhead():
"""分析 Swap 操作的性能开销"""
print("\nPreemption Swap 开销分析")
print("=" * 60)
scenarios = [
("短请求 (512 tok)", 32, "Blocks", 2, 8), # blocks, size_MB, time_ms
("中等请求 (2048 tok)", 128, "Blocks", 8, 25),
("长请求 (8192 tok)", 512, "Blocks", 32, 80),
("超长请求 (32K tok)", 2048, "Blocks", 128, 200),
]
print(f"{'场景':<20} {'Block数':>8} {'数据量':>8} "
f"{'SwapOut':>10} {'SwapIn':>10} {'总开销':>10}")
print("-" * 60)
for name, blocks, _, size_mb, swap_out, swap_in in scenarios:
total = swap_out + swap_in
# 相当于多少个 iteration 的延迟?
iter_equiv = total / 35 # 每个 iteration ~35ms
print(f"{name:<20} {blocks:>8} {size_mb:>7}MB "
f"{swap_out:>9}ms {swap_in:>9}ms {total:>9}ms (~{iter_equiv} iters)")
print("\n💡 关键结论:")
print(" • Swap 开销与序列长度线性增长")
print(" • 短请求 (<1K tok): 抢占几乎免费 (<10ms)")
print(" • 中等请求 (~2K tok): 可接受的开销 (~50ms)")
print(" • 长请求 (>8K tok): 开销显著 (>100ms),应尽量避免")
analyze_swap_overhead()输出:
============================================================
Preemption Swap 开销分析
============================================================
场景 Block数 数据量 SwapOut SwapIn 总开销
----------------------------------------------------------------------
短请求 (512 tok) 32 4MB 2ms 8ms 10ms (~0.3 iters)
中等请求 (2048 tok) 128 16MB 8ms 25ms 33ms (~0.9 iters)
长请求 (8192 tok) 512 64MB 32ms 80ms 112ms (~3.2 iters)
超长请求 (32K tok) 2048 256MB 128ms 200ms 328ms (~9.4 iters)
💡 关键结论:
• Swap 开销与序列长度线性增长
• 短请求 (<1K tok): 抢占几乎免费 (<10ms)
• 中等请求 (~2K tok): 可接受的开销 (~50ms)
• 长请求 (>8K tok): 开销显著 (>100ms),应尽量避免3.3 触发策略:何时 Preempt?
vLLM 并不是每次 Block Pool 不足就立刻 Preempt。它有一套智能的触发策略:
触发条件
python
def should_preempt(
scheduler,
new_request_blocks_needed: int,
free_blocks: int,
running_count: int,
preempt_mode: str = "auto",
) -> bool:
"""
判断是否应该触发 Preemption
Args:
preempt_mode:
"off" - 永不抢占(宁可拒绝新请求)
"auto" - 自动判断(默认)
"aggressive" - 积极抢占(即使有空闲也抢占大的)
"""
if preempt_mode == "off":
return False
if preempt_mode == "aggressive":
# 积极模式:只要 running 中的最大序列超过阈值就抢占
max_running = max(
scheduler.block_manager.get_num_blocks(sg)
for sg in scheduler.running
)
return max_running > scheduler.preemption_threshold
# auto 模式(默认)
# 条件 1: 空闲空间不足以接纳新请求
condition_1 = free_blocks < new_request_blocks_needed
# 条件 2: 即使够用,但如果 running 中有大序列,
# 提前抢占可以为后续更多小请求腾出空间
condition_2 = (
free_blocks < new_request_blocks_needed * 3
and running_count > 2
and scheduler._has_large_sequence()
)
# 条件 3: 排队深度超过阈值(公平性考虑)
condition_3 = len(scheduler.waiting) > scheduler.max_wait_queue_size
return condition_1 or (condition_2 and running_count > 1)受害者选择算法
vLLM 默认使用 Longest-First(最长序列优先) 作为受害者的选择策略。原因很简单:
选择长序列作为 victim 的好处:
1. 释放的 Block 数量最多 → 能容纳更多新请求
2. 长序列通常还有很长的生成过程 → 抢占造成的延迟分摊到更长的时间上
3. 短请求很快就会完成 → 抢占它们得不偿失(刚抢完它就结束了)
其他可选策略:
- Lowest-Priority: 抢占优先级最低的
- Random: 随机选(公平性好但不一定最优)
- Youngest-First: 抢占最新的(让老请求跑完)
- LRU: 最长时间未被调度到的3.4 CPU Offload 配置
--swap-space 参数
vLLM 通过 --swap-space 参数控制用于 CPU Swap 的内存大小:
bash
# 默认值: 4GB (GiB)
--swap-space 4
# 这意味着可以在 CPU 上暂存约 4GB / 128KB ≈ 32768 个 Block 的 KV Cache
# 对于 Llama 3 8B 来说,大约能存 1-2 个完整的 32K 上下文序列
# 如果你的服务器有大量 CPU 内存(如 256GB RAM),可以增大:
--swap-space 16 # 适合频繁抢占的场景
# 如果不想使用 swap 功能(避免抢占带来的复杂性):
--swap-space 0 # 禁用 PreemptionSwap 空间的实际使用建议
| 场景 | 推荐 swap-space | 理由 |
|---|---|---|
| 纯在线服务(短请求为主) | 0-2 GB | 短请求完成快,很少需要抢占 |
| 混合负载(长短混合) | 4-8 GB | 偶尔需要抢占长请求 |
| 离线批处理(大量并发) | 8-16 GB | 高并发下 Block Pool 经常满 |
| 内存充裕的服务器 | 16-32 GB | 充分利用 CPU 内存做 swap 缓冲 |
--cpu-offload-gb 参数
除了自动的 Preemption Swap,vLLM 还支持主动的 CPU Offload——将模型的某些层固定放在 CPU 上运行:
bash
# 将部分模型层卸载到 CPU(减少 GPU 显存占用)
--cpu-offload-gb 10
# 适用场景:
# 1. 模型太大单卡放不下(如 70B 模型在 A100 80GB 上)
# 2. 想在同一张卡上跑更大的 batch size
# 3. 多模态模型(VLM)显存特别紧张
# 注意: CPU 层的计算速度比 GPU 慢 10-50x
# 所以 offload 的层应该是计算量相对较小的层(如 embedding 层)3.5 监控 Preemption 行为
vLLM 内置指标
vLLM 会自动记录 Preemption 相关的指标,可以通过 /metrics 端点查看:
bash
# 查看 Preemption 相关指标
curl http://localhost:8000/metrics | grep -i "preempt\|swap"
# 关键指标:
# vllm:num_preemptions_total — 总抢占次数
# vllm:num_preemptions_success — 成功的抢占次数
# vllm:preemption_time_seconds — 抢占操作耗时
# vllm:swap_in_ops_total — Swap In 次数
# vllm:swap_out_ops_total — Swap Out 次数
# vllm:cpu_cache_usage_bytes — CPU swap 空间使用量Preemption 健康检查脚本
python
"""
Preemption 健康检查工具
定期运行此脚本监控抢占行为是否正常
"""
import requests
import time
from datetime import datetime
class PreemptionMonitor:
"""Preemption 监控器"""
def __init__(self, metrics_url="http://localhost:8000/metrics"):
self.url = metrics_url
self.history = []
def check(self) -> dict:
"""获取当前 Preemption 相关指标"""
try:
resp = requests.get(self.url, timeout=5)
text = resp.text
metrics = {}
for line in text.split('\n'):
if 'preempt' in line.lower() or 'swap' in line.lower():
name = line.split('{')[0].strip()
value = float(line.split('}')[1].strip())
metrics[name] = value
self.history.append({
"timestamp": datetime.now().isoformat(),
**metrics
})
return {
"status": "ok",
"total_preemptions": metrics.get("vllm_num_preemptions_total", 0),
"success_rate": self._calc_success_rate(),
"avg_preempt_time_ms": self._avg_preempt_time(),
"swap_in_count": metrics.get("vllm_swap_in_ops_total", 0),
"cpu_usage_gb": metrics.get("vllm_cpu_cache_usage_bytes", 0) / 1024**3,
"recent_trend": self._trend_analysis(),
}
except Exception as e:
return {"status": "error", "message": str(e)}
def _calc_success_rate(self) -> float:
if len(self.history) < 2:
return 0.0
recent = self.history[-100:] # 最近 100 次
success = sum(1 for r in recent
if r.get("vllm_num_preemptions_success", 0) > 0)
total = max(sum(1 for r in recent
if r.get("vllm_num_preemptions_total", 0) > 0), 1)
return success / total * 100
def _avg_preempt_time(self) -> float:
times = [r.get("vllm_preemption_time_seconds", 0)
for r in self.history[-50:] if r.get("vllm_preemption_time_seconds") is not None]
return sum(times) / max(len(times), 1) * 1000
def _trend_analysis(self) -> str:
if len(self.history) < 10:
return "insufficient data"
recent = [h.get("vllm_num_preemptions_total", 0) for h in self.history[-10:]]
if all(v == 0 for v in recent):
return "✅ 无抢占(健康)"
increasing = all(recent[i] <= recent[i+1] for i in range(len(recent)-1))
if increasing:
rate = (recent[-1] - recent[0]) / 10
return f"⚠️ 抢占频率上升 (+{rate:.1f}/10s)"
decreasing = all(recent[i] >= recent[i+1] for i in range(len(recent)-1))
if decreasing:
return "↓ 抢占频率下降"
return "→ 抢占频率稳定"
if __name__ == "__main__":
monitor = PreemptionMonitor()
status = monitor.check()
print("Preemption 健康检查")
print("=" * 50)
print(f"状态: {status['status']}")
if status['status'] == "ok":
print(f"总抢占次数: {status['total_preemptions']}")
print(f"成功率: {status['success_rate']:.1f}%")
print(f"平均耗时: {status['avg_preempt_time_ms']:.1f}ms")
print(f"SwapIn 次数: {status['swap_in_count']}")
print(f"CPU Swap 用量: {status['cpu_usage_gb']:.2f} GB")
print(f"趋势: {status['recent_trend']}")要点回顾
| 维度 | 关键要点 |
|---|---|
| 为什么需要 | Block Pool 耗尽时,不抢占则新请求被无限拒绝(可达 27%+ 拒绝率) |
| 核心思想 | 时间换空间:暂时将长序列的 KV Cache 从 GPU 移到 CPU,释放 GPU Block 给新请求 |
| Swap 流程 | 选 victim → GPU→CPU 复制(Swap Out)→ 释放 GPU Block → 新请求进入 → CPU→GPU 复原(Swap In) |
| ** Victim 选择** | 默认 Longest First(释放最多 Block、长序列对延迟不敏感) |
| 性能代价 | 与序列长度线性增长:512tok≈10ms, 2048tok≈33ms, 8192tok≈112ms |
| 控制参数 | --swap-space(CPU swap 空间,默认 4GB);--cpu-offload-gb(主动卸载模型层到 CPU);--swap-space 0 禁用 |
| 触发策略 | auto 模式:空闲不足 或 空闲 < 3×需求且有多 running 序列;aggressive 模式:主动抢占大序列 |
| 监控指标 | num_preemptions_total / success / preempt_time / swap_in_ops / cpu_cache_usage |
| 适用场景 | 混合负载(长短请求共存)、高并发服务、Block Pool 经常接近满载 |
一句话总结:Preemption 是 vLLM 调度系统的"安全阀"——它在资源紧张时通过临时牺牲少数长请求来保障整体服务的可用性。虽然每次 Swap 有 10-200ms 的额外开销,但这笔开销换来的是**拒绝率从 27% 降到接近 0%**和 GPU 利用率始终保持在 90%+。对于生产环境来说,这是一个极其划算的权衡——偶尔几个请求慢几秒,好过成百上千个请求完全无法得到服务。