CFN Cloud
2026-01-26

KAI-Scheduler vs HAMi:Kubernetes GPU 共享的两条路(软隔离 vs 硬隔离)

从工程视角拆解 KAI-Scheduler 的 Reservation Pod 机制,以及 HAMi 的硬隔离路径;对比两者在调度表达、隔离保障、落地成本与适用场景上的差异,并给出可组合的协同思路。

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

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

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

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

这篇文章不做选型,主要做三件事:

  1. 把 KAI 的 Reservation Pod 机制讲清楚(它到底“骗”了谁,解决了什么问题)。
  2. 把 HAMi 的“硬隔离”路径讲清楚(它强在哪,代价是什么)。
  3. 给一套更实用的结论:你应该怎么选,甚至怎么组合。

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”。从代码形态上看,大致像这种流程:

  • 寻找 node 上可用的 GPU
  • 选择一个更适合共享的 GPU
  • 进行共享分配(必要时创建 reservation)

(函数名示意)

func AllocateFractionalGPUTaskToNode(ssn *framework.Session, stmt *framework.Statement,
  pod *pod_info.PodInfo, node *node_info.NodeInfo, isPipelineOnly bool) bool {

  fittingGPUs := ssn.FittingGPUs(node, pod)
  gpuForSharing := getNodePreferableGpuForSharing(fittingGPUs, node, pod, isPipelineOnly)
  if gpuForSharing == nil {
    return false
  }

  pod.GPUGroups = gpuForSharing.Groups
  success := allocateSharedGPUTask(ssn, stmt, node, pod, isPipelineOnly)
  if !success {
    pod.GPUGroups = nil
  }
  return success
}

你可以把它理解为:先选“共享哪张卡”,再决定“要不要建 reservation”

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 更像是“共享车位 + 自觉停车”
  • 真正隔离要靠“物理隔离栏”

5) HAMi 的路线:把“隔离”交给更底层

HAMi 的核心诉求就是解决上面那句“自觉停车”的问题:

不靠君子协定,靠机制保障。

典型做法会包含两部分:

  • Device Plugin 侧:把可分配的 GPU 资源以某种形式暴露给 K8s(或者把一张卡切成多个可调度单元)
  • 运行时/Driver 侧:通过对 CUDA Runtime/Driver 的拦截或控制,实现对显存等资源的强制上限

这就带来“硬隔离”的关键收益:

  • 资源有上限:超额就失败,不会把邻居挤死
  • QoS 更稳:更适合多租户生产环境
  • 对业务更透明:通常不需要业务代码显式设置“我只用 20% 显存”

当然,代价同样现实:

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

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

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

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

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

6.2 你更适合 HAMi(偏硬隔离)如果:

  • 你是平台团队,需要面向多个业务方 / 多租户
  • 你要的是 稳定 QoS,不接受“隔壁突然 OOM 把你搞挂”
  • 希望对应用透明,用户只要“申请资源”而不是“写资源限制代码”

7) 一个更现实的协同方向:调度策略 + 硬隔离能力

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

  • 上层调度策略(KAI 擅长):怎么更聪明地把作业放到哪张卡、怎么打散/聚合、怎么做队列/优先级
  • 下层资源保障(HAMi 擅长):放上去之后,怎么保证“你申请多少,就最多用多少”

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

  1. KAI 负责漂亮的“共享调度体验”(annotation / policy / queue)
  2. HAMi 或类似机制提供底层硬隔离(避免 noisy neighbor)

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

  • 既不牺牲 K8s 生态兼容性
  • 又能把共享变成“可运营”的能力

7.5 实战:在共享 GPU 上跑两个推理/训练任务(KAI 软共享怎么“自保”)

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

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

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

7.5.1 Pod 侧:用 annotation 申请分数 GPU

两个 Pod 都用 gpu-fraction 申请分数(示例用 0.5 + 0.5):

apiVersion: v1
kind: Pod
metadata:
  name: infer-a
  annotations:
    gpu-fraction: "0.5"
spec:
  containers:
    - name: app
      image: your-registry/infer:latest
---
apiVersion: v1
kind: Pod
metadata:
  name: eval-b
  annotations:
    gpu-fraction: "0.5"
spec:
  containers:
    - name: app
      image: your-registry/eval:latest

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

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

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

import torch

# 假设你希望这个进程最多使用本机 GPU 0 号设备的 50% 显存
# 这个值不是“绝对可靠的硬隔离”,但足够在很多推理场景里减少互相踩踏。
torch.cuda.set_per_process_memory_fraction(0.5, device=0)

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

常见坑:

  • 一定要在模型加载之前调用(否则模型权重先把显存占满了,后面再限没意义)
  • 多卡 / MIG / 容器里 CUDA_VISIBLE_DEVICES 变化时,device=0 指的是容器可见的第一个设备

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

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

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

排查建议(偏实用):

  • 先用 nvidia-smi 看同一张卡上是不是同时有多个进程在跑、显存是否顶满
  • 如果你的监控里能按 Pod/容器维度采集 GPU 显存/利用率,优先对齐时间线看看“谁先把显存拉满”
  • 如果业务必须稳定,且你没法要求所有应用都做到自限/自律,那通常就该认真考虑硬隔离

结语

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

HAMi 的路线则更像“平台工程”的长期主义:共享必须可控,否则共享只是把风险往线上挪。

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

  • 想快速把利用率拉起来:先把 KAI 这类软共享跑通
  • 想把共享做成生产能力:一定要准备硬隔离(或者至少对 noisy neighbor 有可操作的兜底)

有机会我会再补一篇更“落地操作”的文章:

  • 共享 GPU 的可观测性(怎么知道谁在抢显存/算力)
  • 共享 GPU 的故障场景(为什么会 OOM、怎么自动止损)
  • 以及“混合方案”在真实集群的落地姿势