云原生 AI 平台:Kubernetes 智能调度器如何让 GPU 利用率翻倍

云原生 AI 平台:Kubernetes 智能调度器如何让 GPU 利用率翻倍

一、GPU 空转与排队并存:AI 集群的调度困境

在 AI 训练集群中,一个普遍的矛盾是:部分 GPU 节点利用率不足 30%,而训练任务却在队列中排队等待。造成这种"空转与排队并存"的根本原因,是 Kubernetes 默认调度器对 GPU 资源的粗粒度管理------它只关心"这个节点有没有空闲 GPU",却不知道"这个 GPU 还剩多少显存"或"这个任务需要多少算力"。

当多个训练任务共享同一张 GPU 时,默认调度器无法感知显存碎片,导致本可以并行的任务被串行调度。更棘手的是,推理服务和训练任务混部时,推理的延迟敏感性和训练的吞吐优先级之间缺乏协调机制,结果要么推理延迟飙升,要么训练资源浪费。

本文将深入剖析 Kubernetes 调度器扩展机制,并给出基于自定义调度器实现 GPU 细粒度调度的工程方案。

二、Kubernetes 调度器扩展:从默认调度到智能调度

2.1 默认调度器的局限

Kubernetes 默认调度器基于 predicates 和 priorities 两阶段工作:先过滤满足条件的节点,再按优先级排序选择。对于 GPU 资源,它只识别 nvidia.com/gpu 这类整数型扩展资源,无法表达"这张 GPU 还剩 12GB 显存"这类细粒度信息。

flowchart TD A[Pod 提交到调度队列] --> B[Filter 阶段:过滤不满足条件的节点] B --> C[Score 阶段:对候选节点打分排序] C --> D[选择最高分节点绑定 Pod] D --> E{绑定成功?} E -- 是 --> F[Pod 开始运行] E -- 否 --> G[重新进入调度队列] subgraph 默认调度器的GPU局限 H[只识别整数型 GPU 资源] I[无法感知显存碎片] J[无法区分任务优先级] K[不支持 GPU 时间片共享] end B -.-> H C -.-> I C -.-> J D -.-> K

2.2 调度器扩展框架 Scheduling Framework

Kubernetes 1.19 引入的 Scheduling Framework 允许在调度流水线的各个阶段插入自定义插件:

扩展点 作用 GPU 调度应用
PreFilter 预处理 Pod 信息 解析 GPU 需求(显存、算力)
Filter 过滤不满足条件的节点 检查节点 GPU 显存余量
PostFilter Filter 后的补充处理 处理抢占逻辑
Score 对候选节点打分 按 GPU 碎片率、亲和性打分
Bind 将 Pod 绑定到节点 执行 GPU 资源预留
Reserve 资源预留成功回调 更新 GPU 显存分配表

三、GPU 细粒度调度器的工程实现

3.1 节点 GPU 状态上报

通过 Device Plugin 和自定义指标,将每个节点的 GPU 状态上报到 API Server:

go 复制代码
package gpucheck

import (
	"context"
	"fmt"
	"time"

	v1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/klog/v2"
)

// GPUNodeState 记录单个节点的 GPU 资源状态
type GPUNodeState struct {
	NodeName    string
	GPUDevices  []GPUDevice
	TotalMemory int64  // 总显存(MB)
	UsedMemory  int64  // 已用显存(MB)
}

// GPUDevice 描述单张 GPU 的详细状态
type GPUDevice struct {
	Index       int
	UUID        string
	TotalMemory int64   // 总显存(MB)
	UsedMemory  int64   // 已用显存(MB)
	Utilization float64 // GPU 利用率(0-100)
}

// GPUStateCollector 周期性采集节点 GPU 状态
type GPUStateCollector struct {
	clientset  kubernetes.Interface
	nodeName   string
	updateFreq time.Duration
	stateCache map[string]*GPUNodeState
}

func NewGPUStateCollector(clientset kubernetes.Interface, nodeName string) *GPUStateCollector {
	return &GPUStateCollector{
		clientset:  clientset,
		nodeName:   nodeName,
		updateFreq: 10 * time.Second,
		stateCache: make(map[string]*GPUNodeState),
	}
}

// collectLocalGPU 采集本节点 GPU 状态(通过 nvidia-smi 或 DCGM)
func (c *GPUStateCollector) collectLocalGPU() (*GPUNodeState, error) {
	// 实际实现通过 nvidia-smi 或 DCGM Exporter 获取数据
	// 此处展示数据结构和上报逻辑
	state := &GPUNodeState{
		NodeName: c.nodeName,
		GPUDevices: []GPUDevice{
			{Index: 0, UUID: "GPU-xxx-0", TotalMemory: 24576, UsedMemory: 8192, Utilization: 35.2},
			{Index: 1, UUID: "GPU-xxx-1", TotalMemory: 24576, UsedMemory: 20480, Utilization: 88.7},
		},
	}
	for _, dev := range state.GPUDevices {
		state.TotalMemory += dev.TotalMemory
		state.UsedMemory += dev.UsedMemory
	}
	return state, nil
}

// reportToAPIServer 将 GPU 状态写入 Node 的 Annotations 和 CM
func (c *GPUStateCollector) reportToAPIServer(state *GPUNodeState) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	node, err := c.clientset.CoreV1().Nodes().Get(ctx, state.NodeName, metav1.GetOptions{})
	if err != nil {
		return fmt.Errorf("获取节点信息失败: %w", err)
	}

	if node.Annotations == nil {
		node.Annotations = make(map[string]string)
	}

	// 将 GPU 状态编码为 Annotation,供调度器读取
	node.Annotations["gpu-scheduler/memory-used"] = fmt.Sprintf("%d", state.UsedMemory)
	node.Annotations["gpu-scheduler/memory-total"] = fmt.Sprintf("%d", state.TotalMemory)
	node.Annotations["gpu-scheduler/updated-at"] = time.Now().Format(time.RFC3339)

	_, err = c.clientset.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{})
	if err != nil {
		return fmt.Errorf("更新节点 Annotation 失败: %w", err)
	}

	klog.V(4).Infof("节点 %s GPU 状态已上报: 已用 %dMB / 总计 %dMB",
		state.NodeName, state.UsedMemory, state.TotalMemory)
	return nil
}

3.2 自定义 Score 插件:按碎片率打分

go 复制代码
package scheduler

import (
	"context"
	"fmt"

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

const Name = "GPUScorePlugin"

type GPUScorePlugin struct {
	handle framework.Handle
}

func New(ctx context.Context, _ interface{}, handle framework.Handle) (framework.Plugin, error) {
	return &GPUScorePlugin{handle: handle}, nil
}

func (p *GPUScorePlugin) Name() string { return Name }

// Score 根据 GPU 碎片率和亲和性对节点打分
func (p *GPUScorePlugin) Score(
	ctx context.Context,
	state *framework.CycleState,
	pod *v1.Pod,
	nodeName string,
) (int64, *framework.Status) {
	nodeInfo, err := p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
	if err != nil {
		return 0, framework.NewStatus(framework.Error, fmt.Sprintf("获取节点信息失败: %v", err))
	}

	node := nodeInfo.Node()
	if node == nil {
		return 0, framework.NewStatus(framework.Error, "节点对象为空")
	}

	// 读取节点 GPU 状态 Annotation
	usedStr := node.Annotations["gpu-scheduler/memory-used"]
	totalStr := node.Annotations["gpu-scheduler/memory-total"]
	if usedStr == "" || totalStr == "" {
		// 无 GPU 状态信息,返回默认分数
		return 50, nil
	}

	// 计算碎片率:已用显存比例越接近 0 或 1,碎片越少
	// 碎片率 = min(usage, 1-usage) * 2,越低越好
	var used, total int64
	fmt.Sscanf(usedStr, "%d", &used)
	fmt.Sscanf(totalStr, "%d", &total)

	if total == 0 {
		return 0, nil
	}

	usage := float64(used) / float64(total)
	fragmentation := 1.0 - 2.0*min(usage, 1.0-usage)

	// 将碎片率映射为 0-100 的分数,碎片越少分数越高
	score := int64((1.0 - fragmentation) * 100)
	return score, nil
}

func (p *GPUScorePlugin) ScoreExtensions() framework.ScoreExtensions {
	return nil
}

func min(a, b float64) float64 {
	if a < b {
		return a
	}
	return b
}
flowchart LR A[训练任务 Pod] --> B[PreFilter:解析 GPU 需求] B --> C[Filter:检查节点显存余量] C --> D[Score:按碎片率打分] D --> E[Reserve:预留 GPU 显存] E --> F[Bind:绑定到最优节点] F --> G[GPU 状态更新:扣减已分配显存] H[推理服务 Pod] --> B I[混部场景:推理优先级 > 训练] --> D

四、GPU 调度的架构权衡与边界条件

4.1 调度延迟与资源利用率的矛盾

自定义调度器需要从 API Server 读取节点 GPU 状态,这引入了额外的网络延迟。在高频调度场景下(每秒数十个 Pod),调度延迟可能从默认的 50ms 增加到 200ms 以上。缓存 GPU 状态可以降低延迟,但缓存与实际状态之间的不一致可能导致调度决策错误。

4.2 显存碎片化的治理成本

细粒度显存分配虽然提高了利用率,但也加剧了碎片化。当多个小任务释放显存后,可能无法腾出连续的大块显存给大模型训练任务。定期执行显存整理(类似 JVM 的 GC)可以缓解,但整理期间节点不可调度,影响集群吞吐。

4.3 多租户场景的公平性

GPU 优先级调度容易导致低优先级任务长期饥饿。需要引入公平调度策略(如 DRF),但公平调度与效率最大化之间存在根本冲突------让每个租户都满意,往往意味着整体利用率不是最优。

4.4 调度器扩展的维护成本

自定义调度器插件需要与 Kubernetes 版本保持同步升级。Scheduling Framework 的接口在不同版本间可能发生变化,每次 K8s 升级都需要验证插件兼容性。对于小团队,这种维护成本可能超过 GPU 利用率提升带来的收益。

五、总结

Kubernetes 默认调度器对 GPU 资源的管理停留在整数级别,无法满足 AI 集群对显存细粒度分配和任务优先级协调的需求。通过 Scheduling Framework 扩展,可以实现基于显存余量的 Filter、基于碎片率的 Score、以及基于优先级的抢占调度。

工程落地的关键决策:GPU 状态上报选择 Device Plugin + Annotation 方案,兼顾实时性和实现简洁性;Score 策略优先考虑碎片率而非简单负载均衡,减少显存碎片;抢占策略需要设置优先级阈值,避免低优先级任务无限饥饿;调度器插件必须做好版本兼容性测试,建议与 K8s 发布周期对齐升级。

对于 GPU 规模在 50 张以下的团队,优先使用现成的调度器扩展(如 Volcano、YuniKorn),而非自研。只有当现有方案无法满足业务需求时,才投入自研调度器的成本。

相关推荐
张飞飞飞飞飞1 小时前
目标检测-根据YOLO格式标签统计目标尺寸分布
人工智能·yolo·目标检测
翼达口香糖1 小时前
在普通笔记本上加速大模型:我的OpenVINO异构计算实践
人工智能·边缘计算
Rocky Ding*1 小时前
Token Merging for Fast Stable Diffusion:一篇读懂 Stable Diffusion 的免训练加速机制
论文阅读·人工智能·深度学习·机器学习·stable diffusion·aigc·ai-native
虾壳云官方1 小时前
【一步到位】OpenClaw 2.7.9 Windows 部署 + 激活 + 使用 (含安装包)
人工智能·windows·自动化·openclaw·小龙虾·openclaw安装·openclaw一键安装
椒颜皮皮虾྅1 小时前
OpenVINO™ C# API 3.3 全新发布!正式接入 OpenVINO GenAI,C# 本地大模型开发全面启航!
人工智能·开源·c#·openvino
我认不到你1 小时前
【开源、教程】RAG全流程实现(java+完整代码):第一弹
java·开发语言·人工智能·深度学习·ai·语言模型·开源
羊羊小栈1 小时前
基于GraphRAG的地质矿产知识管理系统(Neo4j_大语言模型)
人工智能·语言模型·自然语言处理·毕业设计·neo4j·大作业
JAMSAN09301 小时前
AI服务器MLCC:从“电子大米”到“算力石油”的价值重估
运维·人工智能·数据分析·智能硬件
xyz_CDragon1 小时前
把旧电脑变成AI算力:llama.cpp RPC 局域网分布式推理验证与实战
人工智能·分布式·python·rpc·llama