GPU 调度:从资源碎片到显存感知
GPU 调度从资源碎片到显存感知一、为什么集群有空闲 GPU任务却跑不起来在云原生环境里跑大模型推理最常遇到的问题是 GPU 资源碎片化。集群明明还有空闲显存新提交的推理任务却分配不到 GPU。原因很简单Kubernetes 原生调度器只看 Pod 请求了多少 GPU不关心这些 GPU 实际用没用到。举个例子一个 8 卡 A100 节点上跑了 4 个推理服务每个请求 2 块 GPU。但实际运行时每个服务只用了 40% 显存和 30% 算力。这时新提交一个需要 1 块 GPU 的任务调度器判定节点已满任务被挂起。这就是所谓的调度黑洞——资源被占着但没用新任务排不上。大模型推理的负载还有明显的波峰波谷。白天高峰期需要大量 GPU凌晨低谷期大量 GPU 空转。如果缺乏弹性伸缩和智能调度GPU 机器利用率往往低于 30%但账单不会打折。问题的根源在于Kubernetes 默认调度器对 GPU 这类异构加速卡缺乏细粒度感知。要解决它需要在调度层引入 GPU 共享、显存感知和负载感知。二、智能调度机制拆解Kubernetes 原生调度流程是Filter过滤可行节点→ Score打分排序→ Bind绑定节点。对于 GPU 调度原生行为在 Filter 阶段只检查nvidia.com/gpu的可分配数量无法感知已分配 GPU 的实际使用率。智能调度的核心改造点在于扩展 Filter 和 Score 两个阶段flowchart TD A[Pod 提交调度请求] -- B[Filter 阶段] B -- B1{GPU 共享检查} B1 --|显存充足| B2{拓扑亲和检查} B1 --|显存不足| B3[节点淘汰] B2 --|同 NUMA 域| B4[节点保留] B2 --|跨 NUMA 域| B5[降权保留] B4 -- C[Score 阶段] B5 -- C C -- C1[显存碎片率打分] C1 -- C2[GPU 利用率打分] C2 -- C3[拓扑距离打分] C3 -- D[加权排序选出最优节点] D -- E[Bind 绑定] E -- F[Device Plugin 分配显存切片]关键机制如下GPU 共享Multi-Instance GPU / 时间分片NVIDIA MIG 技术允许将一块 A100 物理切分为最多 7 个实例每个实例拥有独立的显存和算力。对于不支持 MIG 的卡如 V100可通过 GPU 时间分片Time-Slicing实现逻辑共享代价是性能线性下降。显存感知过滤在 Filter 阶段调度器不再只看 GPU 数量而是查询 Device Plugin 上报的可用显存量。一个请求 8GB 显存的 Pod可以调度到已分配 2 块 GPU 但仍有 20GB 空闲显存的节点上。拓扑亲和调度多卡推理任务如 Tensor Parallel要求分配的 GPU 位于同一 NUMA 节点否则跨 NUMA 访问会导致 PCIe 带宽下降 30% 以上。Score 阶段需要优先选择拓扑距离最近的 GPU 组合。三、基于 Scheduler Framework 的实现以下代码基于 Kubernetes Scheduler Framework 实现 GPU 感知调度插件核心逻辑包括显存过滤和碎片率打分package gpuscheduler import ( context fmt math v1 k8s.io/api/core/v1 k8s.io/kubernetes/pkg/scheduler/framework ) const ( PluginName GPUAwareScheduler // 预留显存开销比例防止显存分配到 100% 后 OOM DefaultMemoryOverhead 0.1 ) type GPUSchedulerPlugin struct { handle framework.Handle gpuInfoCache NodeGPUInfoCache } type NodeGPUInfo struct { GPUDevices []GPUDevice } type GPUDevice struct { Index int TotalMemory int64 // 总显存单位 MB UsedMemory int64 // 已用显存单位 MB NUMANodeID int // NUMA 拓扑节点编号 MIGProfile string // MIG 切分规格 } // Filter 过滤掉无法满足 Pod GPU 显存需求的节点 func (p *GPUSchedulerPlugin) Filter( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo, ) *framework.Status { gpuReq : extractGPURequest(pod) if gpuReq nil { return framework.NewStatus(framework.Success, ) } nodeName : nodeInfo.Node().Name gpuInfo, err : p.gpuInfoCache.Get(ctx, nodeName) if err ! nil { return framework.NewStatus(framework.Error, fmt.Sprintf(获取节点 %s GPU 信息失败: %v, nodeName, err)) } availableMem : int64(0) for _, dev : range gpuInfo.GPUDevices { usable : dev.TotalMemory - dev.UsedMemory - int64(float64(dev.TotalMemory)*DefaultMemoryOverhead) if usable 0 { availableMem usable } } if availableMem gpuReq.MemoryMB { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf(节点 %s 可用显存 %dMB 请求 %dMB, nodeName, availableMem, gpuReq.MemoryMB)) } return framework.NewStatus(framework.Success, ) } // Score 对节点进行打分优先选择碎片率低、利用率均衡的节点 func (p *GPUSchedulerPlugin) Score( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo, ) (int64, *framework.Status) { gpuReq : extractGPURequest(pod) if gpuReq nil { return 0, framework.NewStatus(framework.Success, ) } nodeName : nodeInfo.Node().Name gpuInfo, err : p.gpuInfoCache.Get(ctx, nodeName) if err ! nil { return 0, framework.NewStatus(framework.Error, err.Error()) } // 计算碎片率各 GPU 利用率的标准差越大碎片化越严重 utilizations : make([]float64, 0, len(gpuInfo.GPUDevices)) for _, dev : range gpuInfo.GPUDevices { if dev.TotalMemory 0 { utilizations append(utilizations, float64(dev.UsedMemory)/float64(dev.TotalMemory)) } } fragmentationScore : 100 - int64(stdDev(utilizations)*100) // 拓扑亲和加分如果 Pod 请求多卡优先选择同 NUMA 域的 GPU topologyBonus : int64(0) if gpuReq.GPUCount 1 { topologyBonus calcTopologyBonus(gpuInfo, gpuReq.GPUCount) } // 加权合并碎片率权重 60%拓扑亲和权重 40% finalScore : int64(float64(fragmentationScore)*0.6 float64(topologyBonus)*0.4) return finalScore, framework.NewStatus(framework.Success, ) } func stdDev(values []float64) float64 { if len(values) 0 { return 0 } mean : 0.0 for _, v : range values { mean v } mean / float64(len(values)) variance : 0.0 for _, v : range values { variance (v - mean) * (v - mean) } variance / float64(len(values)) return math.Sqrt(variance) } func calcTopologyBonus(gpuInfo *NodeGPUInfo, reqCount int) int64 { numaCount : make(map[int]int) for _, dev : range gpuInfo.GPUDevices { numaCount[dev.NUMANodeID] } for _, count : range numaCount { if count reqCount { return 100 } } maxInSameNUMA : 0 for _, count : range numaCount { if count maxInSameNUMA { maxInSameNUMA count } } return int64(float64(maxInSameNUMA) / float64(reqCount) * 100) } func extractGPURequest(pod *v1.Pod) *GPURequest { ann : pod.GetAnnotations() if ann nil { return nil } memStr, ok : ann[gpu-scheduler/memory-mb] if !ok { return nil } countStr : ann[gpu-scheduler/gpu-count] count : 1 if countStr ! { fmt.Sscanf(countStr, %d, count) } var memMB int64 fmt.Sscanf(memStr, %d, memMB) return GPURequest{GPUCount: count, MemoryMB: memMB} } type GPURequest struct { GPUCount int MemoryMB int64 }部署调度器插件后需要在 Pod 上添加注解来声明细粒度 GPU 需求apiVersion: v1 kind: Pod metadata: name: llm-inference annotations: gpu-scheduler/memory-mb: 16384 gpu-scheduler/gpu-count: 2 spec: containers: - name: inference image: llm-serving:v2 resources: limits: nvidia.com/gpu: 2四、显存共享的代价调度延迟、OOM 风险与拓扑陷阱智能调度不是银弹它引入了新的复杂度和风险。调度延迟上升。显存感知调度需要实时查询 Device Plugin 上报的 GPU 状态每次调度周期的 Filter 阶段多了一次缓存查询。在千节点规模下如果缓存更新不及时调度决策可能基于过期数据导致 Pod 启动后显存分配失败。解决方案是设置缓存 TTL 为 5 秒并在 Device Plugin 侧主动推送状态变更事件。OOM 风险放大。GPU 共享意味着多个推理进程共享同一块物理卡的显存。如果某个模型因请求突增导致显存使用飙升可能挤占同一卡上其他进程的显存触发 OOM Kill。生产环境中必须为每个 Pod 设置显存硬限制通过 NVIDIA Device Plugin 的--pass-device-specs和 CUDA 虚拟化限制并预留至少 10% 的安全水位。拓扑陷阱。多卡推理场景下如果调度器分配了跨 NUMA 域的 GPUTensor Parallel 的 AllReduce 操作会走跨 NUMA 的 PCIe 通道延迟增加 30%-50%。在 8 卡节点上如果 NUMA 拓扑为 2 个 NUMA 各管 4 卡调度 4 卡任务时必须确保全部落在同一 NUMA 域内否则性能退化严重。Device Plugin 单点故障。自定义 Device Plugin 是调度链路的关键依赖一旦它崩溃节点上的 GPU 状态将无法上报调度器会把该节点标记为 GPU 不可用。必须为 Device Plugin 配置高优先级、健康检查和自动重启策略。五、实施建议云原生 AI 平台的智能调度核心是在 Kubernetes 调度框架中注入 GPU 显存感知和拓扑亲和两个维度的决策能力。通过 Scheduler Framework 的 Filter/Score 扩展点可以在不修改上游代码的前提下实现细粒度调度。落地建议部署 NVIDIA Device Plugin 并开启 MIG 或时间分片让节点能够上报细粒度 GPU 状态。实现自定义调度器插件先上线显存过滤Filter确保硬性约束不违反。逐步引入碎片率打分和拓扑亲和打分Score观察调度质量是否提升。建立 GPU 利用率的监控大盘将显存碎片率、调度延迟、OOM 事件纳入告警体系。结合 HPA 和 KEDA 实现 GPU 负载驱动的弹性伸缩在波谷期自动缩容降低闲置成本。调度是基础设施好的调度应该像空气一样存在——用户感受不到它但离了它一切都会崩塌。改写总结删除了痛点、底层拆解、实战等填充词简化了标题结构使其更直接删除了代码注释中的冗余说明保留核心逻辑将总结改为实施建议更具体删除了部分AI 词汇如赋能、抓手等保持了技术内容的准确性但语言更自然