异构计算碎片化困局: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 内核的伙伴系统碎片整理思想,但做了两点关键改进:

  1. 优先级感知:开发调试任务默认可被抢占,训练任务需配置容忍窗口,在线推理任务只在碎片率>30%时才参与整理。
  2. 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%
日均排队任务数4712-74%
长上下文推理 P99 延迟142ms118ms-17%
开发环境冷启动等待时间23min4min-83%
非计划性 GPU OOM Kill 次数/天283-89%
批量推理任务完成时间3.2h2.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,是资源调度系统的固有熵增。优秀的调度器不是在消除碎片,而是在碎片产生和整理之间找到成本平衡点——这正是系统工程的艺术。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部