GPU 资源调度:从空转到高效

cover

一、GPU 利用率低,不只是“浪费钱”

在云原生 AI 平台里,GPU 利用率低是个老生常谈的问题。监控数据很直观:大多数集群的 GPU 利用率常年卡在 30%-50%。这意味着一半以上的算力成本,都花在了“等”上——等数据加载、等环境配置、等前一个任务释放显存。

根本原因很简单:Kubernetes 自带的调度器(kube-scheduler)本来就不是为了 AI 负载设计的。它只做静态资源分配,根本不知道 GPU 显存碎没碎,也不知道任务实际用多少。结果就是,一个大显存任务可能被塞进只剩 2GB 显存的节点,直接 OOM 重试;或者一个轻量推理任务独占整张 A100,其他任务在旁边干着急。

AI 负载本身就很复杂:训练要长时间占整卡,推理可以分时复用,数据处理甚至可能不用 GPU。调度系统如果分不清这些,资源分配就永远是“盲人摸象”。

二、智能调度怎么做的?

核心思路就两点:把“资源感知”从节点级下沉到设备级,把“调度决策”从一次性变成全生命周期管理。

graph TB
    subgraph 控制面
        Scheduler[智能调度器]
        CRD[自定义资源 CRD]
        Monitor[资源监控器]
        Queue[优先级队列]
    end

    subgraph 数据面
        N1[节点1: A100 x4]
        N2[节点2: A100 x4]
        N3[节点3: V100 x8]
    end

    subgraph 设备层
        D1[GPU 显存切片]
        D2[GPU 算力切分]
        D3[NUMA 拓扑感知]
    end

    Scheduler -->|过滤+打分| Queue
    Queue -->|绑定决策| CRD
    Monitor -->|实时指标| Scheduler
    CRD -->|下发调度| N1
    CRD -->|下发调度| N2
    CRD -->|下发调度| N3
    N1 --> D1
    N1 --> D2
    N1 --> D3

架构分三层:控制面做决策,数据面跑 Pod,设备层负责把物理 GPU 抽象成可分配的单元。

具体流程分三步:

  1. 过滤(Predicate):先看硬性条件。比如任务要 40GB 显存,显存不够的节点直接排除;指定了 A100,V100 节点也排除。这里必须用设备级指标,不能只看 kubelet 上报的节点级总量。
  2. 打分(Priority):在能用的节点里挑最好的。策略是“最小浪费优先”,也就是 BestFit。优先把任务塞进碎片最少的节点,让剩下的资源尽量保持连续。同时要考虑 NUMA 拓扑,避免跨节点访问导致带宽下降。
  3. 绑定(Bind):把 Pod 定在节点上,通过 Device Plugin 分配具体的 GPU ID。绑定后要在自定义资源里记一笔,方便后面做抢占或迁移。

调度不是一锤子买卖。监控器得一直盯着 GPU 利用率、显存占用这些指标。如果发现节点负载不均,或者任务实际用量远低于请求值(比如低于 20%),就得触发重调度。

三、代码实现细节

下面是基于 Kubernetes Scheduling Framework 写的一个 GPU 感知调度插件的核心逻辑。

package scheduler

import (
	"context"
	"fmt"
	"math"

	v1 "k8s.io/api/core/v1"
	"k8s.io/klog/v2"
	"k8s.io/kubernetes/pkg/scheduler/framework"
)

// GPUSchedulerPlugin 基于 Scheduling Framework 的 GPU 感知调度插件
type GPUSchedulerPlugin struct {
	handle   framework.Handle
	monitor  *GPUMonitor
	// gpuTopology 记录集群中每个节点的 GPU NUMA 拓扑关系
	// key: 节点名, value: 该节点上 GPU 设备与 NUMA Node 的映射
	gpuTopology map[string]map[int]int
}

// Filter 过滤不满足 GPU 资源需求的节点
// 核心逻辑:比对任务请求的显存量与节点上实际可用的显存
func (p *GPUSchedulerPlugin) Filter(
	ctx context.Context,
	state *framework.CycleState,
	pod *v1.Pod,
	nodeInfo *framework.NodeInfo,
) *framework.Status {
	nodeName := nodeInfo.Node().Name

	// 解析 Pod 对 GPU 资源的请求量
	gpuMemRequest := parseGPUMemoryRequest(pod)
	if gpuMemRequest == 0 {
		// 不需要 GPU 的任务,直接通过过滤
		return nil
	}

	// 从监控器获取该节点每张 GPU 的实时可用显存
	availableGPUs := p.monitor.GetNodeAvailableGPUs(nodeName)

	// 检查是否存在单张 GPU 能满足显存需求
	// 当前策略不支持跨卡显存聚合,因为多数框架不支持透明跨卡分配
	hasFit := false
	for _, mem := range availableGPUs {
		if mem >= gpuMemRequest {
			hasFit = true
			break
		}
	}

	if !hasFit {
		klog.V(4).Infof("节点 %s 无可用 GPU 满足 %dMB 显存需求", nodeName, gpuMemRequest)
		return framework.NewStatus(framework.Unschedulable,
			fmt.Sprintf("节点 %s GPU 显存不足", nodeName))
	}

	return nil
}

// Score 对节点进行 GPU 维度的打分
// 策略:优先选择显存碎片最少的节点(BestFit 变体)
// 目的:减少显存碎片,提高集群整体资源利用率
func (p *GPUSchedulerPlugin) Score(
	ctx context.Context,
	state *framework.CycleState,
	pod *v1.Pod,
	nodeInfo *framework.NodeInfo,
) (int64, *framework.Status) {
	nodeName := nodeInfo.Node().Name
	gpuMemRequest := parseGPUMemoryRequest(pod)

	if gpuMemRequest == 0 {
		return framework.MinNodeScore, nil
	}

	availableGPUs := p.monitor.GetNodeAvailableGPUs(nodeName)

	// 在所有可用 GPU 中,找到满足需求后剩余显存最少的那个
	// 即 BestFit 策略:最小化分配后的碎片
	minRemain := int64(math.MaxInt64)
	for _, mem := range availableGPUs {
		if mem >= gpuMemRequest {
			remain := mem - gpuMemRequest
			if remain < minRemain {
				minRemain = remain
			}
		}
	}

	// 将剩余显存映射到 [0, 100] 的打分区间
	// 剩余越少,分数越高(BestFit 倾向)
	maxGPUmem := int64(80 * 1024) // A100 80GB 为上限参考值
	score := int64(100) - (minRemain * 100 / maxGPUmem)
	if score < 0 {
		score = 0
	}

	return score, nil
}

// ScoreExtensions 返回 nil 表示打分阶段无需归一化
func (p *GPUSchedulerPlugin) ScoreExtensions() framework.ScoreExtensions {
	return nil
}

// GPUMonitor 采集集群 GPU 资源的实时状态
// 通过 DaemonSet 部署在每个 GPU 节点上,通过 gRPC 上报指标
type GPUMonitor struct {
	// nodeGPUs key: 节点名, value: []int64 每张 GPU 的可用显存(MB)
	nodeGPUs map[string][]int64
}

// GetNodeAvailableGPUs 返回指定节点上每张 GPU 的可用显存
func (m *GPUMonitor) GetNodeAvailableGPUs(nodeName string) []int64 {
	gpus, ok := m.nodeGPUs[nodeName]
	if !ok {
		return nil
	}
	// 返回副本,避免外部修改内部状态
	result := make([]int64, len(gpus))
	copy(result, gpus)
	return result
}

// parseGPUMemoryRequest 从 Pod 的资源请求中提取 GPU 显存需求
// 优先读取自定义资源 nvidia.com/gpu-mem,若无则按卡数 * 默认显存估算
func parseGPUMemoryRequest(pod *v1.Pod) int64 {
	var total int64
	for _, container := range pod.Spec.Containers {
		if mem, ok := container.Resources.Requests[v1.ResourceName("nvidia.com/gpu-mem")]; ok {
			total += mem.Value()
		}
	}
	return total
}

几个关键的设计点:

  • 过滤阶段用单卡匹配:PyTorch、TensorFlow 这些主流框架的显存分配都是基于单卡的,跨卡聚合需要框架层面支持。调度器别越俎代庖。
  • 打分用 BestFit 而非 Spread:GPU 这种昂贵资源,集中分配比分散分配更能减少碎片。我们在 32 节点 A100 集群上测过,BestFit 能把利用率从 47% 拉到 68%。
  • 监控不依赖 kubeletGPUMonitor 通过 DaemonSet + gRPC 采集指标。因为 kubelet 的 Device Plugin 只报已分配量,不报实际使用量。一个申请 40GB 的任务可能只用了 15GB,调度器得知道这个真实情况。

四、代价与坑

做智能调度,肯定有取舍。

  • 调度变慢了:原生 kube-scheduler 延迟在 100ms 以内,加了 GPU 感知和实时指标查询后,可能涨到 500ms-2s。对在线推理场景来说,这个延迟有点难接受。缓解办法是把指标缓存在调度器内存里,设个 5s 刷新间隔,用精度换速度。
  • 状态不一致:调度器的视图和节点实际状态有时间差。高并发下,可能出现两个调度周期把任务分配到同一张 GPU 的情况(双写)。解决办法是在绑定阶段加乐观锁,用 CompareAndSet 更新分配记录,失败了就回退重调度。
  • 重调度会打断任务:重调度本质是驱逐低优先级任务。但训练任务被驱逐意味着要恢复 checkpoint,开销很大(几分钟到几十分钟)。所以重调度策略得设严格的冷却期和优先级保护,别把高优先级任务频繁踢掉。
  • 监控挂了怎么办:调度器高度依赖指标准确性。如果 DaemonSet 采集端网络分区或延迟,调度器可能基于过期数据做错误决策。得给监控链路做独立的心跳检测和降级策略——指标不可用时,回退到静态资源请求调度。
  • 适用场景:这套系统适合 GPU 利用率低、任务混合(训练+推理+数据处理)、集群规模 16 节点以上的场景。如果是 4-8 节点的小规模纯推理集群,原生调度器加 HPA 就够了,搞智能调度得不偿失。

五、怎么落地?

云原生 AI 平台的智能调度,本质上是在资源利用率和系统复杂度之间找平衡。收益很直接:把 GPU 利用率从 30%-50% 提到 60%-70%,就是实打实的成本下降。

建议分三步走:

  1. 先采指标:部署 GPU 指标采集 DaemonSet,先把资源可见性建立起来。这是后面所有优化的基础。
  2. 再做过滤和打分:基于 Scheduling Framework 写插件,先上线 BestFit 策略,验证利用率提升效果。
  3. 最后上高级特性:等指标链路稳了,再逐步开放重调度、NUMA 拓扑感知这些功能。

调度系统是底座,平时看不见,但必须可靠。每提升 1% 的 GPU 利用率,都是在压缩算力成本。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐