为什么要做 GPU 调度:从调度全景到 HAMi mutex 的语义验证

从「为什么要做 GPU 调度」讲起:默认调度器的三个盲区,节点、卡、拓扑三个调度维度,HAMi v2.10 如何用一条策略链覆盖它们;最后用 kind 上可复现的判决性实验裁决 mutex 的真实语义:它要的是一张零租户的卡。

GPU 调度不只是「把卡分给谁」,而是连续回答三层问题:放到哪个节点、放到哪张卡、卡在机器内的什么位置。本文从这三层框架讲到 HAMi v2.10 的策略链,最后裁决一个最容易搞错的策略语义:mutex 要求的不是与其他 mutex Pod 互斥,而是一张零租户的卡。裁决的方法是一个任何人都能在笔记本上复现的判决性实验。

为什么要做 GPU 调度

一台八卡 H100 服务器售价上百万元,其中 GPU 占了绝大部分成本。买它是为了算力,但这张卡有三个 CPU 不具备的属性:太贵,闲不起;粒度太粗,整卡 80GB 显存,而一个推理副本可能只需要两三 GB;性能对邻居敏感,共居的租户会互相争抢算力、显存带宽和 cache。这三条加在一起,就是 GPU 调度要解决的根本问题:在合适的时间,以合适的粒度,把这张昂贵的卡放到合适的位置,交给合适的负载。

而 Kubernetes 默认调度器是为无状态微服务设计的。对 GPU,它的全部认知就是资源声明里的一行:

resources:
  limits:
    nvidia.com/gpu: 1

在它眼里,GPU 是一个不可分割的整数计数。这带来三个盲区:

  • 粒度盲区:只能整卡分配。小负载独占大卡,卡上闲着的显存和算力全是浪费;
  • 共居盲区:不知道哪些负载适合挤在同一张卡上摊薄成本,哪些必须分开才能保住性能;
  • 位置盲区:不知道 GPU 挂在哪个 NUMA 节点、哪个 PCIe 交换机下面,与哪些 GPU 和网卡是「近亲」。

前两个盲区是卡内的问题,HAMi 这类 GPU 共享方案就是为它们而生:把卡按显存和算力切分,让多个 Pod 共居。第三个盲区更隐蔽,也最反直觉。Manan Paliwal 在 Why Kubernetes Is Slowing Down Your GPUs 一文中描述了这样的场景:一个看起来一切健康的 H100 集群,分布式训练却比预期慢 30% 到 40%,继续加卡也几乎无济于事。原因不在 CUDA、PyTorch 或 NCCL,而是调度器把 GPU 和它的 RDMA 网卡放在了不同的 NUMA 节点上,每个数据包都要先跨过 CPU 之间的互连链路才能上网。再比如一台四卡机器,GPU 0 和 GPU 1 挂在 PCIe 交换机 A 下,GPU 2 和 GPU 3 挂在交换机 B 下,负载请求两张 GPU,调度器完全可能给出 GPU 1 加 GPU 2 的组合:数量对了,位置错了,通信跨交换机、跨 CPU 互连。文章里最扎心的一句总结是:没有任何东西崩溃,没有任何任务失败,性能安静地消失了。

三个维度,一条策略链

把盲区整理成问题清单,一次 GPU 调度决策其实要连续回答三层问题:

维度要回答的问题答错或不答的后果
节点级放到哪个节点装箱不当留下一堆碎片节点,打散不当浪费整卡
卡级放到哪张卡,共居还是独占该共享的被独占是浪费,该独占的被共享是性能干扰
拓扑级卡在机器内的物理位置跨 NUMA、跨 PCIe 通信,性能安静地流失

默认调度器只用「资源总量够不够」粗略回答第一层,第二、三层完全交给运气。Kubernetes 生态对此的解法是分层的:Kueue 在队列层做硬件感知的整作业放置,kubelet 的 Topology Manager 在节点内对齐 NUMA,NVIDIA Network Operator 配合 Multus 把 RDMA 网络直接暴露给负载。HAMi 的切入点不同:把第二、三层装进调度器本身,变成 Pod 注解就能表达的语言。

图 1: GPU 调度决策的三个维度与 HAMi 的策略词汇
图 1: GPU 调度决策的三个维度与 HAMi 的策略词汇

具体来说,v2.10 的调度模型是这样的:

  • 卡级共享是老本行:HAMi-core 在驱动层把卡按显存和算力软切分,多个 Pod 共居一张卡,这是「卡级」维度里共享那一侧的基础;
  • 两条注解管两层偏好hami.io/node-scheduler-policy 管节点级(单值 binpack 或 spread),hami.io/gpu-scheduler-policy 管卡级(v2.10 起支持逗号分隔的策略链);
  • 策略分两类角色过滤器(mutex、NVIDIA 的 topology-aware)在排序之前把不合格的卡从候选集中剔除;排序键(binpack、spread、numa)对幸存的卡排序。只写过滤器时,排序回退为 spread。

对照上面的表:binpack 和 spread 回答「怎么装箱」,numa 和 topology-aware 回答「位置对不对」,mutex 回答「能不能有邻居」。mutex,binpack,numa 这样一条链,翻译过来就是:给我一张没人用过的卡,装箱尽量紧凑,最好还在同一个 NUMA 节点上。

在这张地图上,mutex 是唯一带独占语义的策略,也是 v2.10 新加入的。而它「独占」的确切含义,恰恰是文档表述和社区认知里最模糊的一块,这正是接下来要裁决的问题。

背景:mutex 到底和谁互斥

先把结论放在最前面,回答一个最直接的问题:hami.io/gpu-scheduler-policy: "mutex" 是不是让带该注解的 Pod 之间互斥?不是。它的实际行为是:

mutex 的真实语义
  • 零租户:带 mutex 注解的 Pod 只能调度到当前没有任何负载占用的 GPU 卡上,与卡上已有 Pod 是否带 mutex 注解无关;
  • 单向生效:约束只在 mutex Pod 自己被调度的时刻检查;之后调度的非 mutex Pod 仍可加入同一张卡,调度器也不会为 mutex Pod 保留这张卡。

HAMi v2.10 为 hami.io/gpu-scheduler-policy 注解带来了 mutex 策略和可组合策略链(如 mutex,binpack)。关于 mutex 的语义,社区里一直存在两种理解:

理解含义
理解 A:零租户mutex Pod 只能调度到当前没有任何负载的 GPU 卡上,无论已有负载带不带 mutex
理解 B:仅 mutex 互斥mutex Pod 只与其他 mutex Pod 互斥,可以和普通 Pod 共享同一张卡

大多数测试报告(包括我们内部的一份 LWS 测试)都无法区分这两种理解,因为它们把 gpuSchedulerPolicy: mutex 设成了全局默认值。当所有 Pod 都是 mutex 时,两种语义预测的行为完全相同:卡上有任何一个 Pod,后来的 mutex Pod 都会被拒绝。要区分 A 和 B,必须设计一个混合场景:让卡上只有普通 Pod,再提交 mutex Pod。

  • 理解 A 预测:拒绝(卡上已有负载)
  • 理解 B 预测:允许(卡上没有 mutex Pod)

前面已经给出结论:HAMi 的实现是理解 A。支撑它的证据链有四层:issue #2009 的原始需求写的是 “no existing users”、PR #2011 的实现判断 dev.Used > 0(该计数对卡上所有 Pod 生效)、我们在 GKE 四卡 T4 上真实 GPU 实验的观测,以及本文的本地 mock 实验。本文记录第四层证据的完整复现过程,它不需要任何 GPU,一台笔记本、约二十分钟就能跑完。

为什么 HAMi 需要 mutex

要理解这个策略为什么出现,得先看 GPU 共享的根本矛盾。

HAMi 这类 GPU 虚拟化方案的价值在于切分:把一张 80GB 的卡按显存和算力切成多份,让多个推理副本共居,利用率上去了,成本下来了。但切分有一个天然代价:共居的租户共享 SM、共享显存带宽、互相污染 L2 cache。对吞吐型批处理这无所谓,对两类负载是致命的:延迟敏感的在线推理(尾延迟不可控),以及需要稳定基准的性能测试和训练任务(结果不可复现)。

在 mutex 出现之前,想要独占一张卡的用户只有两个都不理想的选项:

  • 不切分,直接占整张卡:低谷期整卡闲置,共享集群里这是最大的浪费来源;
  • 用 MIG 这类硬件切分:几何固定,需要管理员在节点上预配置,Profile 与负载需求很难精确对齐,变更还要动节点。

mutex 填补的是中间的空档:一个调度层的、按 Pod 粒度的、零硬件依赖的软独占开关。它不引入新的隔离技术,只是告诉调度器「给我一张当前零租户的卡」。结合 HAMi 已有的软切分能力,集群第一次可以同时服务两种负载:高峰期用共享切分摊薄成本,夜间跑基准或延迟敏感服务时用 mutex 独占空闲卡,任务结束即释放。

它出现在 v2.10 也不是孤立的。同一个 roadmap(#1889)里同时推进了策略链(#2010),因为真实集群的诉求从来是叠加的:既要紧凑装箱省出整卡,又要 NUMA 亲和,还要给关键负载留几张独占卡。mutex,binpack,numa 这种链式表达,才是这些诉求的完整形态。另一个典型场景是 LeaderWorkerSet 这类副本组调度:同一副本组内的 GPU Pod 不应共用物理卡,把组内 Pod 标记为 mutex 即可天然满足。

还有一个值得品味的设计取舍:mutex 的独占是单向的、只约束自己放置时刻。实现上它只是复用了调度器里已有的 Used 计数器(卡上任何 Pod 分配都会使其加一),不引入任何运行时锁或预留机制。这个选择让实现极简、语义无歧义,代价是它不约束后续负载:之后调度的普通 Pod 仍可加入这张卡。若需要全程独占,应申请整卡资源或用 use-gpuuuid 固定。调度系统里「保证」的边界划在哪里永远是个权衡,HAMi 把它划在了放置时刻,把更强的约束留给用户显式表达。

mutex 的工作机制

策略链的求值模型上一节已经给出,这里聚焦 mutex 这一个过滤器在调度器内部的具体行为。它守在候选卡集合的入口,判断条件只有一个:这张卡的 Used 计数是否为 0。这个计数对卡上所有 Pod 生效,不管对方带不带 mutex 注解。

图 2: mutex 过滤器如何为 Pod 选卡
图 2: mutex 过滤器如何为 Pod 选卡

而「单向互斥」用时间线看最清楚。同一条判断 Used > 0,只在 mutex Pod 自己被调度时执行:

图 3: 互斥是单向的:只约束 mutex Pod 自己的放置
图 3: 互斥是单向的:只约束 mutex Pod 自己的放置

下面的三组实验分别对应这两张图:实验一验证过滤条件是「任何租户」而不只是「mutex 租户」,实验二验证图一的正常路径,实验三验证图二的单向性。

实验环境:kind + mock-device-plugin

整体思路:用 kind 起一个单节点集群,安装 HAMi 调度器(仅控制面),再用 HAMi 官方的 mock-device-plugin 在节点上注册两张假的 Tesla T4。HAMi 调度器会像对待真卡一样对它们做策略计算。

实验使用与 HAMi 官方 Lab 14(GKE 实验)完全相同的构建:Helm chart 取自 HAMi 源码的 45b3d46769b44cfc1445728dfcb8e524939afba1 提交(2026-08-17 的 master HEAD,即 v2.10.0 发布候选代码),镜像用 HAMi CI 为该提交发布的逐提交标签 projecthami/hami:45b3d46。不要图省事用 latest:它是移动标签,本文写作时它已经漂移到了更新的 master 提交(镜像内嵌的版本号从 45b3d46 变成了 949f78e),复现就会失真。

创建集群并准备镜像

kind 节点自己拉镜像容易受网络影响,先在宿主机拉好再载入:

kind create cluster --name hami-mutex2 --image kindest/node:v1.36.1

# 宿主机预拉取(网络不稳时多重试几次)
docker pull projecthami/hami:45b3d46
docker pull projecthami/mock-device-plugin:latest
docker pull \
  registry.cn-hangzhou.aliyuncs.com/google_containers/kube-scheduler:v1.36.1
docker pull liangjw/kube-webhook-certgen:v1.1.1

kind load docker-image projecthami/hami:45b3d46 --name hami-mutex2
kind load docker-image projecthami/mock-device-plugin:latest --name hami-mutex2
kind load docker-image \
  registry.cn-hangzhou.aliyuncs.com/google_containers/kube-scheduler:v1.36.1 \
  --name hami-mutex2
kind load docker-image liangjw/kube-webhook-certgen:v1.1.1 --name hami-mutex2

其中 kube-scheduler 镜像是 HAMi chart 内嵌调度器所用的 sidecar,tag 要与集群版本一致;certgen 是安装时的 webhook 证书 Job 镜像。

安装 HAMi 调度器与 mock 插件

# 获取与 Lab 14 相同的 chart 源码
curl -fsSL https://codeload.github.com/Project-HAMi/HAMi/tar.gz/45b3d46769b44cfc1445728dfcb8e524939afba1 \
  -o hami-src.tar.gz
tar xzf hami-src.tar.gz

helm install hami HAMi-45b3d46769b44cfc1445728dfcb8e524939afba1/charts/hami \
  -n kube-system \
  --set global.imageTag=45b3d46 \
  --set devicePlugin.enabled=false \
  --set mockDevicePlugin.enabled=true \
  --set mockDevicePlugin.image.tag=latest \
  --set mockDevicePlugin.image.pullPolicy=IfNotPresent

三个关键参数:

  • devicePlugin.enabled=false:没有真实 GPU,关闭 HAMi 自己的设备插件;
  • mockDevicePlugin.enabled=true:改用 mock 插件向节点注册虚拟的显存/算力扩展资源;
  • mockDevicePlugin.image.tag=latest必须用 latest。1.0.1 版本解析不了当前 chart 的新版 vnpus 配置格式,会直接报 cannot unmarshal !!map into []ascend.VNPUConfig 崩溃,这个坑我踩过。

注册两张假 T4

mock 插件的设计是"三件套":device-config 配置块(chart 已自带)、节点上的 count 扩展资源(健康门,只需大于 0)、hami.io/node-nvidia-register 注解(描述假卡)。后两件需要手动提供:

NODE=hami-mutex2-control-plane

# 健康门:nvidia.com/gpu = 2 卡 x 10 切分
kubectl patch node $NODE --subresource=status --type=json -p '[
  {"op": "add", "path": "/status/capacity/nvidia.com~1gpu", "value": "20"}
]'

# 两张假 T4:GPU-MOCK-A 和 GPU-MOCK-B,各 15360 MiB
kubectl annotate node $NODE --overwrite \
  'hami.io/node-nvidia-register=[
    {"id":"GPU-MOCK-A","count":10,"devmem":15360,"devcore":100,
     "type":"NVIDIA-Tesla-T4","health":true,"numa":0,"mode":"hami-core"},
    {"id":"GPU-MOCK-B","index":1,"count":10,"devmem":15360,"devcore":100,
     "type":"NVIDIA-Tesla-T4","health":true,"numa":0,"mode":"hami-core"}
  ]'

等约 30 秒,确认 mock 插件完成了资源注册:

kubectl get node $NODE -o jsonpath='{.status.allocatable}' | python3 -c "
import json, sys
alloc = json.load(sys.stdin)
nvidia = {k: v for k, v in alloc.items() if 'nvidia' in k}
print(json.dumps(nvidia, indent=2))
"
{
  "nvidia.com/gpu": "20",
  "nvidia.com/gpucores": "200",
  "nvidia.com/gpumem": "30720",
  "nvidia.com/gpumem-percentage": "200"
}

两张卡就绪。所有测试 Pod 都用同一个模板:申请 1 个 vGPU、1000 MiB 显存。本环境预载了所有镜像,注意 imagePullPolicy: IfNotPresent 仍建议显式写上,避免运行时再依赖 registry:

cat > plain-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: plain-N
spec:
  restartPolicy: Never
  containers:
    - name: app
      image: docker.io/projecthami/hami:45b3d46
      imagePullPolicy: IfNotPresent
      command: ["sh", "-c", "sleep 3600"]
      resources:
        limits:
          nvidia.com/gpu: 1
          nvidia.com/gpumem: 1000
EOF

观察放置结果的唯一工具是这个命令,HAMi 调度器会把选中的卡写到 Pod 注解上:

kubectl get pods -o custom-columns=\
  'POD:.metadata.name,'\
  'CARD:.metadata.annotations.hami\.io/vgpu-devices-allocated'

实验一:判决性实验

第一步,部署两个不带任何策略注解的普通 Pod(sed 换个名字即可):

sed 's/plain-N/plain-1/' plain-pod.yaml | kubectl apply -f -
sed 's/plain-N/plain-2/' plain-pod.yaml | kubectl apply -f -
kubectl wait --for=condition=Ready pod/plain-1 pod/plain-2 --timeout=3m
POD       CARD
plain-1   GPU-MOCK-B,NVIDIA,1000,0:;
plain-2   GPU-MOCK-A,NVIDIA,1000,0:;

默认的 spread 策略让两个普通 Pod 分占了两张卡。此刻集群里一个 mutex Pod 都没有。提交 mutex Pod:

cat > mutex-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: mutex-1
  annotations:
    hami.io/gpu-scheduler-policy: "mutex"
spec:
  restartPolicy: Never
  containers:
    - name: app
      image: docker.io/projecthami/hami:45b3d46
      imagePullPolicy: IfNotPresent
      command: ["sh", "-c", "sleep 3600"]
      resources:
        limits:
          nvidia.com/gpu: 1
          nvidia.com/gpumem: 1000
EOF
kubectl apply -f mutex-pod.yaml
sleep 20
kubectl get pod mutex-1
kubectl describe pod mutex-1 | sed -n '/Events:/,$p' | tail -3
NAME      READY   STATUS    RESTARTS   AGE
mutex-1   0/1     Pending   0          20s

Warning  FailedScheduling  20s  hami-scheduler
  0/1 nodes are available: 1 2/2 ExclusiveDeviceAllocateConflict.
  no new claims to deallocate,
  preemption: 0/1 nodes are available: 1
  No preemption victims found for incoming pod.

2/2 ExclusiveDeviceAllocateConflict:两张卡全被拒绝。如果理解 B(仅 mutex 互斥)成立,此刻卡上没有任何 mutex Pod,这个 Pod 应该立刻调度成功。它没有。

第二步,删除 plain-1 释放 GPU-MOCK-B,看 mutex Pod 的去向:

kubectl delete pod plain-1 --force --grace-period=0
kubectl wait --for=condition=PodScheduled pod/mutex-1 --timeout=2m
POD       CARD
mutex-1   GPU-MOCK-B,NVIDIA,1000,0:;
plain-2   GPU-MOCK-A,NVIDIA,1000,0:;

mutex Pod 落到了那张被完全清空的卡上,而另一张卡上仍有 plain-2 在运行。零租户语义得到确认。

实验二:mutex 对 mutex(对照组)

清空所有 Pod,连续提交三个 mutex Pod:

kubectl delete pod --all --force --grace-period=0
kubectl apply -f mutex-pod.yaml
sed 's/name: mutex-1/name: mutex-2/' mutex-pod.yaml | kubectl apply -f -
sed 's/name: mutex-1/name: mutex-3/' mutex-pod.yaml | kubectl apply -f -
POD       PHASE     CARD
mutex-1   Running   GPU-MOCK-B,NVIDIA,1000,0:;
mutex-2   Pending   GPU-MOCK-A,NVIDIA,1000,0:;
mutex-3   Pending   <none>

前两个 mutex Pod 分占两张卡,第三个被 2/2 ExclusiveDeviceAllocateConflict 拒绝。这个场景两种理解预测一致,作为对照。顺带一提,mutex-2 短暂出现过 node lock contention 的事件后重试成功,这是 HAMi 已知的节点锁竞争行为,不影响语义。

实验三:互斥是单向的

最后一个问题:mutex Pod 占了一张卡之后,这张卡是不是就此被独占了?在 mutex-1 运行于 GPU-MOCK-B 时,提交一个 binpack 注解的普通共享 Pod:

POD        CARD
binpack-1  GPU-MOCK-B,NVIDIA,1000,0:;
mutex-1    GPU-MOCK-B,NVIDIA,1000,0:;
mutex-2    GPU-MOCK-A,NVIDIA,1000,0:;

binpack Pod 落在了 mutex Pod 正在使用的同一张卡上。互斥只对 mutex Pod 自己的放置时刻生效,是单向的:它只要求放置时刻目标卡上没有任何负载,但不会为其保留这张卡。

结论

三组实验与代码、需求、真实 GPU 环境的观测完全一致,HAMi mutex 的语义可以概括为两句话:

  1. mutex Pod 只能落到当前零租户的卡上,与卡上已有 Pod 是否带 mutex 注解无关;
  2. 独占是单向且只看放置时刻的:之后的非 mutex Pod 仍然可以加入这张卡。

对使用者的直接建议:如果你的目标是"这个负载要独占整卡直到它退出",mutex 只完成了一半。要么申请整卡资源(把显存和算力都占满),要么用 nvidia.com/use-gpuuuid 把卡固定下来,两者在 HAMi 官方博客里也有说明。

回看动机与机制,mutex 的价值不在于隔离技术本身,而在于它给软切分世界补上了「我要一个人住」的选项,并且以过滤器这个最轻量的实现挂进了策略链。放回本文开头那张三个维度的地图,mutex 只占「卡级独占」一个格子,但正因为策略可组合,这个格子才能与装箱偏好、NUMA 亲和叠进同一条链里各自演进。判断它行为的方法论同样重要:当文档措辞出现分歧时,构造一个两种解释预测不同结果的最小场景,用 mock 设备在本地复现,几分钟就能拿到裁决。同样的方法可以继续验证 binpack,numa 策略链的排序行为、组合策略的回退规则等,欢迎扩展。

清理

kubectl delete pod --all --force --grace-period=0
helm uninstall hami -n kube-system
kind delete cluster --name hami-mutex2

总结

本文从「为什么要做 GPU 调度」这个问题出发,拆出节点、卡、拓扑三个调度维度,说明了 HAMi v2.10 如何用过滤器加排序键的策略链覆盖它们;随后聚焦其中唯一带独占语义的 mutex,用 kind 加 mock-device-plugin 搭了一个无 GPU 的本地环境,通过三个实验裁决了它的语义分歧:它要求目标卡零租户(而不是仅与其他 mutex Pod 互斥),且独占只在放置时刻单向生效。实验使用与官方 Lab 14 相同的 v2.10.0 发布候选构建,全部命令与输出均来自实际运行,可完整复现。文中还记录了三个实际的坑:mock 插件 1.0.1 解析不了新版配置需用 latest 标签、latest 镜像必须显式 IfNotPresent、以及 kind 节点预载镜像的必要性。

参考文献

宋净超(Jimmy Song)

宋净超(Jimmy Song)

专注于 AI 原生基础设施与云原生应用架构的研究与开源实践。

文章导航

评论区