前面兩天的排隊是同一個形狀:資源不夠就等。
Kueue 問「你有沒有額度」,沒有就把 Job 壓在 suspend;Volcano 問「你湊不湊得齊」,湊不齊就不綁。兩個的答案都是等。
但真實的叢集有一種很不甘心的狀況:配額用完了,機器卻還空著。
你的團隊配額是 4 張卡,現在用滿了。隔壁團隊今天沒人上班,他們的 4 張閒著。這時候你想跑一個實驗,要等嗎?
KAI 給的答案是第三種:你可以用,但要接受被趕走;不能接受的,就只能用保證的那一份。
KAI Scheduler 是一個 Kubernetes 排程器,專門為 AI 與機器學習工作負載最佳化 GPU 資源配置。它設計來管理大規模 GPU 叢集,涵蓋從小型互動式工作到大型訓練與推論的整個 AI 生命週期,而且可以與叢集上其他排程器並存。
它建立在 kube-batch 之上。這個名字昨天出現過,Volcano 的排程器同樣以 kube-batch 為基礎,兩者算是同一個祖先的兩條分支。目前它是 CNCF sandbox 專案。
來歷則和 NVIDIA 有關。KAI Scheduler 最初是在 Run:ai 平台內部開發的,NVIDIA 在 2025 年 4 月以 Apache 2.0 授權將它開源,同時它仍然包裝在 NVIDIA Run:ai 平台中出貨。
用法上和 Volcano 一樣是二級排程器。要讓 KAI 排一個工作需要兩件事:用 kai.scheduler/queue label 指定佇列名稱,並在 Pod spec 中把 schedulerName 設為 kai-scheduler。
Kueue 的 nominalQuota 是一條線:線以內可以用,線以外就等。KAI 的佇列則有兩個數字。
佇列有四個屬性:Quota 是保證的資源配額,Limit 是資源用量的硬上限,Over-Quota Weight 是同一優先權層級內資源分配的權重,Queue Priority 則決定超出配額時的分配順序。
幾個特殊值的行為如下:
| 欄位 | 值 | 行為 |
|---|---|---|
quota |
-1 |
無限配額 |
quota |
0 或未設定 |
沒有保證資源(預設) |
limit |
-1 |
沒有上限 |
limit |
0 或未設定 |
不允許額外資源(預設) |
佇列還有階層,而且只有葉子佇列(底下沒有子佇列的佇列)可以用來排程工作,父佇列只是組織單位,負責在子佇列之間分配資源。
兩條線中間那一段叫 over-quota。quota 是佇列有權取得的最低資源,不論其他佇列在做什麼,這一份都拿得到;over-quota 則是叢集在滿足所有配額之後還有餘裕時,佇列可以額外使用的資源。
分配因此分成兩個階段。第一階段每個佇列拿到自己的保證配額,也就是 min(quota, requested),這個階段佇列優先權完全不影響結果,所有佇列一視同仁。第二階段才把剩下來的容量依佇列優先權分出去,高優先權的佇列先拿,同優先權內再依 over-quota weight 按比例切。
誰可以用到 over-quota 那一段?取決於工作的優先級。KAI 部署時內建四個 priority class:
| Priority Class | 值 | 用途 |
|---|---|---|
train |
50 | 可搶佔的訓練工作 |
build-preemptible |
75 | 可搶佔的互動式工作 |
build |
100 | 不可搶佔的互動式工作 |
inference |
125 | 不可搶佔的推論工作 |
分界線寫得很死:值在 100 以上的 PriorityClass 視為不可搶佔,而不可搶佔的工作只能使用佇列的配額內資源,不能超出配額。換個說法就是,不可搶佔的工作永遠不會用到剩餘容量。
所以這是一個交換:可搶佔的工作能用超過配額的資源,代價是可能被驅逐;不可搶佔的工作不會被搶佔,代價是只能用配額以內的。
沒寫 priorityClassName 的話也有規定。KAI 會依工作負載型別套用預設值:Deployment 與 Knative Service 是 inference,Kubeflow Notebook 是 build,其他情況的一般預設則是 train。一般預設是 train,也就是可搶佔的,這會直接決定等一下兩個實驗的結果。要自己指定的話有三種寫法:在工作負載本身加 priorityClassName label、在它的 Pod 加同名 label,或是直接寫 Pod spec 的 priorityClassName。
沿用前兩天 fake-gpu-operator 的叢集,一台 worker 上有 8 張假卡。先確認 Kueue 和 Volcano 都還裝在上面:
kubectl get pods -A | grep -cE "kueue|volcano"
5
五個 Pod 還在跑,而接下來要再裝第三個排程器。Kubernetes 允許多個排程器並存,由 Pod 的 spec.schedulerName 決定由哪一個負責。它們看的是同一批節點,但各自只處理指定給自己的 Pod,今天的實驗也只會用到 KAI 的 Pod。
helm upgrade -i kai-scheduler \
https://github.com/kai-scheduler/KAI-Scheduler/releases/download/v0.17.2/kai-scheduler-v0.17.2.tgz \
-n kai-scheduler --create-namespace \
--wait --timeout 5m
本篇用的是 release 附的 tgz,官方目前建議的安裝方式則是 OCI chart,也就是 helm upgrade -i kai-scheduler oci://ghcr.io/kai-scheduler/kai-scheduler/kai-scheduler -n kai-scheduler --create-namespace --version <VERSION>。
kubectl get pods -n kai-scheduler
NAME READY STATUS RESTARTS AGE
admission-74877d84df-n8f5j 1/1 Running 0 66s
binder-654c9b7d4d-xzjgt 1/1 Running 0 66s
kai-operator-57844668b8-v9xrj 1/1 Running 0 88s
kai-scheduler-default-597766868c-vcpts 1/1 Running 0 66s
pod-grouper-689c447c78-n9qcx 1/1 Running 0 66s
podgroup-controller-6574458f78-wcgtq 1/1 Running 0 66s
queue-controller-7cd46bdf48-n65mc 1/1 Running 0 66s
七個 Pod。 對照前兩天:Kueue 一個、Volcano 三個、KAI 七個。其中兩個元件有專門的文件說明。
第一個是 binder,它負責把 Pod 實際放到選定的節點上,包含磁碟區、resource claim 等相依資源。把它從排程器拆出來有三個理由:綁定可能因節點狀態改變、資源競爭或 API server 問題而失敗,做成同一個元件時會連帶影響其他 Pod 的排程;綁定涉及多次 API 呼叫,遇到 DRA 或磁碟區時可能很慢,拆開之後排程器不必等它,可以先去排別的 Pod;失敗的綁定需要退避重試機制,留在排程器裡會增加複雜度。兩者透過一個叫 BindRequest 的自訂資源溝通,排程器決定好節點後建立 BindRequest,binder 監看到之後執行綁定、處理 DRA 與磁碟區配置、更新狀態,失敗則依退避策略重試。
第二個是 pod-grouper,它依照傳入的 Pod 自動建立與管理 PodGroup,讓屬於同一個邏輯工作負載的 Pod 被正確歸為一組,以支援 gang scheduling。它採用外掛式架構,先沿著 ownerReferences 找出 Pod 的最上層擁有者,再依工作負載型別套用對應的歸組策略,Deployment、Job、PyTorchJob 各有各的外掛。
安裝完成後會自動建立一組預設佇列階層,不需要手動設定。default-parent-queue 是頂層的父佇列,預設沒有保留資源配額,用來治理它底下的子佇列;default-queue 是父佇列底下的葉子佇列,工作負載應該指向這一個。
kubectl get queues.scheduling.run.ai
NAME PRIORITY PARENT CHILDREN
default-parent-queue ["default-queue"]
default-queue default-parent-queue
正好演示前面那個階層:工作只能送到葉子佇列。(CRD 的 API group 仍然叫 scheduling.run.ai,開源前的名字留在 API 裡。)
重點在它的 GPU 配額:
kubectl get queue default-queue -o jsonpath='{.spec.resources.gpu}'
{"limit":-1,"overQuotaWeight":1,"quota":0}
| 欄位 | 值 | 意思 |
|---|---|---|
quota |
0 | 保證給你零張卡 |
limit |
-1 | 沒有上限 |
overQuotaWeight |
1 | 剩餘資源的分配權重 |
保證什麼都沒有,但也什麼都不擋。 叢集的 8 張卡,全部落在 over-quota 那一段。
apiVersion: v1
kind: Pod
metadata:
name: solo
namespace: kai-test
labels:
kai.scheduler/queue: default-queue # 指定佇列
spec:
schedulerName: kai-scheduler # 交給 KAI 排程
restartPolicy: Never
containers:
- name: w
image: busybox:1.36
command: ["sh", "-c", "sleep 120"]
resources:
limits: { nvidia.com/gpu: 1 }
兩個標記就是前面提到的那兩項要求,缺一不可。
kubectl apply -f kai-test.yaml
kubectl -n kai-test get pods
NAME READY STATUS RESTARTS AGE
solo 1/1 Running 0 37s
跑起來了。但佇列的 quota 是 0,這個 Pod 要 1 張卡,它憑什麼跑?
因為它沒寫 priorityClassName,套用的是一般預設值 train,值 50,低於 100,屬於可搶佔的工作。可搶佔的工作能使用超出配額的資源,而 limit: -1 表示沒有上限,所以它落在 over-quota 那一段。
看它的 events:
kubectl -n kai-test describe pod solo | grep -A8 "^Events"
Normal Scheduled 45s kai-scheduler Successfully assigned pod kai-test/solo to node gpu-lab-worker at node-pool default
Normal Bound 45s binder Pod bound successfully to node gpu-lab-worker
Normal Pulled 44s kubelet ...
兩個不同的來源:kai-scheduler 做決定,binder 完成綁定,正是前面那個拆開的設計。另外注意 at node-pool default,KAI 有節點池的概念,這個實驗沒用到。
同一個 Pod 複製一份,只加一行:
spec:
schedulerName: kai-scheduler
priorityClassName: inference # 只加了這一行
restartPolicy: Never
...
其他完全一樣:一樣 1 張卡、一樣的佇列、一樣的 image。inference 的值是 125,在 100 以上,屬於不可搶佔。
kubectl apply -f kai-wall.yaml
sleep 15
kubectl -n kai-test get pods
NAME READY STATUS RESTARTS AGE
solo-inference 0/1 Pending 0 57s
排不上去,而理由 KAI 說得很完整:
kubectl -n kai-test describe pod solo-inference | grep -A8 "^Events"
Warning Unschedulable 47s (x25 over 71s) kai-scheduler
NonPreemptibleOverQuota: Non-preemptible workload is over quota.
Workload requested 1 GPUs, but default-queue quota is 0 GPUs,
while 0 GPUs are already allocated for non-preemptible pods.
Use a preemptible workload to go over quota.
一句一句拆:
| 訊息 | 在說什麼 |
|---|---|
Non-preemptible workload is over quota |
你是不可搶佔的,而你超出配額了 |
requested 1 GPUs, but quota is 0 GPUs |
你要 1,配額是 0 |
0 GPUs are already allocated for non-preemptible pods |
而且不是別人佔走的,本來就沒有 |
Use a preemptible workload to go over quota |
想超額,就別當不可搶佔的 |
這段訊息等於把前面那條規則念了一遍。
solo solo-inference
image busybox:1.36 busybox:1.36
GPU 1 1
queue default-queue default-queue
schedulerName kai-scheduler kai-scheduler
priorityClassName (沒寫,預設 train) inference
──────────── ────────────
結果 Running Pending
唯一的差別是一行 priorityClassName。同一張卡、同一個佇列、同樣的資源需求,一個進得去,一個進不去。而那張卡從頭到尾都是空的,8 張裡只有一個 Pod 在用。
它拿不到,不是因為沒有資源,是因為它要的是保證,而這個佇列的保證額度是 0。
昨天 Volcano 的 PodGroup 是一個看得見的東西。KAI 這邊我一個字都沒寫,先看看有沒有東西冒出來。
solo-inference 還 Pending 的時候,去撈 PodGroup:
kubectl get podgroups -A
No resources found
明明有一個 Pending 的 KAI Pod,卻說沒有。查一下就知道為什麼:
kubectl api-resources | grep -i podgroup
podgroups scheduling.k8s.io/v1alpha2 true PodGroup
podgroups pg scheduling.run.ai/v2alpha2 true PodGroup
podgroups pg,podgroup-v1beta1 scheduling.volcano.sh/v1beta1 true PodGroup
這個叢集裡有三個叫 podgroups 的資源:
| API group | 來源 |
|---|---|
scheduling.k8s.io/v1alpha2 |
Kubernetes 上游的 PodGroup API(KEP-5832 Decouple PodGroup API,搭配 KEP-4671 gang scheduling) |
scheduling.run.ai/v2alpha2 |
KAI |
scheduling.volcano.sh/v1beta1 |
Volcano |
第一個是 Kubernetes 內建的 API,不是任何人裝的 CRD。指名道姓才撈得到 KAI 那一個:
kubectl get podgroups.scheduling.run.ai -A
NAMESPACE NAME AGE
kai-test pg-solo-inference-d301b8f4-84c1-4285-954a-dc30567cfd1d 14h
所以 No resources found 不是真的沒有,是 kubectl 挑中了上游那張空表。
至於這個 PodGroup 的由來,沒有擁有者的 Pod 會拿到一個 MinMember 1 的 PodGroup,預設優先級是 train,本例則被 Pod 上的 priorityClassName 覆寫成 inference。
那換成有擁有者的工作負載呢?接下來兩個實驗分別看 Job 和 Deployment。
apiVersion: batch/v1
kind: Job
metadata:
name: plain-job
namespace: kai-pg
spec:
parallelism: 3
completions: 3
template:
metadata:
labels:
kai.scheduler/queue: default-queue
spec:
schedulerName: kai-scheduler
restartPolicy: Never
containers:
- name: c
image: registry.k8s.io/e2e-test-images/busybox:1.29-4
command: ["sh","-c","sleep 600"]
全篇只有兩處跟 KAI 有關:schedulerName 和那個 queue label,PodGroup 一個字都沒寫。
kubectl create ns kai-pg
kubectl apply -f podgroup-plain-job.yaml
kubectl -n kai-pg get pods
NAME READY STATUS RESTARTS AGE
plain-job-5c7n2 1/1 Running 0 6s
plain-job-62m9k 1/1 Running 0 6s
plain-job-wqfd6 1/1 Running 0 6s
kubectl -n kai-pg get podgroups.scheduling.run.ai \
-o custom-columns='NAME:.metadata.name,MIN:.spec.minMember,QUEUE:.spec.queue,PRIO:.spec.priorityClassName,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER_NAME:.metadata.ownerReferences[0].name'
NAME MIN QUEUE PRIO OWNER_KIND OWNER_NAME
pg-plain-job-a52a2308-ffc1-4f60-be3c-19c6ed291e83 1 default-queue train Job plain-job
一個 PodGroup、三個 Pod 共用,名字是 pg-<Job 名字>-<Job UID>,ownerReferences 指回 Job。但 minMember 是 1,不是 3,和 parallelism 沒有關係。
這是文件寫明的設計。對 Job 資源,pod-grouper 會建立一個對應 Job 身分的 PodGroup,把 MinMember 預設設為 1,理由是原生的 Kubernetes batch job 通常不需要 gang scheduling;同時把 PodGroup 的 priority class 設為 train,好讓它可以超出配額。換個角度描述同一件事:一般的 batch job 產生的多個 Pod 是各自分開排程的,會同時執行還是先後執行,取決於叢集的可用資源。
要 gang 就得自己宣告,方式是在 Job 或 JobSet 資源上加 kai.scheduler/batch-min-member annotation,指定至少要有幾個 Pod 一起被排程。文件舉的例子是 parallelism 6 但只要求 2 個一起啟動,適用於像超參數搜尋這種需要一定平行度、但不必全部同時執行的工作。
同一份東西,只把工作負載換成 apps/v1 Deployment、replicas: 3,其他完全一樣:
apiVersion: apps/v1
kind: Deployment
metadata:
name: plain-deploy
namespace: kai-pg
spec:
replicas: 3
selector:
matchLabels: {app: plain-deploy}
template:
metadata:
labels:
app: plain-deploy
kai.scheduler/queue: default-queue
spec:
schedulerName: kai-scheduler
containers:
- name: c
image: registry.k8s.io/e2e-test-images/busybox:1.29-4
command: ["sh","-c","sleep 600"]
kubectl -n kai-pg get podgroups.scheduling.run.ai \
-o custom-columns='NAME:.metadata.name,MIN:.spec.minMember,PRIO:.spec.priorityClassName,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER_NAME:.metadata.ownerReferences[0].name'
NAME MIN PRIO OWNER_KIND OWNER_NAME
pg-plain-deploy-69cdc5777c-6khw6-88b53162-4a06-454d-bb94-a9d79a144b84 1 inference Pod plain-deploy-69cdc5777c-6khw6
pg-plain-deploy-69cdc5777c-stc67-b1b341aa-f457-4c78-9851-7f74dae8b7f1 1 inference Pod plain-deploy-69cdc5777c-stc67
pg-plain-deploy-69cdc5777c-xj66q-f5e86f0d-47c8-4175-992e-8cb78d4ad04c 1 inference Pod plain-deploy-69cdc5777c-xj66q
pg-plain-job-a52a2308-ffc1-4f60-be3c-19c6ed291e83 1 train Job plain-job
同樣是三個 Pod、同樣的 label 和 schedulerName,只換了工作負載型別,PodGroup 就從一個變三個、owner 從 Job 變成 Pod 自己、priority class 從 train 變成 inference。pod-grouper 判斷的依據是工作負載型別,不是 Pod 數量。 唯一沒變的是 minMember,兩邊都是 1。
它幫你把 PodGroup 建出來,但不幫你決定要幾個一起上車。 原生 Job 的 minMember 預設是 1,等於預設沒有 gang,要 gang 得自己加 kai.scheduler/batch-min-member。
但這不代表 KAI 不做 gang。文件的 PyTorchJob 範例就明講,一個 master 加兩個 worker,因為使用 gang scheduling,三個 Pod 會一起被排程,或在資源足夠之前都不排。差別在於 pod-grouper 對不同工作負載型別套用不同的歸組策略。
和 Volcano 並排:
| Volcano | KAI | |
|---|---|---|
| 入口 | 要改用 batch.volcano.sh/v1alpha1 的 Job |
吃原生 batch/v1 Job、apps/v1 Deployment |
| PodGroup 誰建的 | controller 自動建立 | pod-grouper 自動建立 |
| 原生 Job 的 gang | 不適用(要改用它的 Job 型別) | 預設關閉(minMember: 1) |
| gang 怎麼宣告 | Job spec 的 minAvailable |
kai.scheduler/batch-min-member annotation |
| 安裝的元件數 | 3 | 7 |
兩邊的 PodGroup 都是自動產生的,真正的差別在入口:Volcano 要你改用它自己的 Job CRD,KAI 吃原生的工作負載。
今天介紹了 KAI Scheduler。它的佇列有兩個數字:quota 是保證給你的,limit 是硬上限,兩者之間那段是 over-quota。誰能用到那一段取決於優先級:值在 100 以上的工作不可搶佔,只能用配額以內的資源;100 以下的可搶佔,能超出配額,代價是可能被趕走。實驗中兩個 Pod 完全相同,只差一行 priorityClassName,一個跑起來、一個停在 Pending,而那張卡從頭到尾都空著。
三天下來,三個排程器問的問題不同:Kueue 問你有沒有額度,Volcano 問你湊不湊得齊,KAI 問你接不接受被趕走。KAI 的答案不是「有或沒有」,而是一個交換,剛好對應線上推論與研究實驗這兩種不同的工作。
NVIDIA/KAI-Scheduler
KAI Scheduler — Quick Start
KAI Scheduler — Scheduling Queues
KAI Scheduler — Workload Priority
KAI Scheduler — Scheduling Deep Dive
KAI Scheduler — Batch Scheduling
KAI Scheduler — Pod Grouper
KAI Scheduler — Binder
KAI Scheduler — Dynamic Resource Allocation
NVIDIA Developer Blog — NVIDIA Open Sources Run:ai Scheduler
KEP-5832 — Decouple PodGroup API
Kubernetes — Configure Multiple Schedulers