
云原生 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 -.- K2.2 调度器扩展框架 Scheduling FrameworkKubernetes 1.19 引入的 Scheduling Framework 允许在调度流水线的各个阶段插入自定义插件扩展点作用GPU 调度应用PreFilter预处理 Pod 信息解析 GPU 需求显存、算力Filter过滤不满足条件的节点检查节点 GPU 显存余量PostFilterFilter 后的补充处理处理抢占逻辑Score对候选节点打分按 GPU 碎片率、亲和性打分Bind将 Pod 绑定到节点执行 GPU 资源预留Reserve资源预留成功回调更新 GPU 显存分配表三、GPU 细粒度调度器的工程实现3.1 节点 GPU 状态上报通过 Device Plugin 和自定义指标将每个节点的 GPU 状态上报到 API Serverpackage 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 插件按碎片率打分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而非自研。只有当现有方案无法满足业务需求时才投入自研调度器的成本。