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 这类方案走的是另一条路:不仅在调度层做分配,而且在运行时做强制限制,尽量把“共享”变成可控、可预测的多租户能力。
这篇文章不做选型,主要做三件事:
- 把 KAI 的 Reservation Pod 机制讲清楚(它到底“骗”了谁,解决了什么问题)。
- 把 HAMi 的“硬隔离”路径讲清楚(它强在哪,代价是什么)。
- 给一套更实用的结论:你应该怎么选,甚至怎么组合。
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 做的事是:
- 看到
gpu-fraction后,自己在内部选择“落在哪个节点的哪块物理 GPU 上”。 - 如果这块 GPU 还没人共享,KAI 会先创建一个 Reservation Pod:它会正儿八经请求
nvidia.com/gpu: "1"。 - 一旦 Reservation Pod 把整卡申请走了,kube-scheduler 就认为这张卡“没了”,自然不会再把整卡任务丢过来造成冲突。
- 之后所有分数任务都通过标签(例如
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 做的是“调度层的分账”,不是“执行层的强制限额”。
这会带来三个现实问题:
-
显存/算力没有强制上限
- 一个 Pod 申请
gpu-fraction: 0.2,理论上仍可能申请到更多显存
- 一个 Pod 申请
-
“吵闹邻居”无法从机制上阻止
- 即便都很“文明”,某次 batch 变大、某个模型热更新,都可能把隔壁顶掉
-
往往需要应用侧配合
- 比如 PyTorch 的
torch.cuda.set_per_process_memory_fraction(...) - 或者在 TensorFlow / Triton Server 里做显存策略
- 比如 PyTorch 的
一句话:
- 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 擅长):放上去之后,怎么保证“你申请多少,就最多用多少”
如果未来社区能把这两层接口对齐,理论上可以出现更理想的组合:
- KAI 负责漂亮的“共享调度体验”(annotation / policy / queue)
- 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、怎么自动止损)
- 以及“混合方案”在真实集群的落地姿势