4 min read
0.1 · 算力层的四层心智模型
The 4-Layer Mental Model for AI Compute

这是我正在写的一个关于 GPU 容量与调度系列的开篇基础文章——它是我反复回到的那个心智模型,也是后面所有文章的地基。

TL;DR

一个 GPU 集群并不是单一系统,而是四个叠在一起的系统;我见过的几乎每一次让人头疼的线上事故,都源于把其中两层搞混了。这四层是:

  1. 容量供给(Capacity supply)——你拥有使用权的一块预留资源池
  2. 置备(Provisioning)——把这块资源池变成真正在运行的机器(节点)。
  3. 调度(Scheduling)——把工作单元(Pod)放到这些机器上。
  4. 工作负载(Workload)——真正消耗 GPU 的训练或推理任务。

需要记住的一点:这是四个不同的系统,通常由不同的人负责——在你能把它们分开命名之前,你其实没法真正地推理一个 GPU 集群。

写作动机:先有地图,再谈地形

这里没有什么惊心动魄的故事——我想先把这点说清楚。这是一篇打地基的文章:它是后面整个系列依赖的心智模型,老实说,也是我当初刚开始拼凑 GPU 基础设施时,希望有人能先帮我画出来的那张图。真正的故事在后面——co-location、抢占、回收——这篇只是让那些故事读起来有意义的共同词汇。

它要解决的日常痛点是这样的:「我的 Pod 卡在 Pending」大概是关于 GPU 集群最常被说起的一句话——而它作为一个描述几乎毫无用处,因为 Pending 可能意味着四件完全不同的事,分别由四拨不同的人负责:

  • 容量团队说「预留是 active 的,我们在付费」
  • 平台团队说「有 Pod 处于 Pending」
  • 调度器说「我没有任何地方可以放它们」

三句话可以同时成立,而每一句指向不同的一层。没有一张共同的地图,这场对话只会原地打转;有了地图,一句话就能定位。这篇文章就是那张地图。(真正去调试这个 Pending 症状是另一篇——0.3;这里我只想要地图本身。)

开始之前

如果你大致知道 Kubernetes 的 Pod 和 Node 是什么、以及 GPU 是一种可以被申请的资源,你会读得最顺。不需要任何特定的调度器;demo 只需要 kind 加任意一个容器运行时(Docker / Podman / Colima)——不需要 GPU 硬件。读完之后,你应该能在大约一分钟内,把任何「我的 Pod 卡住了」的问题对应到正确的那一层。

心智模型(一张图)

                         WHAT IT IS              FAILS LOOK LIKE
  ┌─────────────────────────────────────────────────────────────────────┐
  │ L4  WORKLOAD          the training / inference   "loss diverged",     │
  │     (consumes GPUs)   job that burns the GPU      OOM, NCCL hang      │
  ├─────────────────────────────────────────────────────────────────────┤
  │ L3  SCHEDULING        place / share / preempt      pod Pending:       │
  │     (the scheduler)   units of work onto nodes     "no node fits"     │
  ├─────────────────────────────────────────────────────────────────────┤
  │ L2  PROVISIONING      turn the pool into            node NotReady,    │
  │     (autoscaler)      actual running machines       launch stuck      │
  ├─────────────────────────────────────────────────────────────────────┤
  │ L1  CAPACITY SUPPLY   the reserved pool of          "no capacity",    │
  │     (reservations)    hardware you can claim        alloc errors      │
  └─────────────────────────────────────────────────────────────────────┘
            ▲                                                    │
            └──────── the SEAM (L1 ↔ L3): pool ⇄ schedulable ────┘
                      where paid-for capacity goes idle

从下往上读,因为容量实际就是这样流动的:

名词它回答的问题通常归谁负责
L1 容量供给一块预留资源池「我到底有没有对硬件的使用权?」容量 / FinOps
L2 置备一台运行中的机器「这份使用权是不是一个就绪(Ready)的节点?」平台 / autoscaler
L3 调度一个工作单元「这个 Pod 会被放到某个节点上吗?」调度器 / 平台
L4 工作负载那个任务「放上去的活儿跑得对不对、值不值?」ML 团队

诀窍在于:症状几乎总是出现在 L3(「我的 Pod 卡在 Pending」),无论真正出问题的是哪一层。L3 只是那个报信的人。这个模型的作用,就是让你别再迁怒于报信的人。

逐层拆解

L1 — 容量供给。 这是对硬件的使用权,而不是正在运行的硬件。一个预留、一个 capacity block、一份配额。关键性质:你可能在 L1 已经开始付费,却在 L2 一无所有。 一块「active」但还没变成节点的预留资源池,就是纯成本、零能力。这里的失败听起来像「容量不足」——每家云都有自己的叫法(AWS InsufficientInstanceCapacity、GCP ZONE_RESOURCE_POOL_EXHAUSTED、Azure AllocationFailed)——或者干脆「配额是零」。

L2 — 置备。 这一步把使用权变成一个 Ready 的节点。autoscaler 向 L1 要机器,然后等 30 秒到 5 分钟,让它启动、加入集群、通过健康检查。这里的失败:节点卡在 NotReady;或者 GPU 驱动 / device plugin 还没装好;或者启动一直挂着。从 L3 的视角看,一个还在置备中的节点根本不存在

L3 — 调度。 有了就绪的节点,决定哪个 Pod 放到哪里:过滤(哪些节点放得下)、打分(哪个最合适)、绑定。真正丰富的问题都在这一层——gang 调度、公平性、抢占、拓扑——也是这个系列大部分篇幅要讲的。但要注意:L3 只能把活儿放到 L2 从 L1 的资源池里真正产出的节点上。它变不出容量。

L4 — 工作负载。 任务本身:并行方式、checkpoint、KV-cache、batching。一个调度得完美的任务,仍然可能把 GPU 的大部分算力浪费掉——Model FLOPs Utilization(MFU)远低于 1.0;即使是调得很好的大型训练,也常常只落在 40–55% 一带——而这是 L4 / 效率问题,不是调度问题。另一篇文章,另一种解法。

关于 L1↔L3 之间那道缝的提醒。 这两层通常由不同的团队、看不同的看板负责——L1 的看板说「预留 active,$X/小时」,L3 的看板说「N 个 Pod 处于 Pending」。没有哪个看板显示它们之间的那段差距:已经付费、却从未变得可调度的容量。这道缝特别容易被所有人忽略,恰恰因为没有任何一层真正拥有它。

模型会在哪里漏(因为没有哪个四格模型是全部真相)

两个诚实的提醒,免得这个模型在边角处误导你:

  • DaemonSet 和 static pod 并不真的玩 L3 这套游戏。 它们按设计就被放到每个(或某个特定)节点上,绕过了过滤 / 打分的竞争。「所有症状都在 L3 表现为 Pending」这条规则,说的是用户工作负载争抢稀缺 GPU——不包括系统 Pod。
  • Dynamic Resource Allocation(DRA)模糊了 L2 和 L3。 随着 DRA 在 Kubernetes 中成熟(结构化参数的资源申领),一些这个模型归到 L2/置备的分配决策,其实发生在调度过程中。四层依然是一张有用的地图;只是要知道 L2↔L3 这条线在变模糊,而不是变清晰。

如果你的问题正好落在这些角落里,这个模型会把你指到大致的街区,然后礼貌地退到一边。

Demo:在一台笔记本上看清四层

实话说明:这个 demo 跑在 kind(Kubernetes 跑在 Docker 里)上,GPU 是假造到节点上的——没有真实硬件。目的是把四层看成四个各自独立、可观察的东西,而不是给 GPU 跑分。下面每一条命令和输出都来自一次真实运行(kind v0.32,Kubernetes v1.36.1)。如果你不熟悉 kubectl,每一步都会说明它做了什么、你该看到什么。

关键选择:用一个多节点集群,这样调度器才真有地方可以在其中做选择——这正是让 L3 变得可见、而不是靠脑补的原因。

搭建——一个 3 节点集群(1 个 control-plane + 2 个 worker)。

cat > kind-layers.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
EOF

kind create cluster --name layers --config kind-layers.yaml
# 新建的节点会短暂处于 NotReady(一个会自动消失的 taint);等它们就绪。
kubectl wait --for=condition=Ready nodes --all --timeout=90s
node/layers-control-plane condition met
node/layers-worker condition met
node/layers-worker2 condition met

三台机器,零个 GPU。(如果后面某个 applyserviceaccount "default" not found,说明集群还在启动——等几秒重试即可。)

L1 — 容量供给:预留。 在任何 GPU 存在之前,你先持有一个预留——一份对尚未变成可用节点的硬件的使用权。在云上,这就是 AWS Capacity Block for ML、GCP 的 future/calendar reservation,或 Azure 的 capacity reservation;对于像 H200 或 GB200 这样紧缺的型号,没有预留你基本上根本起不来一块 GPUkind 没有「预留」这个原语,所以这里的预留用这样一个事实来表示:我们的 worker 节点存在,但对外宣告的可调度 GPU 是——使用权在手,但还没有任何东西能在上面跑:

WORKER=$(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' \
          -o jsonpath='{.items[0].metadata.name}')
kubectl get node "$WORKER" -o jsonpath='gpu=[{.status.capacity.nvidia\.com/gpu}]{"\n"}'
gpu=[]

空的。节点在那儿(预留持有着),但它还不提供任何 GPU。这正是一块付了费的预留可能在烧钱的那道缝——你握着 H200,却没有任何东西被调度上去。

L2 — 置备:把预留变成可调度的 GPU。 在云上,autoscaler 启动那台预留的实例,它加入集群,它的 GPU device plugin 把 GPU 宣告给 Kubernetes——正是这一步让预留的容量真正可被调度kind 的机器已经在那儿了,所以我们用「在每个 worker 上宣告一块 GPU」来替代 device plugin:

for n in $(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' \
            -o jsonpath='{.items[*].metadata.name}'); do
  kubectl patch node "$n" --subresource=status --type=json \
    -p '[{"op":"add","path":"/status/capacity/nvidia.com~1gpu","value":"1"}]'
done

kubectl get node "$WORKER" -o jsonpath='gpu=[{.status.capacity.nvidia\.com/gpu}]{"\n"}'
node/layers-worker patched
node/layers-worker2 patched
gpu=[1]

这个 gpu=[]gpu=[1] 的翻转就是 L1→L2 的边界:一个预留的存在(一份使用权),对比一个节点真正提供出一块调度器可以往上放活儿的 GPU。现在我们有两个 worker,每个一块可调度的 GPU。

L3 — 调度:调度器选择每个 Pod 跑在哪里。 提交三个 Pod,每个都申请一块 GPU:

for p in trainer-a trainer-b trainer-c; do
  kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: $p
spec:
  containers:
  - name: c
    image: busybox
    command: ["sh", "-c", "sleep 3600"]
    resources:
      limits:
        nvidia.com/gpu: 1
EOF
done

kubectl get pods -o wide      # NODE 列显示的就是调度器的决定
NAME        READY   STATUS    NODE
trainer-a   1/1     Running   layers-worker2
trainer-b   1/1     Running   layers-worker
trainer-c   0/1     Pending   <none>

(真实的 -o wide 还会打印 RESTARTS / AGE / IP / NOMINATED NODE;这里为了宽度做了精简。)

这就是 L3 在干它真正的活儿。两块 GPU 分布在两个节点上;调度器把 trainer-atrainer-b 放到了不同的 worker 上——选哪个节点就是那个决策——而把 trainer-c 留在 Pending,因为两块 GPU 都被占了。放置和 Pending 是同一层的两面:有容量就把活儿放上去,没有就拒绝。

L4 — 工作负载:在一个运行中的 Pod 里面跑活儿。 Pod 是调度器填进去的那个坑位;工作负载是在这个坑位里跑的东西。我们已经看着调度器把 trainer-a 放到了一个 GPU 节点上(那是 L3)——所以接下来就在那个同一个 Pod 里把活儿跑起来,不杀掉、也不重新调度任何东西。kubectl exec 会在一个运行中的容器里跑一个进程。我们的工作负载故意搞得很简单——打一个时间戳,「干活」三步,再打一次时间——作为真实训练脚本的替身:

kubectl exec trainer-a -- sh -c 'echo "[trainer-a] $(date +%H:%M:%S) starting"; for i in 1 2 3; do echo "[trainer-a] step $i"; sleep 1; done; echo "[trainer-a] $(date +%H:%M:%S) done"'
[trainer-a] 14:53:58 starting
[trainer-a] step 1
[trainer-a] step 2
[trainer-a] step 3
[trainer-a] 14:54:01 done

这就是 L4:工作负载跑在这个 Pod 持有的 GPU 上,而且 Pod 自始至终都活着——kubectl get pods 仍然显示 trainer-atrainer-b 处于 Running,什么都没被拆掉。注意 exec 完全没碰调度器:放置决策(L3)只在 Pod 被创建时发生过一次;工作负载只是在这个 Pod 已经占下的坑位里跑。现实里也是这个形状——一个训练 Pod 被调度一次,然后在里面跑很多步。

(另一个常见的做法是 Kubernetes Job:它创建自己的一个新 Pod,跑到完成,并报告 Complete——当你希望系统去追踪一个任务是否跑完时很好用。同样是这四层;Job 只是把「调度一个新 Pod」和「跑活儿」捆进了一个对象里,而 exec 让我们把这两件事分开来看。)

kind delete cluster --name layers     # 清理

你刚刚逐层看到的东西:

我们做了什么看起来是什么样
L1 容量供给持有一个预留——节点存在,但 GPU 还不可调度gpu=[]
L2 置备device plugin 在每个 worker 上宣告 GPUgpu=[]gpu=[1]
L3 调度调度器把 Pod 放到节点上,并拒绝了第三个trainer-a→worker2trainer-b→workertrainer-c Pending
L4 工作负载在运行中的 trainer-a 里面跑了工作负载(exec打了一个时间戳 + 三步;Pod 一直 Running

四层,四种可观察的行为——而不只是「一个 Pod 需要一块 GPU」。当出问题时,缺的是这四种行为里的哪一种,就是整个诊断问题的核心——而这正是这个四层模型给你的东西。

小结

四个叠在一起的系统——预留、置备、调度、工作负载——每一个都是一个独立、可观察的东西,而不是一团笼统的「GPU 集群」。实际的好处不大但很实在:出问题时,你能说出是这四者中的哪一个,而不用靠猜。后面的每篇文章都会深入其中一层。


这是一个个人博客——观点是我自己的,不代表我的雇主;这里的一切都基于公开文档中的特性。