AI 推理服务的资源超卖策略:在稳定与效率之间找平衡

AI1周前发布 beixibaobao
9 0 0

AI 推理服务的资源超卖策略:在稳定与效率之间找平衡

一、你的 GPU 集群平均利用率 35%,但老板说"再买 GPU 预算批不下来"

GPU 推理服务的资源利用率悖论:为了保证稳定性,给每个推理服务留了 30-50% 的 GPU 显存 buffer——防止突发流量导致 OOM。结果是 8 张 A100 GPU,平均利用率不到 40%,但每次高峰期仍然有服务报 OOM。

问题在于"静态资源分配"——每个推理服务独占 GPU 或 GPU 的一部分,无论它当前在用不用。A 服务在下午 2 点高峰期跑满,B 服务同一时间几乎无负载——B 的 GPU 闲置,A 却不能借用。这就是资源超卖(Resource Oversubscription)需要解决的问题:允许服务申请超过物理资源的"逻辑资源",通过调度器动态分配,在整体利用率低时给需要资源的服务多分配。

但超卖有风险——当多个服务的"高峰期"重叠时,物理资源不够分,必须有所取舍。这正是资源超卖策略的核心:定义优先级、抢占规则、过载保护。

二、底层机制与原理剖析

资源超卖的三层机制:

第一层:超卖率设定。超卖率 = 逻辑资源总量 / 物理资源总量。例如:8 张 A100 共 640GB 显存,超卖率 1.5x 意味着允许申请总量 960GB 显存。超卖率的选择依赖于"所有服务的峰值是否同时发生"——如果服务的峰值时间错开(如在线服务白天高峰、批处理夜间执行),超卖率可以设高。如果同时间段服务同时跑来峰值,超卖率只能接近 1.0。

第二层:优先级抢占。当物理资源不足时,低优先级的服务可以被"抢占"(驱逐),把资源让给高优先级服务。抢占不是"杀掉进程"——GPU 推理服务加载模型权重需要 30-60 秒。更温和的做法是:降低低优先级服务的 batch size 和 GPU 配额,而不是直接驱逐。

第三层:过载保护。即便有超卖和抢占,极端情况下物理资源仍然可能不够。需要在入口做流量控制:拒绝超出物理容量上限的新请求,返回 429(Too Many Requests)或排队等待。

三、生产级代码实现

"""
GPU 资源超卖调度器
核心数据:资源池、服务优先级、抢占规则
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from enum import IntEnum
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class Priority(IntEnum):
    """服务优先级(数字越小优先级越高)"""
    P0_ONLINE = 0       # 在线推理——不可抢占
    P1_BATCH = 1         # 批处理推理——可被 P0 抢占
    P2_EXPERIMENT = 2    # 实验/开发——可被任何优先级抢占
@dataclass
class GPUResource:
    """GPU 资源配置"""
    vram_gb: float           # 显存(GB)
    compute_percent: float   # 计算占比(0-100)
@dataclass
class ServiceRequest:
    """服务资源请求"""
    service_id: str
    priority: Priority
    requested: GPUResource
    # 当前实际使用量(可能低于 requested)
    actual_usage: GPUResource = field(default_factory=lambda: GPUResource(0, 0))
    # 可抢占标记
    preemptible: bool = True  # False 表示不可抢占
    created_at: float = field(default_factory=time.time)
class GPUResourcePool:
    """
    GPU 资源池管理
    三种状态:
    - FREE: 空闲,可分配给任何请求
    - ALLOCATED: 已分配,正在使用
    - PREEMPTIBLE: 已分配但可被抢占(低优先级服务的资源)
    """
    def __init__(self, total_vram_gb: float, oversell_ratio: float = 1.5):
        self.total_vram = total_vram_gb
        self.oversell_ratio = oversell_ratio
        # 逻辑容量(允许超卖后的总量)
        self.logical_capacity = total_vram_gb * oversell_ratio
        # 当前使用量
        self.allocated_vram = 0.0    # 已分配的显存总量
        self.actual_usage_vram = 0.0 # 实际使用的显存
        # 活跃服务
        self.services: Dict[str, ServiceRequest] = {}
        logger.info("GPU Pool: %.0fGB physical, %.0fGB logical (%.1fx oversell)",
                   total_vram_gb, self.logical_capacity, oversell_ratio)
    def can_allocate(self, request: ServiceRequest) -> bool:
        """
        判断是否可以分配资源
        检查两个条件:
        1. 逻辑容量是否足够(超卖角度的检查)
        2. 物理容量 + 可抢占资源是否足够(安全角度的检查)
        """
        # 条件 1:逻辑容量检查
        if self.allocated_vram + request.requested.vram_gb > self.logical_capacity:
            # 超过逻辑容量 → 如果该请求优先级高于某个已有服务,可以抢占
            preemptible_vram = self._get_preemptible_vram()
            if (self.allocated_vram - preemptible_vram + request.requested.vram_gb
                    <= self.logical_capacity):
                # 通过抢占可以容纳
                return True
            return False
        # 条件 2:物理容量检查
        physical_available = self.total_vram - self.actual_usage_vram
        if request.requested.vram_gb > physical_available:
            # 物理容量不足 → 需要抢占
            preemptible_usage = self._get_preemptible_actual_usage()
            if request.requested.vram_gb > physical_available + preemptible_usage:
                return False
        return True
    def allocate(self, request: ServiceRequest) -> List[str]:
        """
        分配资源——可能需要抢占低优先级服务
        返回:被抢占的服务 ID 列表
        """
        preempted = []
        # 如果需要抢占
        physical_available = self.total_vram - self.actual_usage_vram
        if request.requested.vram_gb > physical_available:
            shortage = request.requested.vram_gb - physical_available
            # 从最低优先级开始抢占
            sorted_services = sorted(
                self.services.values(),
                key=lambda s: (s.priority, -s.created_at),  # 低优先级 + 新来的优先被抢
            )
            for svc in sorted_services:
                if shortage <= 0:
                    break
                if svc.priority <= request.priority:
                    # 同优先级或更高优先级——不能抢占
                    continue
                if not svc.preemptible:
                    continue
                # 抢占这个服务
                shortage -= svc.actual_usage.vram_gb
                preempted.append(svc.service_id)
                logger.warning("Preempting %s (P%d) for %s (P%d)",
                             svc.service_id, svc.priority,
                             request.service_id, request.priority)
        # 注册服务
        self.services[request.service_id] = request
        self.allocated_vram += request.requested.vram_gb
        self.actual_usage_vram += request.requested.vram_gb
        # 移除被抢占的服务
        for svc_id in preempted:
            self._remove_service(svc_id)
        logger.info("Allocated %.0fGB to %s (P%d) | Pool: %.0f/%.0fGB phys, %.0f/%.0fGB logical",
                   request.requested.vram_gb, request.service_id, request.priority,
                   self.actual_usage_vram, self.total_vram,
                   self.allocated_vram, self.logical_capacity)
        return preempted
    def release(self, service_id: str):
        """释放服务占用的资源"""
        self._remove_service(service_id)
    def update_actual_usage(self, service_id: str, actual: GPUResource):
        """更新服务的实际使用量"""
        if service_id in self.services:
            old_actual = self.services[service_id].actual_usage
            self.actual_usage_vram -= old_actual.vram_gb
            self.actual_usage_vram += actual.vram_gb
            self.services[service_id].actual_usage = actual
    def get_utilization(self) -> Tuple[float, float]:
        """获取利用率(物理 / 逻辑)"""
        phys_util = self.actual_usage_vram / self.total_vram * 100
        logical_util = self.allocated_vram / self.logical_capacity * 100
        return phys_util, logical_util
    def _get_preemptible_vram(self) -> float:
        """获取可抢占的显存总量(逻辑分配)"""
        return sum(
            s.requested.vram_gb
            for s in self.services.values()
            if s.preemptible
        )
    def _get_preemptible_actual_usage(self) -> float:
        """获取可抢占的显存实际使用量"""
        return sum(
            s.actual_usage.vram_gb
            for s in self.services.values()
            if s.preemptible
        )
    def _remove_service(self, service_id: str):
        if service_id in self.services:
            svc = self.services.pop(service_id)
            self.allocated_vram -= svc.requested.vram_gb
            self.actual_usage_vram -= svc.actual_usage.vram_gb
# ---------------------------------------------------------------------------
# 模拟使用
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    pool = GPUResourcePool(total_vram_gb=640, oversell_ratio=1.5)
    # 部署服务
    services = [
        ServiceRequest("agent-online", Priority.P0_ONLINE,
                      GPUResource(160, 100), preemptible=False),
        ServiceRequest("batch-analysis", Priority.P1_BATCH,
                      GPUResource(120, 80)),
        ServiceRequest("model-eval", Priority.P2_EXPERIMENT,
                      GPUResource(100, 60)),
        ServiceRequest("new-service", Priority.P1_BATCH,
                      GPUResource(80, 60)),
    ]
    for svc in services:
        can = pool.can_allocate(svc)
        print(f"  {svc.service_id} (P{int(svc.priority)}): "
              f"可分配={can}, 需要={svc.requested.vram_gb:.0f}GB")
        if can:
            preempted = pool.allocate(svc)
            if preempted:
                print(f"    → 抢占: {preempted}")
    phys, log = pool.get_utilization()
    print(f"n利用率: 物理 {phys:.1f}% / 逻辑 {log:.1f}%")

四、边界分析与架构权衡

超卖率的设定

  • 超卖率过高(3x+)→ 高峰期大量抢占 → 低优先级服务频繁被驱逐 → 批处理和实验任务无法完成
  • 超卖率过低(<1.2x)→ 利用率提升有限 → 没有解决问题
  • 推荐从 1.3x 起步,观察一个完整的业务周期(含周末)的抢占次数。如果一周内抢占 < 5 次,可以逐步提高到 1.5-1.8x

抢占的伤害控制

  • 被抢占的服务不是杀掉退——而是"降级"。降低被抢占服务的 batch size(继续运行,但慢一点),或者将其排队等待资源释放
  • 抢占前给服务一个 grace period(如 30 秒)来保存 Checkpoint 再退出
  • 对可抢占的批处理任务做 Checkpoint,被抢占后可以从断点恢复而不是从头开始

不适合超卖的负载类型

  • 延时敏感的在线推理——超卖导致资源竞争,延迟不可控
  • 状态ful 的模型推理——被抢占后需要重新加载模型权重,冷启动成本太高
  • 安全关键型负载——不能接受任何因抢占导致的不可用

五、总结

GPU 资源超卖本质是用稳定性(增加低优先级任务的抢占风险)换效率(提高整体 GPU 利用率)。核心三机制:超卖率(逻辑容量/物理容量)、优先级抢占(低优先级被高优先级驱逐)、过载保护(防止超卖过头导致全面崩溃)。关键是优先级体系设计——在线推理不可抢占(P0),批处理可被在线抢占(P1),实验任务随时被抢占(P2)。只要你把最关键的负载设置了"不可抢占 + 独占资源",其余负载的超卖就是安全的。

© 版权声明

相关文章