异构 GPU 混部:A100 和 H100 放在一个集群的调度策略

发布时间:2026/7/14 12:44:28

异构 GPU 混部:A100 和 H100 放在一个集群的调度策略 异构 GPU 混部A100 和 H100 放在一个集群的调度策略一、不同型号 GPU 混在一个集群Kubernetes 默认调度器根本分不清组织内的 GPU 集群通常是分批采购的第一批可能是 8 节点 A100-40GB第二批新增了 8 节点 A100-80GB第三批又上了 4 节点 H100。不同型号的 GPU 放在同一个 Kubernetes 集群中如果调度器不加区分训练和推理任务可能被错误分配到不兼容的 GPU 上。实际案例中一个使用了 FP8 精度的训练 Job 被调度到了 A100 节点上——A100 不支持 FP8H100 才开始支持导致训练启动失败。开发者在排查了 20 分钟后才意识到这个 Pod 跑在错误的 GPU 型号上。如果他们一开始做了节点标签和 Node Affinity这个错误根本不会发生。异构 GPU 混部的挑战不止于此。A100 和 H100 的显存容量不同、计算能力SM Count不同、NVLink 带宽不同、甚至 CUDA Compute Capability 也不同A100 是 sm_80H100 是 sm_90。基础设施不需要漂亮话调度系统必须把 GPU 的型号、显存容量、拓扑信息全部纳入调度决策而不能假设所有 GPU 都一样。二、基于节点标签的三层调度匹配链Kubernetes 调度器本身不具备 GPU 感知能力它只知道节点上有nvidia.com/gpu资源。要让调度器理解不同 GPU 型号的差异需要建立三层标签体系配合 Device Plugin 暴露 GPU 信息。graph TB subgraph 标签层 1: GPU 型号 L1_A100[nvidia.com/gpu.product NVIDIA-A100-SXM4-80GB] L1_H100[nvidia.com/gpu.product NVIDIA-H100-80GB-HBM3] L1_A40[nvidia.com/gpu.product NVIDIA-A40] end subgraph 标签层 2: GPU 代际/能力 L2_A100[nvidia.com/gpu.family ampere] L2_H100[nvidia.com/gpu.family hopper] L2_Compute[nvidia.com/gpu.compute-capability sm_80 | sm_90] end subgraph 标签层 3: 业务分组 L3_Train[gpu-pool training-hopper] L3_Infer[gpu-pool inference-ampere] L3_Bench[gpu-pool benchmark] end subgraph 调度决策 D1[FP8 训练 Jobbr/→ sm_90 必须br/→ 仅 H100 节点] D2[INT8 推理 Podbr/→ sm_80 均可br/→ A100 或 H100] D3[适配验证br/→ GPU 型号匹配br/→ 驱动/库兼容性检查] end L1_A100 -- L2_A100 L1_H100 -- L2_H100 L1_A40 -- L2_Compute L2_A100 -- L2_Compute L2_H100 -- L2_Compute L2_A100 -- L3_Infer L2_H100 -- L3_Train L3_Train -- D1 L3_Infer -- D2 L2_Compute -- D3 style L1_H100 fill:#c8e6c9,color:#333 style L1_A100 fill:#fff9c4,color:#333 style D1 fill:#ffcdd2,color:#333三层标签体系的分工层 1 — GPU 型号由 NVIDIA GPU Operator 的 Device Plugin 自动注入格式为nvidia.com/gpu.product。这个标签让 Pod 可以精确声明我只能在 H100 上运行。层 2 — GPU 代际需要手动或通过脚本注入。代际信息决定 CUDA Compute Capability进而决定 PyTorch/TensorFlow 镜像的兼容性。sm_90 的二进制不能在 sm_80 的 GPU 上运行。层 3 — 业务分组运维侧按用途将节点分组。例如 H100 节点给大模型训练A100 节点给推理服务。分组标签是 Node Affinity 的匹配目标。三、生产级异构 GPU 调度的完整配置以下代码展示了节点标签自动注入和 Pod 调度声明的完整流程。// hetero_gpu_labeler.go — 异构 GPU 节点自动标注器 // 职责读取 nvidia-smi 获取 GPU 型号和代际信息自动给节点打标签 // 作为 DaemonSet 运行在 GPU 节点上确保标签与硬件状态同步 package main import ( context encoding/xml fmt os/exec regexp strconv strings time metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/apimachinery/pkg/types k8s.io/client-go/kubernetes k8s.io/client-go/rest ) // GPUInfo 从 nvidia-smi 提取的 GPU 信息 type GPUInfo struct { ProductName string // GPU 型号名称 ComputeCapability string // 计算能力版本 MemoryTotalMB int // 总显存 GPUCount int // 节点上的 GPU 数量 } // GPULabeler 自动标注 GPU 节点 type GPULabeler struct { client kubernetes.Interface nodeName string interval time.Duration } // NewGPULabeler 创建 GPU 标注器 func NewGPULabeler(nodeName string, interval time.Duration) (*GPULabeler, error) { config, err : rest.InClusterConfig() if err ! nil { return nil, fmt.Errorf(加载集群配置失败: %w, err) } clientset, err : kubernetes.NewForConfig(config) if err ! nil { return nil, fmt.Errorf(创建 Kubernetes 客户端失败: %w, err) } return GPULabeler{ client: clientset, nodeName: nodeName, interval: interval, }, nil } // DetectGPUInfo 通过 nvidia-smi 获取 GPU 信息 func (l *GPULabeler) DetectGPUInfo(ctx context.Context) (*GPUInfo, error) { ctx, cancel : context.WithTimeout(ctx, 10*time.Second) defer cancel() // 获取 GPU 产品名称和显存 cmd : exec.CommandContext(ctx, nvidia-smi, --query-gpuname,memory.total,compute_cap, --formatcsv,noheader,nounits, ) output, err : cmd.Output() if err ! nil { return nil, fmt.Errorf(nvidia-smi 查询失败: %w, err) } lines : strings.Split(strings.TrimSpace(string(output)), \n) if len(lines) 0 { return nil, fmt.Errorf(未检测到 GPU) } // 解析第一张 GPU 的信息假设同节点型号一致 fields : strings.Split(lines[0], ,) if len(fields) 3 { return nil, fmt.Errorf(nvidia-smi 输出格式异常: %s, lines[0]) } productName : strings.TrimSpace(fields[0]) computeCapability : strings.TrimSpace(fields[2]) // 提取显存大小去除小数点 memRe : regexp.MustCompile((\d)) memMatches : memRe.FindStringSubmatch(strings.TrimSpace(fields[1])) memoryMB : 0 if len(memMatches) 1 { memoryMB, _ strconv.Atoi(memMatches[1]) } return GPUInfo{ ProductName: productName, ComputeCapability: computeCapability, MemoryTotalMB: memoryMB, GPUCount: len(lines), }, nil } // DetermineGPUFamily 根据 Compute Capability 或产品名称判断 GPU 代际 func DetermineGPUFamily(info *GPUInfo) string { cc : info.ComputeCapability product : strings.ToUpper(info.ProductName) // 按 Compute Capability 判断 if strings.HasPrefix(cc, 9.) || strings.Contains(product, H100) || strings.Contains(product, H200) { return hopper } if strings.HasPrefix(cc, 8.) || strings.Contains(product, A100) { return ampere } if strings.HasPrefix(cc, 7.5) || strings.Contains(product, T4) { return turing } return unknown } // ApplyLabels 将检测到的 GPU 信息作为 Kubernetes 标签写入节点 func (l *GPULabeler) ApplyLabels(ctx context.Context, info *GPUInfo) error { node, err : l.client.CoreV1().Nodes().Get(ctx, l.nodeName, metav1.GetOptions{}) if err ! nil { return fmt.Errorf(获取节点 %s 失败: %w, l.nodeName, err) } gpuFamily : DetermineGPUFamily(info) labels : map[string]string{ nvidia.com/gpu.product: strings.ReplaceAll(info.ProductName, , -), nvidia.com/gpu.family: gpuFamily, nvidia.com/gpu.memory-mb: fmt.Sprintf(%d, info.MemoryTotalMB), nvidia.com/gpu.compute-capability: info.ComputeCapability, nvidia.com/gpu.count: fmt.Sprintf(%d, info.GPUCount), } // 只更新变更的标签 updateNeeded : false for k, v : range labels { if existing, ok : node.Labels[k]; !ok || existing ! v { updateNeeded true break } } if !updateNeeded { return nil } // 合并标签 updated : node.DeepCopy() if updated.Labels nil { updated.Labels make(map[string]string) } for k, v : range labels { updated.Labels[k] v } // 执行 Patch patchBytes, err : strategicpatch.CreateTwoWayMergePatch( convertToBytes(node), convertToBytes(updated), corev1.Node{}, ) if err ! nil { return fmt.Errorf(生成 Patch 失败: %w, err) } _, err l.client.CoreV1().Nodes().Patch( ctx, l.nodeName, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}, ) if err ! nil { return fmt.Errorf(Patch 节点标签失败: %w, err) } fmt.Printf(节点 %s 标签已更新: family%s, product%s, memory%dMB\n, l.nodeName, gpuFamily, info.ProductName, info.MemoryTotalMB) return nil }Pod 的调度声明利用标签体系精确匹配# fp8-training-job.yaml — 仅调度到 H100 节点的训练 Job apiVersion: batch/v1 kind: Job metadata: name: llama3-fp8-finetune spec: template: spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: nvidia.com/gpu.family operator: In values: - hopper # H100 代际 - key: nvidia.com/gpu.memory-mb operator: Gt values: - 70000 # 至少 70GB 显存H100-80GB 满足 containers: - name: trainer image: pytorch/pytorch:2.5.0-cuda12.4 resources: limits: nvidia.com/gpu: 8 # 8 卡训练四、异构混部的运维成本与反模式异构混部的最大成本不是技术复杂度而是镜像和驱动的多版本管理。sm_80A100和 sm_90H100的 CUDA 二进制不兼容意味着需要维护两套 PyTorch/TensorFlow 镜像。CI/CD 流水线必须按 GPU 代际产出不同的镜像 Tag。驱动版本锁定。A100 和 H100 对 NVIDIA 驱动的最低版本要求不同H100 需要 R525 的驱动。节点组之间驱动版本不一致可能导致 CUDA 库的兼容性问题。在 GPU Operator 的 ClusterPolicy 中可以为不同节点组指定不同的驱动版本但这增加了配置维护的复杂度。节点浪费的风险。如果 A100 节点池和 H100 节点池的比例不合理可能出现一种型号的 GPU 闲置而另一种型号的 GPU 排队。缓解方式是在 Cluster Autoscaler 中为每个节点组配置独立的扩缩容策略而非全局统一。反模式按成本优先级做优先调度。有些团队尝试用 PriorityClass 多节点池让任务优先调度到便宜的 GPU这种策略在高负载时会因为节点池不匹配导致调度失败率上升。更合理的做法是按 GPU 能力而非成本做 Affinity 匹配。五、总结异构 GPU 集群的调度需要从标签体系做起核心行动项建立三层标签体系型号product、代际family、业务分组pool为调度器提供完整信息。使用 GPU Operator 自动注入 product 标签手工维护的其他标签通过 DaemonSet 脚本注入。CI/CD 按 GPU 代际产出不同镜像避免二进制兼容性问题。训练和推理按 GPU 型号物理隔离H100 用于大模型训练和 FP8 推理A100 用于一般推理不在同一节点池混跑。

相关新闻