GPU 超售策略:显存共享的风险和收益要同时评估
一、推理集群 30% 利用率背后的超售冲动
GPU 推理集群的利用率曲线通常呈"驼峰状"——流量高峰时部分节点的 GPU 利用率冲到 90%,低谷时全局利用率跌至 20%。运维团队面对老板的降本增效指标时,GPU 超售(Oversubscription)成了一个挥之不去的选项。
超售的思路很简单:既然多个推理服务各占一张卡、各自只用 2-6GB 显存,为什么不把它们的模型同时加载到一张卡上,共享剩余的显存资源?从资源利用率的账面上看,这确实能把 30% 拉升到 70% 甚至更高。但超售不是免费的午餐——它把显存故障的爆炸半径从单一服务扩展到了一整张 GPU 卡上的所有服务。
基础设施不需要漂亮话,利用率提升的每一个百分点都对应着一个潜在的风险敞口。当 GPU 显存被超额分配时,CUDA OOM 错误的恢复成本不再是重启一个容器,而是可能引发连锁的服务雪崩。
二、GPU 显存的物理边界与超售的三种实现路径
GPU 显存(VRAM)的分配是离散的、不可压缩的。CPU 内存可以通过 swap 和压缩算法实现"看起来比物理内存大",但 GPU 显存没有 swap 机制,分配给进程后就是独占的物理区间。超售的本质是承诺超过物理显存的容量给多个消费者,赌博这些消费者不会同时使用其承诺的配额。
graph LR subgraph "物理 GPU (40GB VRAM)" VRAM[40GB HBM] end subgraph "超售策略 A: 静态超额" A1[服务1: 承诺 15GB] A2[服务2: 承诺 15GB] A3[服务3: 承诺 15GB] A4[服务4: 承诺 15GB] NoteA[承诺总量 60GB > 物理 40GB<br/>赌各服务实际占用 < 承诺值] end subgraph "超售策略 B: 动态超售" B1[服务1: 20GB 弹性] B2[服务2: 20GB 弹性] B3[服务3: 20GB 弹性] NoteB[显存池化,按需分配<br/>同卡服务排队申请] end subgraph "超售策略 C: 模型卸载" C1[GPU 热模型: 15GB] C2[CPU 冷模型: 30GB] C3[GPU 热模型: 10GB] NoteC[活跃模型在 GPU<br/>冷模型卸载到 CPU RAM] end VRAM --> A1 VRAM --> A2 VRAM --> B1 VRAM --> C1 VRAM --> C3 style NoteA fill:#ffcdd2,color:#333 style NoteB fill:#fff9c4,color:#333 style NoteC fill:#c8e6c9,color:#333策略 A:静态超额分配。这是最危险的超售方式。Kubernetes 的 Device Plugin 机制默认按整卡分配 GPU 资源,nvidia.com/gpu: 1对应一张完整的物理 GPU。如果需要超额使用,必须绕过默认的 Device Plugin,使用显存级别的资源宣称(如nvidia.com/gpu-memory: 10Gi)。但这种方式没有硬性隔离——如果服务 A 实际使用了 12GB,Kubernetes 完全没有感知。
策略 B:动态超售。通过 GPU 共享框架(如 NVIDIA GPU Operator 的 Time-Slicing 模式或 HAMi)将 GPU 显存和计算资源池化。多个容器共享一张 GPU,框架在用户态分配显存配额。当配额超分时,依赖 LRU 或优先级策略做显存回收。这比静态超额多了回收机制,但回收过程可能导致服务短暂的性能下降。
策略 C:模型卸载。异步推理服务将不活跃的模型从 GPU 显存卸载到 CPU 内存,请求到达时再加载回 GPU。这种方式利用显存的"时间复用"实现超售——同一时间点上只有核心服务模型驻留 GPU。卸载和加载的延迟在百毫秒至秒级,取决于模型大小和 PCIe 带宽,适合对冷启动容忍度较高的场景。
三、生产级 GPU 超售的监控与 OOM 防护
在实际部署中,超售必须有配套的监控和熔断机制。以下代码展示了对 GPU 显存使用量的实时采集和 OOM 预警逻辑。
// gpu_oversubscription_monitor.go — GPU 超售监控器 // 职责:持续采集 GPU 进程级显存使用量,在超售风险阈值触发告警和自动限流 // 数据源:nvidia-smi 的进程级显存报告 package main import ( "context" "fmt" "os/exec" "regexp" "strconv" "strings" "sync" "time" ) // GPUProcessMemory 表示单个 GPU 进程的显存使用 type GPUProcessMemory struct { PID int ProcessName string UsedMB int } // GPUMemorySnapshot 表示单张 GPU 的显存快照 type GPUMemorySnapshot struct { GPUIndex int TotalMB int UsedMB int FreeMB int Processes []GPUProcessMemory Timestamp time.Time } // OversubscriptionMonitor 超售监控器 type OversubscriptionMonitor struct { gpuIndex int totalVRAMMB int // 物理显存总量 overCommitRate float64 // 超售比例,如 1.5 表示 150% 超售 warnThreshold float64 // 告警阈值,如 0.9 表示使用率 ≥ 90% 时告警 criticalThresh float64 // 熔断阈值,如 0.97 表示使用率 ≥ 97% 时拒绝新请求 mu sync.RWMutex callback func(snapshot GPUMemorySnapshot) // 回调函数,用于对接告警系统 } // NewOversubscriptionMonitor 创建超售监控器 func NewOversubscriptionMonitor(gpuIndex, totalVRAMMB int, overCommitRate float64) *OversubscriptionMonitor { return &OversubscriptionMonitor{ gpuIndex: gpuIndex, totalVRAMMB: totalVRAMMB, overCommitRate: overCommitRate, warnThreshold: 0.85, // 85% 物理显存使用率告警 criticalThresh: 0.95, // 95% 物理显存使用率熔断 } } // SetAlertCallback 设置告警回调 func (m *OversubscriptionMonitor) SetAlertCallback(fn func(GPUMemorySnapshot)) { m.mu.Lock() defer m.mu.Unlock() m.callback = fn } // CollectSnapshot 通过 nvidia-smi 采集 GPU 显存快照 func (m *OversubscriptionMonitor) CollectSnapshot(ctx context.Context) (*GPUMemorySnapshot, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // nvidia-smi 查询指定 GPU 的进程级显存使用 // --query-compute-apps=pid,process_name,used_gpu_memory 输出 CSV 格式 cmd := exec.CommandContext(ctx, "nvidia-smi", "-i", fmt.Sprintf("%d", m.gpuIndex), "--query-compute-apps=pid,process_name,used_gpu_memory", "--format=csv,noheader,nounits", ) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("nvidia-smi 查询失败 (GPU %d): %w", m.gpuIndex, err) } snapshot := &GPUMemorySnapshot{ GPUIndex: m.gpuIndex, TotalMB: m.totalVRAMMB, Timestamp: time.Now(), } // 解析进程显存信息 pidRegex := regexp.MustCompile(`(\d+),\s*(.+),\s*(\d+)\s*MiB`) lines := strings.Split(strings.TrimSpace(string(output)), "\n") for _, line := range lines { matches := pidRegex.FindStringSubmatch(line) if len(matches) != 4 { continue } pid, _ := strconv.Atoi(matches[1]) usedMB, _ := strconv.Atoi(matches[3]) snapshot.Processes = append(snapshot.Processes, GPUProcessMemory{ PID: pid, ProcessName: strings.TrimSpace(matches[2]), UsedMB: usedMB, }) snapshot.UsedMB += usedMB } snapshot.FreeMB = snapshot.TotalMB - snapshot.UsedMB return snapshot, nil } // CheckAndAlert 检查超售风险并触发相应动作 func (m *OversubscriptionMonitor) CheckAndAlert(ctx context.Context) error { snapshot, err := m.CollectSnapshot(ctx) if err != nil { return fmt.Errorf("采集显存快照失败: %w", err) } usageRatio := float64(snapshot.UsedMB) / float64(snapshot.TotalMB) m.mu.RLock() warnThresh := m.warnThreshold critThresh := m.criticalThresh m.mu.RUnlock() switch { case usageRatio >= critThresh: // 熔断:拒绝新请求,已有请求继续 return fmt.Errorf("GPU %d 显存达到熔断阈值 %.1f%% (已用 %d/%d MB), 拒绝新请求", m.gpuIndex, usageRatio*100, snapshot.UsedMB, snapshot.TotalMB) case usageRatio >= warnThresh: // 告警:记录日志并发送告警,但继续服务 fmt.Printf("[WARN] GPU %d 显存使用率 %.1f%% 超过告警阈值 %.1f%%\n", m.gpuIndex, usageRatio*100, warnThresh*100) m.mu.RLock() cb := m.callback m.mu.RUnlock() if cb != nil { cb(*snapshot) } } return nil }四、超售的风险矩阵与禁用场景
超售的主要风险不是性能下降,而是显存耗尽导致的级联故障。当多个服务共享一张 GPU 显存时,一个服务的显存泄漏(常见的如 KV Cache 未及时释放)可能挤占其他服务的显存空间,触发连锁 OOM。
KV Cache 的动态性是最难预测的变量。推理服务中,KV Cache 的显存占用与并发请求数量、序列长度正相关。在高峰期,单个请求的 KV Cache 可能从 100MB 膨胀到 2GB。静态的超售比(如承诺显存比物理显存多 50%)在流量波动下毫无意义。
超售与 MIG 的互斥性。NVIDIA MIG 和显存超售无法同时使用。MIG 在硬件层面固定了显存分区,无法在运行时调整分区大小。如果要超售,只能用 Time-Slicing 或 MPS 方案,丧失了硬件隔离的安全网。
适用场景:
- 离线批处理任务,任务完成时间不敏感
- 冷热模型共存的推理场景,冷模型允许冷启动
- 模型大小稳定、KV Cache 可预测的推理服务
- 开发测试环境,稳定性要求低
禁用场景:
- SLO ≥ 99.9% 的在线推理服务
- 需要 MIG 硬件隔离的多租户平台
- 大模型训练任务(训练必须独占显存)
- 显存使用模式不稳定的服务(如动态 Batch Size)
五、总结
GPU 超售是用稳定性换利用率,决策前需要回答三个问题:
- 超售比怎么定?不是拍脑袋,需要基于 7-30 天的历史显存使用数据计算峰值和均值,超售比 ≤ 物理显存 / 峰值使用量。
- OOM 的恢复成本是多少?服务冷启动(模型加载 + 预热)需要 10 秒还是 10 分钟?这决定了你是否承受得起一次连锁 OOM。
- 有没有更好的替代方案?MIG 切分、模型量化、Request Batching 在某些场景下能达到接近的利用率提升,且不引入超售风险。