Kubernetes 里想“共享 GPU”,看起来好像一句话:让一个 GPU 能跑多个训练 / 推理任务。

但真落到集群里,马上会踩到一堆现实问题:怎么表达“0.2 张卡”?怎么避免调度冲突?更关键的是——怎么避免“隔壁任务把显存吃光把你挤掉”?

最近 KAI-Scheduler(Run:ai 被 NVIDIA 收购后开源的调度组件)把一套 GPU Sharing 方案放到台前。它的核心招式叫 Reservation Pod:用“占整卡”的方式把 kube-scheduler 挡在门外,然后在调度器内部自己做分账。

另一方面,HAMi 这类方案走的是另一条路:不仅在调度层做分配,而且在运行时做强制限制,尽量把“共享”变成可控、可预测的多租户能力。

先看你到底缺哪一层

GPU sharing 的问题可以拆成两层,不拆清楚就会选错工具:

  • 调度表达问题:Kubernetes 原生只认整数 GPU,你需要表达“半张卡”“0.2 张卡”或队列内分账。
  • 运行时隔离问题:任务已经放到同一张卡上,但显存、算力、故障影响范围仍然需要控制。
  • 租户治理问题:不同团队、不同优先级、不同 SLA 的任务不能只靠“大家自觉少用一点”。
  • 回滚与止损问题:共享策略一旦导致抖动,需要能快速退回整卡独占或更保守的策略。

KAI 更偏向解决调度表达和队列分配,HAMi 更偏向解决运行时限制和隔离。把两者当成同一类“GPU 共享方案”去比,很容易得出过度简化的结论。

最容易误判的地方

  • 调度器能把任务放到同一张卡,不代表运行时一定隔离可靠。
  • 有显存限制,不代表所有性能干扰都消失了,带宽、I/O、CPU feed 仍然可能互相影响。
  • Reservation Pod 不是魔法,它是在 Kubernetes 整数设备模型外面做一层调度账本。
  • 硬隔离能力越强,落地复杂度、兼容性测试和运维成本通常也越高。

1) 为什么 K8s 原生很难“分数 GPU”

先把锅甩给 Kubernetes 并不公平,但事实是:K8s 的设备类资源模型,天生就是“整块分配”。

你熟悉的 nvidia.com/gpu: 1 本质是一个整数扩展资源(extended resource)。它的限制很直接:

  • 只能整数:你没法写 nvidia.com/gpu: 0.2
  • 调度器不懂“分数”:kube-scheduler 的默认逻辑只会做整数资源的 Fit/Score
  • 状态很难标准表达:就算你“私下”把同一块 GPU 切成 5 份,K8s 也不知道那块 GPU 现在到底被谁用了多少

很多团队的第一反应是:

“那我写个 device plugin,把 1 张卡上报成 5 个虚拟 GPU 不就行了?”

没错,这就是一条经典路线(后面讲 HAMi 的时候会展开)。但 KAI 走了另一条更“调度器脑洞”的路。


2) KAI 的核心招式:Reservation Pod

2.1 直觉理解:先把整张卡“占座”,再内部拼桌

KAI 的思路可以用一句话概括:

我不跟 Kubernetes 争论“分数 GPU”这个概念,我直接用一个 Pod 把整张卡占下来。

然后,真正需要 0.x GPU 的业务 Pod,并不会直接向 K8s 申请分数 GPU,而是通过 annotation 告诉 KAI:

metadata:
  annotations:
    gpu-fraction: "0.2"  # 20%

KAI 做的事是:

  1. 看到 gpu-fraction 后,自己在内部选择“落在哪个节点的哪块物理 GPU 上”。
  2. 如果这块 GPU 还没人共享,KAI 会先创建一个 Reservation Pod:它会正儿八经请求 nvidia.com/gpu: "1"。
  3. 一旦 Reservation Pod 把整卡申请走了,kube-scheduler 就认为这张卡“没了”,自然不会再把整卡任务丢过来造成冲突。
  4. 之后所有分数任务都通过标签(例如 gpu-group)“跟着这个 Reservation Pod 拼桌”。

2.2 关键点:它改变的是调度模型,GPU 本身没有被虚拟化

Reservation Pod 的价值不在于它真实消耗 GPU(它往往是一个轻量容器),而在于它把 K8s 的资源视角变成了:

  • “这块 GPU 归某个 reservation 组了”

于是 KAI 在内部就能维护更细粒度的账本。


3) KAI GPU Sharing 的运作细节(更接近工程视角)

这里把你最可能关心的三个部分拆开讲:

3.1 调度入口:解析 annotation

KAI 在调度时识别分数 GPU 需求,并选择可共享的物理 GPU:

  • 读取 gpu-fraction、gpu-memory 等请求
  • 在节点和物理 GPU 维度做容量匹配
  • 必要时创建 Reservation Pod 占住整卡
  • 把共享 Pod 绑定到同一资源组并维护内部账本

内部函数名会随版本变化。稳定接口是 Pod annotation、schedulerName、Queue 标签和 Reservation Pod 行为。

3.2 关键动作:创建 Reservation Pod

Reservation Pod 的创建逻辑,会把 Pod 固定到某个 node,并请求整卡:

func (rsc *service) createResourceReservationPod(
  nodeName, gpuGroup, podName, appName string,
  resources v1.ResourceRequirements,
) (*v1.Pod, error) {

  podSpec := &v1.Pod{
    ObjectMeta: metav1.ObjectMeta{
      Name:      podName,
      Namespace: "runai-reservation",
      Labels: map[string]string{
        "gpu-group": gpuGroup,
        // ...其他 label
      },
    },
    Spec: v1.PodSpec{
      NodeName: nodeName,
      Containers: []v1.Container{{
        Name:      "reservation",
        Image:     rsc.reservationPodImage,
        Resources: resources, // request nvidia.com/gpu: "1"
      }},
    },
  }

  return podSpec, rsc.kubeClient.Create(context.Background(), podSpec)
}

注意两个点:

  • NodeName 固定:reservation 不是“等调度”的,它是 KAI 指定贴到某个节点上。
  • 用 label 做逻辑绑定:后续分数 Pod 会被标记到同一个 gpu-group。

3.3 内部记账:记录每个共享组的“已用显存”等

KAI 会在自己的结构体里维护共享状态,例如:

type GpuSharingNodeInfo struct {
  ReleasingSharedGPUs       map[string]bool
  UsedSharedGPUsMemory      map[string]int64
  ReleasingSharedGPUsMemory map[string]int64
  AllocatedSharedGPUsMemory map[string]int64
}

这类字段反映了一个很重要的事实:

KAI 的共享是通过“调度器内部账本”完成的,而不是通过底层强制隔离。

3.4 回收:最后一个共享 Pod 结束 -> 删除 Reservation Pod

当某个 gpu-group 下已经没有还在 Running/Pending 的分数 Pod 时,KAI 会删除对应 reservation,把整卡资源还给 Kubernetes:

func (rsc *service) syncForPods(ctx context.Context, pods []*v1.Pod, gpuGroupToSync string) error {
  reservationPods := map[string]*v1.Pod{}
  fractionPods := map[string][]*v1.Pod{}

  for _, pod := range pods {
    if pod.Namespace == "runai-reservation" {
      reservationPods[gpuGroupToSync] = pod
      continue
    }
    if pod.Status.Phase == v1.PodRunning || pod.Status.Phase == v1.PodPending {
      fractionPods[gpuGroupToSync] = append(fractionPods[gpuGroupToSync], pod)
    }
  }

  for gpuGroup, reservationPod := range reservationPods {
    if _, found := fractionPods[gpuGroup]; !found {
      return rsc.deleteReservationPod(ctx, reservationPod)
    }
  }
  return nil
}

4) KAI 这条路线的优点与“现实约束”

4.1 优点:落地优雅

KAI 的亮点非常工程化:

  • K8s 原语级别的集成:Pod/Label/Annotation,基本不碰 K8s 核心代码
  • 对默认调度器干扰小:reservation 把资源占住,不让 kube-scheduler“插手”
  • 分数更灵活:理论上可以 0.13、0.27 这种任意比例

如果你的诉求是“尽快在集群里跑起来”并且应用团队愿意配合,KAI 是一个很快能产生收益的方案。

4.2 关键限制:软隔离(Soft Isolation)

但它也有一个绕不开的本质:

KAI 做的是“调度层的分账”,不是“执行层的强制限额”。

这会带来三个现实问题:

  1. 显存/算力没有强制上限

    • 一个 Pod 申请 gpu-fraction: 0.2,理论上仍可能申请到更多显存
  2. “吵闹邻居”无法从机制上阻止

    • 即便都很“文明”,某次 batch 变大、某个模型热更新,都可能把隔壁顶掉
  3. 往往需要应用侧配合

    • 比如 PyTorch 的 torch.cuda.set_per_process_memory_fraction(...)
    • 或者在 TensorFlow / Triton Server 里做显存策略

一句话:

  • KAI 更像“共享车位 + 调度账本”
  • 更强边界要靠运行时约束或 MIG/vGPU 硬件分区

5) HAMi 的路线:把约束放到 CUDA API 层

HAMi 的核心诉求是减少对应用自律的依赖:

调度负责分配,运行时负责限制。

典型做法会包含两部分:

  • Device Plugin 侧:把可分配的 GPU 资源以某种形式暴露给 K8s(或者把一张卡切成多个可调度单元)
  • 运行时侧:注入 HAMi-Core,通过 CUDA API 拦截限制显存分配并调节计算使用

它比应用自限提供更强的运行时边界:

  • 显存有上限:超额分配会失败,减少相邻任务被挤出的概率
  • 计算可配额:降低单任务长期独占计算资源的风险
  • 对业务更透明:通常不需要业务代码显式设置“我只用 20% 显存”

当然,代价同样现实:

  • 部署链路更复杂(device plugin + runtime 组件)
  • 对 GPU/驱动版本、容器运行时等可能有更多约束

这仍是用户态 CUDA API 拦截,不等同于 MIG 的硬件分区。绕过 Hook 的访问路径、驱动兼容性、PCIe 和显存带宽干扰仍需验证。


6) 怎么选:别纠结“谁更先进”,看约束条件

我更建议按下面这个表来做决策。

6.1 你更适合 KAI(偏轻量)如果:

  • 你主要诉求是 提高 GPU 利用率,且容忍一定抖动
  • 你可控业务方,应用愿意配合做显存限制
  • 你希望尽量少碰底层,更偏“调度即服务”

6.2 你更适合 HAMi(运行时约束)如果:

  • 你是平台团队,需要面向多个业务方 / 多租户
  • 你需要比应用自限更可靠的显存边界和计算配额
  • 希望对应用透明,用户只要“申请资源”而不是“写资源限制代码”

7) 一个更现实的协同方向:调度策略 + 运行时约束

很多人会把它当成“二选一”,但从平台演进角度,我更倾向于把它看成两层能力:

  • 上层调度策略(KAI 擅长):怎么更聪明地把作业放到哪张卡、怎么打散/聚合、怎么做队列/优先级
  • 下层资源约束(HAMi 擅长):放上去之后,怎么在 CUDA API 层限制显存与计算使用

如果未来社区能把这两层接口对齐,理论上可以出现更理想的组合:

  1. KAI 负责漂亮的“共享调度体验”(annotation / policy / queue)
  2. HAMi 或类似机制提供运行时限制(减少显存越界与计算抢占)

这样对平台的价值非常直接:

  • 既不牺牲 K8s 生态兼容性
  • 又能把共享变成更可观测、可回滚的能力

它仍不能消除所有 noisy neighbor:PCIe、显存带宽、CPU/I/O 供给和绕过拦截的访问路径仍可能互相影响。需要硬件级隔离时,应评估 MIG 或 vGPU。


8) 实战:在共享 GPU 上跑两个推理/训练任务

这里给一个很现实的场景:一张 24GB 的卡,你想同时跑两个任务:

  • 任务 A:稳定在线推理(希望显存占用可控)
  • 任务 B:离线小批量评估/训练(允许慢一点,但别把 A 挤挂)

如果你用的是 KAI 的软共享,调度层能把 A/B 放到同一张物理 GPU 上,但“最终会不会互相伤害”,依然取决于应用是否会越界吃显存。

7.5.1 Pod 侧:用 annotation 申请分数 GPU

下面假设 KAI 已使用 global.gpuSharing=true 安装,default-queue 已存在,Reservation Pod 使用的 RuntimeClass 能访问 NVML。两个 Pod 都用 gpu-fraction 申请分数(示例用 0.5 + 0.5):

apiVersion: v1
kind: Pod
metadata:
  name: infer-a
  labels:
    kai.scheduler/queue: default-queue
  annotations:
    gpu-fraction: "0.5"
spec:
  schedulerName: kai-scheduler
  containers:
    - name: app
      image: your-registry/infer:<pinned-version>
---
apiVersion: v1
kind: Pod
metadata:
  name: eval-b
  labels:
    kai.scheduler/queue: default-queue
  annotations:
    gpu-fraction: "0.5"
spec:
  schedulerName: kai-scheduler
  containers:
    - name: app
      image: your-registry/eval:<pinned-version>

注意:这里故意不写 resources.limits: nvidia.com/gpu,因为分数不是 K8s 原生资源;具体写法要以你集群里 KAI 的接入方式为准(有的会结合额外 CRD/策略)。

7.5.2 应用侧:显存“自限”是关键(以 PyTorch 为例)

要避免 noisy neighbor,最简单的手段就是在进程里主动限制可用显存。例如在服务启动最早期加上:

import torch

# 假设你希望这个进程最多使用本机 GPU 0 号设备的 50% 显存
# 只限制 PyTorch CUDA Caching Allocator,不是容器级隔离。
torch.cuda.memory.set_per_process_memory_fraction(0.5, device=0)

# 之后再加载模型/分配张量

常见坑:

  • 一定要在模型加载之前调用(否则模型权重先把显存占满了,后面再限没意义)
  • 多卡 / MIG / 容器里 CUDA_VISIBLE_DEVICES 变化时,device=0 指的是容器可见的第一个设备
  • 它不能约束其他进程,也不能限制绕过 PyTorch Caching Allocator 的 CUDA 分配

7.5.3 出问题怎么判断是不是“邻居干扰”

如果线上经常出现这些现象,很可能就是共享下的邻居干扰:

  • 推理延迟忽高忽低(batch 时间突然拉长)
  • CUDA out of memory 偶发,重启又好
  • 同一张卡上某个 Pod 的显存曲线锯齿很大,且和另一个 Pod 的峰值时间点重合

排查建议(偏实用):

  • 先用 nvidia-smi 看同一张卡上是不是同时有多个进程在跑、显存是否顶满
  • 如果你的监控里能按 Pod/容器维度采集 GPU 显存/利用率,优先对齐时间线看看“谁先把显存拉满”
  • 如果业务必须稳定,且无法要求所有应用自限,应评估 HAMi;需要故障域隔离时再使用 MIG/vGPU

最后怎么判断

KAI-Scheduler 的 Reservation Pod 机制,确实是一种非常“懂 Kubernetes”的解法:用 K8s 认可的方式把资源占住,再把复杂度留在调度器内部。

HAMi 补上了平台工程更关心的运行时约束,但它不是 MIG 级硬件隔离。

如果你正在做 GPU 平台,我的建议很朴素:

  • 想快速提高利用率:先把 KAI 这类调度共享跑通
  • 需要显存和计算边界:增加 HAMi 并验证 Hook 覆盖范围
  • 强合规或强故障隔离:保留 MIG/vGPU 资源池

生产验收仍要采集每个 Pod 的显存与 SM 使用,区分共享池和独占池,并为不适合共享的 workload 保留快速回退路径。

参考链接