异构计算碎片化困局:GPU 池化与时分复用统一调度架构实战
在大模型算力荒的当下,几乎所有 AI 团队都面临一个反直觉的现实:GPU 利用率常年低于 40%,但任务仍然排不上队。这个矛盾的根源不是总量不足,而是算力被碎片化锁死在了错误的地方。本文将从工程视角深入剖析 GPU 碎片化的四种典型症状,并给出一个生产可落地的统一调度架构。
一、算力荒的另一面:碎片化的四副面孔
某 AI 基础设施团队的数据极具代表性:集群 200 张 H100 80GB,监控显示平均显存利用率 62%,但每天都有超过 15% 的任务因为"显存不足"被排队。这种"明明还有显存却分配不了"的怪象,根因是显存碎片化。
第一类:尾部碎片。一块 H100 上跑了三个推理任务,各占 12GB、8GB、6GB,剩余 54GB。此时来了一个需要 60GB KV Cache 的长上下文推理请求——失败。54GB 的连续空间被锁死在一张卡上,无法跨节点聚合。
第二类:时空错配。训练团队白天用满集群,推理团队夜间才到高峰。训练任务释放的算力无法实时让渡,只能靠人工排班"交接"。GPU 的排他性分配模型让时间维度上的共享变得不可能。
第三类:规格错配。A100 40GB 跑 7B 推理绰绰有余,但用户申请的是 "1x H100 80GB"——因为工具链里没有细粒度规格选项。一张 80GB 卡被 7B 模型占着只用 14GB,剩余 66GB 成为沉没问题资产。
第四类:抢占盲区。Kubernetes 默认调度器不知道 GPU 内部状态,它只看到 " nvidia.com/gpu: 1 "。当它把第三个任务调度到一张只剩 8GB 显存的卡上,OOM Kill 会级联杀死邻居进程,造成雪崩。
这四类碎片叠加,使得名义利用率 62% 的集群,有效可用率实际不到 35%。
二、从理论到现实:为什么 GPU 池化如此艰难
理想状态下,GPU 像 CPU 一样被一个弹性资源池管理,需要 2GB 显存就给 2GB,需要 1/4 的算力就给 1/4。但现实有三座大山:
CUDA 上下文的重量级本质。与进程的轻量上下文切换不同,CUDA context 切换代价高昂——每个 context 占用约 400MB 独占显存,且包含 JIT 编译缓存、TensorRT engine、CUDA Graph 等状态。频繁切换意味着这些状态反复失效重建。每次切换的恢复延迟可达 50-200ms,对 P99 延迟敏感的推理服务不可接受。
显存隔离的缺失。CUDA 运行时的 UVM(Unified Virtual Memory)机制允许多进程共享地址空间,但缺乏硬隔离。一个内存泄漏的进程可以污染整个 GPU 的显存池。NVIDIA 的 MIG(Multi-Instance GPU)提供了空间隔离,但分区粒度固定(如 1/7、2/7),无法动态重组,且不支持 MPS。
计算单元的共享悖论。GPU 的 SM(Streaming Multiprocessor)本质上是时分复用的——多个 kernel 在同一个 SM 上交替执行。理论上可以像 CPU 一样做时间片调度,但 AI 计算的特殊性在于:推理服务的 P99 延迟要求 <100ms,一次完整的 transformer forward pass 可能只需要 5-20ms。如果每 10ms 就要让出一次 SM,上下文切换的 cache trashing 会让性能暴跌 40% 以上。
因此,可行的池化方案必须在空间分区和时间复用之间找到平衡点,且需要一个感知应用生命周期的调度器来做仲裁。
三、核心机制一:CUDA MPS 与显存硬限额
CUDA MPS(Multi-Process Service)是被严重低估的技术。它通过一个 daemon 进程聚合多个客户端的 CUDA 调用,转发到单一 GPU context。核心价值不是"共享 context",而是让多个进程共享同一个 CUDA Graph 缓存和 JIT 编译结果。
MPS 真正解决生产问题的是配合 CUDA_MPS_PINNED_DEVICE_MEM_LIMIT 做显存限额。以下是一个典型的多租户推理隔离配置:
# 启动 MPS daemon
export CUDA_VISIBLE_DEVICES=0
export CUDA_MPS_PIPE_DIRECTORY=/tmp/nvidia-mps
export CUDA_MPS_LOG_DIRECTORY=/tmp/nvidia-log
nvidia-cuda-mps-control -d
# 客户端 A:长上下文推理,限额 32GB
export CUDA_MPS_PINNED_DEVICE_MEM_LIMIT=32GB
python serve_longcontext.py --kv-cache-size 32
# 客户端 B:常规推理,限额 16GB
export CUDA_MPS_PINNED_DEVICE_MEM_LIMIT=16GB
python serve_standard.py
# 客户端 C:嵌入模型,限额 8GB
export CUDA_MPS_PINNED_DEVICE_MEM_LIMIT=8GB
python serve_embedding.py
关键点在于:MPS 模式下,当客户端 A 的显存触及 32GB 限额,它的 cudaMalloc 会直接失败返回 cudaErrorMemoryAllocation——而不是像普通模式那样触发 OOM Killer 把 B 和 C 也带走。这就是"显存硬隔离"的实现原理。
但 MPS 有个致命限制:所有客户端共享同一组 SM。如果 A 在跑一个 heavy attention kernel,B 和 C 的延迟会飙高。解决方案是引入 Active Thread Percentage(ATP) 控制——MPS 可以为每个客户端限制最大占用 SM 线程的比例:
# 客户端 A 最多占 60% SM 资源
echo "set_default_active_thread_percentage 60" | nvidia-cuda-mps-control
echo "set_active_thread_percentage 60 <client_pid>" | nvidia-cuda-mps-control
ATP 控制让 MPS 从"被动共享"变为主动仲裁。实测效果:三张卡 MPS 池化后,7B 推理的 P99 延迟从 85ms 升至 97ms(+14%),但吞吐量从 1200 QPS 升至 3100 QPS(+158%)。用 14% 的延迟代价换取 158% 的吞吐,对批处理友好的推理场景是绝佳交易。
四、核心机制二:抢占式 Checkpoint/Resume
对于需要全量 SM 的长训练任务,MPS 方案不再适用。这时需要一个"让出机制":当高优任务到达时,当前低优任务快速释放 GPU,后续再恢复。
GPU 上下文 Checkpoint 的核心难点是三个状态:显存中的模型权重、CUDA Graph 捕获的执行图、以及 NCCL 通信句柄。传统方案(如 CRIU)无法捕获 CUDA 状态。实际生产中的做法是在应用层实现受控 checkpoint:
class GPUCheckpointManager:
"""应用级 GPU 上下文保存/恢复管理"""
def __init__(self, device_id=0):
self.device_id = device_id
self._checkpoint_state = {}
async def checkpoint(self, model, optimizer, step):
"""将 GPU 状态导出到系统内存"""
# 1. 同步 CUDA stream,确保所有计算完成
torch.cuda.synchronize(self.device_id)
# 2. 将权重和 optimizer state 拷贝到 pinned memory
state = {
'model': {k: v.cpu().contiguous()
for k, v in model.state_dict().items()},
'optimizer': {k: v.cpu()
for k, v in optimizer.state_dict().items()},
'step': step,
'rng_state': {
'torch': torch.get_rng_state().cpu(),
'cuda': torch.cuda.get_rng_state(self.device_id).cpu(),
'numpy': np.random.get_state(),
}
}
# 3. 释放 GPU 显存
del model
torch.cuda.empty_cache()
self._checkpoint_state = state
return len(state['model']) * 4 # 返回导出的字节数估算
async def restore(self, model_template, optimizer_template):
"""从系统内存恢复 GPU 上下文"""
state = self._checkpoint_state
# 1. 重建 model 并加载权重
model = model_template.to(f'cuda:{self.device_id}')
model.load_state_dict({
k: v.to(f'cuda:{self.device_id}', non_blocking=True)
for k, v in state['model'].items()
})
# 2. 恢复 optimizer
optimizer = optimizer_template(model.parameters())
optimizer.load_state_dict(state['optimizer'])
# 3. 恢复随机数状态(确保可复现性)
torch.set_rng_state(state['rng_state']['torch'])
torch.cuda.set_rng_state(state['rng_state']['cuda'], self.device_id)
return model, optimizer, state['step']
这个方案的 Checkpoint 耗时约 1.2 秒(80GB 权重通过 PCIe 5.0 拷贝),Resume 约 0.8 秒。对训练任务来说,每小时被抢占一次损失 2 秒,开销仅 0.06%。但我们还需要仲裁器来智能决定是否值得抢占。
五、统一调度器:生产级碎片整理算法
调度器的核心职责有三:感知碎片的产生、决定何时整理碎片、执行迁移而不影响业务。以下是基于 Go 实现的碎片分析核心逻辑:
package scheduler
import (
"sort"
"sync"
"time"
)
// FragScore 碎片评分:0~1,越低越碎片化
type FragScore float64
// GPUNode 单卡资源状态
type GPUNode struct {
ID string
TotalMemoryMB int
UsedMemoryMB int
ComputeLoad float64 // 0~1 计算利用率
ActiveTasks []TaskAllocation
LastFragmentAt time.Time
mu sync.RWMutex
}
// TaskAllocation 任务-GPU 绑定关系
type TaskAllocation struct {
TaskID string
MemoryMB int
ComputeShares float64 // ATP 配额
Priority int // 0:训练 1:推理-online 2:推理-batch 3:开发调试
Checkpointable bool // 是否支持应用层 checkpoint
LastActiveTime time.Time
}
// ClusterState 集群碎片全景
type ClusterState struct {
Nodes []*GPUNode
TotalMemoryMB int
FreeMemoryMB int
AllocatableMB int // 碎片整理后实际可分配的内存
}
// CalculateFragScore 计算集群碎片指数
func (cs *ClusterState) CalculateFragScore() FragScore {
if len(cs.Nodes) == 0 {
return 1.0
}
var totalVariance float64
avgUtil := float64(cs.TotalMemoryMB-cs.FreeMemoryMB) / float64(cs.TotalMemoryMB)
for _, node := range cs.Nodes {
nodeUtil := float64(node.UsedMemoryMB) / float64(node.TotalMemoryMB)
diff := nodeUtil - avgUtil
totalVariance += diff * diff
}
variance := totalVariance / float64(len(cs.Nodes))
// 低方差 = 均匀利用 = 低碎片(理想状态 score 接近 1)
return FragScore(1.0 - variance)
}
// ConsolidationPlan 碎片整理计划
type ConsolidationPlan struct {
Migrations []Migration
FreedNodes []string // 可以休眠/下线的节点
EstimatedFloor time.Duration // 预计整理时长
}
type Migration struct {
TaskID string
FromNode string
ToNode string
Strategy MigMigrationStrategy // WARMING / COLD
Priority int
}
type MigMigrationStrategy int
const (
WARM_MIGRATION MigMigrationStrategy = iota // 运行时热迁移(需 RDMA)
COLD_MIGRATION // checkpoint + 重调度 + restore
)
// GenerateConsolidationPlan 生成碎片整理方案
func (cs *ClusterState) GenerateConsolidationPlan() ConsolidationPlan {
// 1. 按显存使用率升序排列(优先清空低利用率的节点)
nodes := make([]*GPUNode, len(cs.Nodes))
copy(nodes, cs.Nodes)
sort.Slice(nodes, func(i, j int) bool {
ui := float64(nodes[i].UsedMemoryMB) / float64(nodes[i].TotalMemoryMB)
uj := float64(nodes[j].UsedMemoryMB) / float64(nodes[j].TotalMemoryMB)
return ui < uj
})
var migrations []Migration
var freedNodes []string
// 从最低利用率节点开始迁移任务
for _, src := range nodes {
src.mu.RLock()
tasks := make([]TaskAllocation, len(src.ActiveTasks))
copy(tasks, src.ActiveTasks)
src.mu.RUnlock()
// 紧急排空策略:开发调试任务最先迁移
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].Priority > tasks[j].Priority
})
for _, task := range tasks {
if task.Checkpointable {
// 找到最佳目标节点:最少的内存浪费
dst := cs.findBestFitNode(task.MemoryMB, src.ID)
if dst != nil {
migrations = append(migrations, Migration{
TaskID: task.TaskID,
FromNode: src.ID,
ToNode: dst.ID,
Strategy: COLD_MIGRATION,
Priority: task.Priority,
})
}
}
}
// 迁移后源节点是否可释放
remaining := src.UsedMemoryMB
for _, m := range migrations {
if m.FromNode == src.ID {
for _, t := range tasks {
if t.TaskID == m.TaskID {
remaining -= t.MemoryMB
}
}
}
}
if remaining < src.TotalMemoryMB*5/100 {
freedNodes = append(freedNodes, src.ID)
}
}
return ConsolidationPlan{
Migrations: migrations,
FreedNodes: freedNodes,
}
}
这个调度器的核心逻辑借鉴了 Linux 内核的伙伴系统碎片整理思想,但做了两点关键改进:
- 优先级感知:开发调试任务默认可被抢占,训练任务需配置容忍窗口,在线推理任务只在碎片率>30%时才参与整理。
- Best Fit 辅助:选择目标节点时优先填满已有节点(最小剩余空间策略),而不是平均散布。
六、工程避坑:五个让生产环境翻车的细节
1. PCIe 带宽是隐形的瓶颈
Checkpoint 期间,GPU→CPU→GPU 的显存拷贝要经过 PCIe 5.0 x16 总线。理论带宽 64 GB/s,实测在大页 + 异步拷贝并发下约 48-52 GB/s。一张 80GB 的 H100 做完 full checkpoint + resume 至少 3 秒。如果集群有 1000 张卡同时被调度器要求整理,PCIe 带宽会饱和,导致 checkpoint 时间膨胀 5-10 倍。解法是按 Pod 分组错峰执行,设置并发上限。
2. NCCL 通信句柄不可迁移
多卡训练任务的 NCCL communicator 绑定了具体的 GPU 设备号和 NVLink 拓扑。迁移后必须重建 communicator,这要求所有参与节点同步配合。生产中的做法是:多卡训练任务不参与碎片整理——即使空转,也不触发迁移。将多卡任务视为"不可移动的大石块",只整理单卡推理任务。
3. MPS Daemon 单点故障
MPS 模式下,daemon 进程挂了等于整张卡上的所有推理服务全挂。解决方案是双 daemon 热备 + 客户端断线重连机制,或者将 MPS 限制在"非关键推理"(如批量离线预处理)场景,关键在线推理仍用独占模式。
4. 显存限额不等于延迟保障
CUDA_MPS_PINNED_DEVICE_MEM_LIMIT 只限制了 pinned device memory,不限制 UVM 的 system memory backing。如果任务通过 cudaMallocManaged 申请了托管内存,当 system memory 池被污染时,限额会失效。实际部署中需要禁用 UVM 或将其池大小设为限额的 80%。
5. 观测断层是最大的运维坑
调度器做了碎片迁移,但监控系统(Prometheus + Grafana)显示的 GPU 利用率曲线毫无波澜——因为迁移过程中 GPU 确实在"空转"等 checkpoint。没有专门的可观测层,运维团队会误以为迁移造成了性能下降,随后回滚配置。必须暴露 gpu_task_fragmentation_score、gpu_migration_count、gpu_effective_utilization(整理后的折算利用率)等指标。
七、部署效果与演进方向
某 AI 平台(200x H100 80GB,混合训练+推理)实施上述策略后的数据:
| 指标 | 实施前 | 实施后 | 变化 |
|---|---|---|---|
| 集群整体有效利用率 | 36% | 61% | +69% |
| 日均排队任务数 | 47 | 12 | -74% |
| 长上下文推理 P99 延迟 | 142ms | 118ms | -17% |
| 开发环境冷启动等待时间 | 23min | 4min | -83% |
| 非计划性 GPU OOM Kill 次数/天 | 28 | 3 | -89% |
| 批量推理任务完成时间 | 3.2h | 2.1h | -34% |
代价是:需要 1~2 名工程师维护调度器代码,以及约 5% 的算力作为"碎片整理缓冲池"(预留给迁移中的任务使用)。
演进方向上,GPU-over-IP 池化和CUDA 上下文快照是两条明确的技术路线:
- GPU-over-IP:通过网络将远端 GPU 的 PCIe 内存映射到本地,实现物理分离的 GPU 池化。但 100Gbps RDMA 下往返延迟 10μs,比本地 PCIe 的 1μs 高一个数量级,目前只适合离线批处理。
- CUDA 上下文快照:NVIDIA 在研究将 CUDA context 完整序列化到文件的技术。一旦成熟,checkpoint 时间可从秒级降到毫秒级,碎片整理的"禁区"将大幅缩小。
碎片化不是 bug,是资源调度系统的固有熵增。优秀的调度器不是在消除碎片,而是在碎片产生和整理之间找到成本平衡点——这正是系统工程的艺术。

发表评论 取消回复