GPU 资源调度:AI 集群算力管理的核心引擎

cover

一、GPU 算力碎片化与利用率困境:AI 基础设施的核心痛点

在 AI 集群中,GPU 是最昂贵也最稀缺的资源。一张 A100-80G 的月租成本约 1.5 万元,一个 8 卡节点的年成本超过 140 万元。然而,生产环境中 GPU 的平均利用率往往只有 30%-50%。算力碎片化的根源在于:不同任务对 GPU 资源的需求差异极大——训练任务需要多卡并行且长时间占用,推理任务需要低延迟响应但单次占用时间短,数据处理任务只需要少量 GPU 内存。

传统的静态分配方式(一台机器绑定一个任务)导致大量 GPU 资源在任务空闲时被浪费。GPU 资源调度的核心目标,是在满足各类任务 SLA 的前提下,最大化 GPU 利用率,降低单位算力成本。

二、GPU 资源调度机制:从时间片共享到多实例切分

GPU 资源调度需要在空间共享(多任务分时复用同一 GPU)和时间共享(任务排队等待 GPU 空闲)两个维度上协同设计。下图展示了 GPU 资源调度的分层架构:

flowchart TB
    subgraph 任务提交层
        Train[训练任务 高优/长时]
        Infer[推理服务 在线/低延迟]
        Batch[批处理任务 低优/弹性]
    end

    subgraph 调度引擎层
        Queue[优先级队列]
        Scheduler[调度器 Bin-Packing/DRF]
        Planner[容量规划 GPU拓扑感知]
    end

    subgraph GPU 切分层
        MIG[MIG 实例切分 A100]
        MPS[MPS 多进程共享]
        TimeSlice[时间片轮转]
        VGPU[vGPU 虚拟化]
    end

    subgraph 物理GPU层
        GPU1[GPU 0]
        GPU2[GPU 1]
        GPU3[GPU 2]
        GPU4[GPU 3]
    end

    Train --> Queue
    Infer --> Queue
    Batch --> Queue
    Queue --> Scheduler
    Scheduler --> Planner
    Planner --> MIG
    Planner --> MPS
    Planner --> TimeSlice
    Planner --> VGPU
    MIG --> GPU1
    MPS --> GPU2
    TimeSlice --> GPU3
    VGPU --> GPU4

    style Scheduler fill:#ff9,stroke:#333
    style MIG fill:#9ff,stroke:#333
    style MPS fill:#9f9,stroke:#333

2.1 MIG(Multi-Instance GPU):A100 的硬件级切分

MIG 是 NVIDIA 从 A100 开始引入的硬件级 GPU 切分技术。一张 A100 可以被切分为最多 7 个独立实例,每个实例拥有独立的 SM、L2 Cache 和显存带宽,实例之间硬件级隔离,互不影响。MIG 适合推理服务场景——每个推理实例分配一个 MIG 切片,既保证隔离性,又避免整卡浪费。

2.2 MPS(Multi-Process Service):软件级空间共享

MPS 允许多个 CUDA 进程共享同一 GPU 的计算单元,通过协作式调度减少上下文切换开销。MPS 适合计算密集但显存占用不高的任务,但缺乏隔离性——一个进程的异常可能影响其他进程。

2.3 时间片轮转:K8s 设备插件的默认策略

K8s 的 NVIDIA Device Plugin 默认以整卡为调度单位。通过 time-slicing 配置,可以将一张 GPU 虚拟为多个设备,以时间片方式分配给不同 Pod。这种方式实现简单,但存在上下文切换开销,且无法保证隔离性。

三、生产级 GPU 资源调度实现

3.1 基于 K8s 的 GPU 调度器扩展

# GPU 时间片配置——NVIDIA Device Plugin
# 为什么用时间片而非 MIG?
# 因为 MIG 只支持 A100/H100 等少数架构,
# 时间片方案兼容所有 GPU 型号,适合异构集群
apiVersion: v1
kind: ConfigMap
metadata:
  name: nvidia-device-plugin-config
  namespace: gpu-operator
data:
  config.yaml: |
    version: v1
    flags:
      migStrategy: none
      failOnInitError: true
    sharing:
      timeSlicing:
        renameByDefault: false
        resources:
          - name: nvidia.com/gpu
            replicas: 4  # 每张 GPU 虚拟为 4 个设备
/**
 * GPU 感知调度器——扩展 K8s 调度框架
 * 为什么需要自定义调度器而非用默认调度?
 * 因为默认调度器只看 GPU 数量,不看 GPU 利用率和拓扑关系,
 * 可能将通信密集的训练任务调度到跨 NUMA 节点的 GPU 上,
 * 导致 NVLink 通信退化为 PCIe 通信,性能下降 50% 以上
 */
package scheduler

import (
	"context"
	"fmt"
	"k8s.io/klog/v2"
	framework "k8s.io/kubernetes/pkg/scheduler/framework"
)

type GPUScheduler struct {
	handle framework.Handle
}

func (g *GPUScheduler) Score(ctx context.Context, state *framework.CycleState,
	pod *v1.Pod, nodeInfo *framework.NodeInfo) (int64, *framework.Status) {

	gpuAllocatable := nodeInfo.Allocatable.ScalarResources["nvidia.com/gpu"]
	gpuRequested := nodeInfo.Requested.ScalarResources["nvidia.com/gpu"]

	if gpuAllocatable == 0 {
		return 0, nil
	}

	// 计算当前节点 GPU 利用率
	// 为什么用利用率而非剩余数量做打分?
	// 因为 Bin-Packing 策略优先填满已有节点的 GPU,
	// 避免将任务分散到多个节点导致碎片化
	utilization := float64(gpuRequested) / float64(gpuAllocatable)

	// 利用率越高的节点得分越高(Bin-Packing 策略)
	score := int64(utilization * 100)

	// 拓扑感知加分:如果 Pod 需要多卡且节点有 NVLink
	if requiresMultiGPU(pod) && hasNVLink(nodeInfo.Node()) {
		score += 20
		klog.V(4).Infof("节点 %s 有 NVLink,拓扑加分",
			nodeInfo.Node().Name)
	}

	// 推理服务优先调度到 MIG 实例
	if isInferenceService(pod) && hasMIGInstances(nodeInfo.Node()) {
		score += 15
	}

	return min(score, 100), nil
}

func (g *GPUScheduler) ScoreExtensions() framework.ScoreExtensions {
	return nil
}

// 判断 Pod 是否需要多卡并行
func requiresMultiGPU(pod *v1.Pod) bool {
	gpuCount := int64(0)
	for _, container := range pod.Spec.Containers {
		if limit, ok := container.Resources.Limits["nvidia.com/gpu"]; ok {
			gpuCount += limit.Value()
		}
	}
	return gpuCount > 1
}

3.2 GPU 利用率监控与弹性调度

"""
GPU 利用率监控与弹性调度控制器
为什么需要弹性调度?
因为推理服务的流量存在明显波峰波谷,
固定分配 GPU 在低峰期浪费算力,弹性调度可自动缩容释放资源
"""
import subprocess
import json
from dataclasses import dataclass
from typing import List

@dataclass
class GPUMetrics:
    gpu_id: int
    utilization: float    # 计算利用率 0-100
    memory_used_mb: int
    memory_total_mb: int
    temperature: int
    running_processes: int

class GPUMonitor:
    def __init__(self):
        self.metrics_history: List[List[GPUMetrics]] = []

    def collect(self) -> List[GPUMetrics]:
        """采集 GPU 指标"""
        try:
            result = subprocess.run(
                ["nvidia-smi",
                 "--query-gpu=index,utilization.gpu,memory.used,"
                 "memory.total,temperature.gpu",
                 "--query-compute-apps=gpu_uuid,pid",
                 "--format=csv,noheader,nounits"],
                capture_output=True, text=True, timeout=10
            )
            return self._parse_metrics(result.stdout)
        except Exception as e:
            # 采集失败时返回空列表,调度器将使用历史数据
            return []

    def get_low_utilization_gpus(self,
                                  threshold: float = 30.0,
                                  window_minutes: int = 15
                                  ) -> List[int]:
        """识别持续低利用率的 GPU"""
        if len(self.metrics_history) < window_minutes:
            return []

        # 取最近 N 分钟的平均利用率
        # 为什么用滑动窗口平均而非瞬时值?
        # 因为 GPU 利用率天然波动大,瞬时值可能导致误判,
        # 滑动窗口平均能过滤短期波动,识别真正的空闲 GPU
        recent = self.metrics_history[-window_minutes:]
        avg_util = {}
        for snapshot in recent:
            for m in snapshot:
                avg_util.setdefault(m.gpu_id, [])
                avg_util[m.gpu_id].append(m.utilization)

        low_util_gpus = []
        for gpu_id, utils in avg_util.items():
            if sum(utils) / len(utils) < threshold:
                low_util_gpus.append(gpu_id)

        return low_util_gpus


class ElasticScheduler:
    """弹性调度控制器:根据 GPU 利用率自动扩缩容"""

    def __init__(self, monitor: GPUMonitor,
                 min_replicas: int = 1,
                 max_replicas: int = 8):
        self.monitor = monitor
        self.min_replicas = min_replicas
        self.max_replicas = max_replicas
        self.current_replicas = min_replicas

    def reconcile(self):
        """调度循环:每分钟执行一次"""
        self.monitor.metrics_history.append(self.monitor.collect())

        low_gpus = self.monitor.get_low_utilization_gpus(
            threshold=30.0, window_minutes=15)

        # 如果超过 50% 的 GPU 持续低利用率,缩容
        total_gpus = self.current_replicas
        if len(low_gpus) > total_gpus * 0.5:
            new_replicas = max(self.min_replicas,
                               self.current_replicas - 1)
            if new_replicas < self.current_replicas:
                self._scale_down(new_replicas)
                return

        # 如果所有 GPU 利用率超过 80%,扩容
        all_high = all(
            m.utilization > 80
            for snapshot in self.monitor.metrics_history[-5:]
            for m in snapshot
        )
        if all_high:
            new_replicas = min(self.max_replicas,
                               self.current_replicas + 1)
            if new_replicas > self.current_replicas:
                self._scale_up(new_replicas)

    def _scale_down(self, target: int):
        """缩容:优先释放低利用率 GPU 上的推理实例"""
        # 为什么缩容而非直接回收 GPU?
        # 因为推理服务需要优雅停机,直接回收会导致进行中的请求失败
        self.current_replicas = target

    def _scale_up(self, target: int):
        """扩容:从资源池中分配新 GPU"""
        self.current_replicas = target

3.3 训练与推理混部:分时复用 GPU

# K8s Pod 配置——训练与推理混部
# 为什么训练和推理可以混部?
# 因为训练任务通常在凌晨跑,推理服务在白天高峰,
# 两者在时间上天然互补,混部可将 GPU 利用率从 40% 提升至 80%
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inference-service
  labels:
    app: inference
    priority: high
spec:
  replicas: 4
  template:
    spec:
      containers:
        - name: inference
          image: inference-server:v2
          resources:
            limits:
              nvidia.com/gpu: 1
              memory: "16Gi"
            requests:
              nvidia.com/gpu: 1
              memory: "8Gi"
      # 低优先级:训练任务到来时可被抢占
      # 为什么推理服务设置低优先级?
      # 不是因为推理不重要,而是因为推理可以快速迁移到其他节点,
      # 而训练任务的检查点保存耗时较长,抢占代价更高
      priorityClassName: preemptible-inference
---
# 训练任务——使用 K8s Job
apiVersion: batch/v1
kind: Job
metadata:
  name: model-training
spec:
  template:
    spec:
      containers:
        - name: trainer
          image: training-worker:v2
          resources:
            limits:
              nvidia.com/gpu: 4  # 多卡训练
              memory: "64Gi"
      priorityClassName: high-priority-training
      restartPolicy: OnFailure

四、架构权衡:GPU 调度方案的代价与边界

MIG 切分的代价:MIG 切分后每个实例的计算能力和显存带宽被固定,无法动态调整。A100 切分为 7 个实例后,每个实例只有约 1/7 的算力,对于需要高吞吐的推理场景可能不够。MIG 切分和重组需要重启 GPU 上的所有任务,无法在线调整。

时间片的代价:时间片轮转存在上下文切换开销,每次切换需要保存和恢复 GPU 状态,约增加 5%-15% 的性能损耗。多个任务共享 GPU 时,一个计算密集型任务可能抢占大部分时间片,导致其他任务延迟不稳定。

弹性调度的代价:弹性缩容需要优雅迁移推理实例,迁移过程中该实例无法服务,可能导致短暂的服务降级。扩容需要预热模型(加载权重到 GPU),对于 70B 模型预热时间可能超过 2 分钟,无法应对突发流量。

混部的代价:训练任务抢占推理实例时,推理请求会短暂失败。需要配合负载均衡和重试机制,确保请求被路由到其他可用实例。混部增加了集群调度的复杂度,调度延迟可能增加 30%-50%。

适用边界:GPU 调度优化适用于拥有 10 张以上 GPU 的 AI 集群。对于小规模集群(3-5 张 GPU),静态分配 + 手动调度更简单可靠。调度优化的收益与集群规模正相关,规模越大碎片化越严重,优化收益越显著。

五、总结

GPU 资源调度的核心目标是最大化算力利用率、降低单位成本。三个关键策略:MIG 硬件切分实现推理服务的精细隔离、弹性调度根据利用率自动扩缩容、训练与推理混部分时复用 GPU。落地路线上,建议先建立 GPU 利用率监控体系,识别碎片化严重的节点;再根据 GPU 型号选择切分方案(A100/H100 用 MIG,其他用时间片);最后引入弹性调度和混部策略进一步提升利用率。GPU 调度不是一次性配置,而是需要持续监控和调优的动态过程。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐