本文是《AI 平台工程:资源层与交付层》系列的第 1 篇(共九篇)。下一篇:容器里的 GPU——驱动、CUDA、device plugin 与镜像。
一个刚装好的 Kubernetes 集群,三台 worker 每台插着一张 GPU。提交一个只有十几行的 Pod,resources.limits 里写 nvidia.com/gpu: 1,它一直 Pending。kubectl describe pod 的 Events 里只有一行:0/4 nodes are available: 3 Insufficient nvidia.com/gpu。三张卡明明在那里,nvidia-smi 在宿主机上能看到,调度器却说”不够”。
这一行事件是本系列的起点,因为它精确地暴露了原生 Kubernetes 与 AI 负载之间的第一道缝:调度器不知道什么是 GPU。它只知道节点的 status.allocatable 里有没有一个叫 nvidia.com/gpu 的键、值是几;没人往里写这个键,值就是零,零小于一,所以 Insufficient。要让这行事件消失,得装 device plugin——这是第二篇的事。但装完之后,缝并没有合上,只是换了位置:32 个 Pod 的训练任务调度了 30 个就卡住、两张卡在同一台机器上却走了 PCIe、推理服务的 HPA 盯着 CPU 利用率永远不扩容、一张 80 GB 的卡跑一个 7B 模型剩下 60 GB 没人能用。每一道缝后面都有一个组件,每个组件的存在都是因为引擎提出了某个原生 Kubernetes 没打算满足的要求。
所以在装任何组件之前,值得先把这些要求列出来。训练框架要什么?推理引擎要什么?两者要的东西哪些一致、哪些冲突?原生 Kubernetes 能满足哪些、满足不了哪些?满足不了的那些,分别由哪个组件、在哪一层去填?这一篇不装任何东西,只做这份清单,并用它把后面七篇的组件放到一张图上。
本篇的核心问题:
一个 4 节点 32 卡的训练任务和一个 TP=2、副本数动态变化的推理服务,各自对平台提出的需求列成一张表,哪几条是原生 Kubernetes 满足不了的?1
全文的版本锚点:Kubernetes v1.37.0;引擎侧只引用 PyTorch 2.13.0 的 torchrun 环境变量与 rendezvous 语义、vLLM v0.28.0 的指标名与启动参数,不进入任何引擎内部实现。平台组件的版本随全景图逐一标注,全部发布于本文日期之前。
一、总览
1. 引擎的需求
平台之上跑的是两类引擎,它们对平台的要求可以各自压缩成一句话。
训练框架:给我 \(W\) 个进程,每个进程一张卡,让它们同时起来、能互相找到、之间有高带宽网络,再给我一个能周期性写几百 GB 的地方——然后别打断我。这句话里的每个词都是一条需求:
W 个进程同时起来 → 整数个 GPU;全员到齐才能开始(gang)
互相找到 → 稳定的地址(MASTER_ADDR)与编号(RANK / WORLD_SIZE)
高带宽网络 → 节点内 NVLink、节点间 RDMA;拓扑接近
周期性写几百 GB → checkpoint 的突发顺序写带宽
别打断我 → 抢占要考虑上次 checkpoint 之后的损失;一个进程挂了要整组重启
推理引擎:给我一张(或几张)显存够放权重加 KV cache 的卡,让我把权重加载完再接流量,副本多少按我内部的队列长度来定,请求别用轮询发给我。同样拆开:
显存够放 → 显存是硬约束,不能超卖;小模型只需要一张卡的一部分
加载完再接流量 → 扩容时间由权重加载决定,就绪探针要等它
按我内部的队列长度 → 扩缩容信号是 vllm:num_requests_waiting 这类引擎指标,不是 CPU
别轮询 → 路由要知道每个副本的 KV cache 与队列状态
第二、三章把这两句话展开成两张需求清单,第四章把两张清单放在一起看它们的冲突。
2. K8s 的空缺
Kubernetes 的核心对象——Pod、Deployment、Service、Job——建立在四个假设上:工作负载是单 Pod 独立的、资源是可细分可压缩的 CPU 与内存、网络是一张 overlay 网卡、服务的负载信号是CPU 利用率。AI 负载逐条违背这四个假设,于是有了四类空缺:
假设 AI 负载的现实 空缺
──────────────────────────── ──────────────────────────────────── ──────────────────────────────
Pod 之间独立,逐个调度 训练是 W 个 Pod 强耦合,缺一不可 gang scheduling、队列与配额、拓扑
资源是 CPU / 内存,可细分 GPU 是整数个、有型号、有拓扑、不能超卖 设备发现与属性、共享与切分
一张 overlay 网卡 NCCL 需要直接打开 RDMA 设备 第二张网卡、RDMA 设备直通
Service 轮询 + HPA 看 CPU 副本状态在引擎内部;请求长短差百倍 多 Pod 副本、引擎指标扩缩容、状态感知路由
第五章逐条说明这些空缺在原生对象上具体表现为什么,哪些只是”缺个插件”,哪些是模型层面的缺失。
3. 平台的机制(全局图)
填这些空缺的组件,按”引擎之下”与”引擎之侧”分成两层。资源层的输入是裸节点与引擎的资源请求,输出是一组配好 GPU、网络、存储、能互相找到的 Pod;交付层的输入是这些 Pod,输出是一个有地址、有 SLA、有配额、有账单的服务。
请求(OpenAI 兼容协议)
│
交付层 ┌────────────────────────▼────────────────────────────────────────────────────────┐
│ 模型网关 Gateway API Inference Extension v1.6.0(InferencePool)+ EPP(llm-d-router v0.10.0)│ 第七篇
│ Serving KServe v0.20.0 · llm-d v0.9.0 · LeaderWorkerSet v0.10.0 · KEDA v2.20.2 │ 第六篇
│ 可观测 DCGM Exporter 4.6.0-4.8.3 · vllm:* 指标 · OpenCost v1.121.1 │ 第八篇
└────────────────────────┬────────────────────────────────────────────────────────┘
│ 一组能接流量的 Pod / 一组已就位的训练 Pod
资源层 ┌────────────────────────▼────────────────────────────────────────────────────────┐
│ 任务表达 Kubeflow Trainer v2.3.0(TrainJob)· KubeRay v1.7.0(RayJob / RayService) │ 第三篇
│ 调度 Kueue v0.19.2(配额闸门)· Volcano v1.15.2(批调度器) │ 第三篇
│ 切分 MIG(GPU Operator MIG Manager)· 时间片 / MPS(device plugin)· HAMi v2.10.0 │ 第四篇
│ 网络存储 Network Operator v26.7.0 · Multus v4.3.0 · RDMA shared dev plugin v1.5.4 · CSI │ 第五篇
│ 设备 GPU Operator v26.7.0 · k8s-device-plugin v0.20.0 · DRA(Kubernetes v1.37.0) │ 第二篇
└────────────────────────┬────────────────────────────────────────────────────────┘
│ 裸节点:驱动、GPU、网卡、本地盘
┌─────▼─────┐
│ 节点池 │ 同位替代:Slurm(Slinky slurm-operator v1.2.2)· Ray
└───────────┘
第六章展开这张图:每一层的输入输出、组件为什么落在这一层、Slurm 与 Ray 为什么是”同位替代”而不是”另一层”。
4. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 训练任务的形态与需求 | torchrun 的进程组与 rendezvous;MASTER_ADDR / WORLD_SIZE / RANK;一个进程挂掉为什么全停;四条资源需求 |
| 三 | 推理服务的形态与需求 | 副本的三种形态;prefill 与 decode;显存硬约束;扩容时间;负载信号在引擎内部 |
| 四 | 两组矛盾的需求 | 独占 vs 共享、拓扑 vs 弹性、批处理 vs 长驻;混部为什么难;两者的共同点 |
| 五 | 原生 Kubernetes 的空缺 | kube-scheduler 的逐 Pod 模型;device plugin 的计数模型;一张网卡;Service 与 HPA 的假设 |
| 六 | 平台的两层拆分 | 资源层与交付层的输入输出;组件全景图按层落位;Slurm 与 Ray 的位置 |
| 七 | 核心问题:两张需求表 | 32 卡训练任务与 TP=2 推理服务各一张表,”原生 K8s 能否满足”列与补缺的篇目 |
| 八 | 全系列术语表 | gang、cohort、ResourceFlavor、MIG profile、InferencePool、EPP、TTFT/TPOT、goodput、分配率与使用率 |
| 九 | 代价与边界 | 四栏表;每叠一层的代价;本系列的边界;托管服务替你做了什么 |
| 十 | 小结 | 要点、源码与 CRD 位置、mini-platform 的第一批文件 |
二、训练任务的形态与需求
1. torchrun 启动的进程组
一个多机训练任务在平台眼里是一组进程。以 PyTorch 为例,每台机器上运行一个 torchrun(PyTorch 2.13.0 的 torch/distributed/run.py),它是一个 agent:本机起 --nproc-per-node 个 worker 进程,各绑一张卡,然后盯着它们。--nnodes 台机器上的 agent 通过 rendezvous 相互发现,共同构成一个大小为 \(W = \text{nnodes} \times \text{nproc\_per\_node}\) 的进程组。
节点 0 节点 1 节点 2 节点 3
torchrun (agent) torchrun (agent) torchrun (agent) torchrun (agent)
├─ worker RANK 0 GPU 0 ├─ worker RANK 8 GPU 0 ├─ worker RANK 16 GPU 0 ├─ worker RANK 24 GPU 0
├─ worker RANK 1 GPU 1 ├─ worker RANK 9 GPU 1 ├─ ... ├─ ...
├─ ... ├─ ... │ │
└─ worker RANK 7 GPU 7 └─ worker RANK 15 GPU 7 └─ worker RANK 23 GPU 7 └─ worker RANK 31 GPU 7
▲
rendezvous:--rdzv-backend=c10d --rdzv-endpoint=<节点 0 地址>:29400 --rdzv-id=<任务 ID>
run.py 的模块文档把这个结构定义为三层:Worker 是训练进程,LocalWorkerGroup 是一台机器上的 worker 集合,WorkerGroup 是全部节点 worker 的并集。平台需要理解的只有一件事:这 \(W\) 个进程在逻辑上是一个不可分的整体,它们的编号由 rendezvous 统一分配,它们的通信拓扑在起来的那一刻就固定了。
2. rendezvous 与三个环境变量
rendezvous 是”\(W\) 个进程如何互相找到并达成一致编号”的协议。torchrun 用 --rdzv-backend(默认推荐 c10d,即 PyTorch 自带的 TCP store)、--rdzv-endpoint(store 所在的地址与端口)、--rdzv-id(本任务的唯一 ID)三个参数定位它。所有节点的 agent 连到同一个 endpoint、报同一个 ID,torch/distributed/elastic/rendezvous/dynamic_rendezvous.py 里的 _DistributedRendezvousOpExecutor 等到参与者数量达到 RendezvousSettings.min_nodes(默认等于 --nnodes),关闭这一轮 rendezvous,给每个节点分配一个 group_rank。
之后每个 agent 为它的 worker 注入一组环境变量。run.py 的文档列了十几个,平台关心的是这三个加两个:
MASTER_ADDR RANK 0 所在节点的地址;进程组用它初始化 c10d TCP store(后续 NCCL 的通信也从这个 store 起步)
MASTER_PORT MASTER_ADDR 上的端口
WORLD_SIZE 进程组的总大小 W
RANK 本进程在 0..W-1 中的全局编号
LOCAL_RANK 本进程在本节点上的编号(0..nproc_per_node-1),代码里 device_ids=[int(os.environ["LOCAL_RANK"])]
这五个变量是训练框架与平台之间最窄的接口。任何一个把训练任务搬进 Kubernetes 的方案——Kubeflow Trainer 的 TrainJob、Volcano 的 vcjob、手写的 Indexed Job——本质上都在做同一件事:让每个 Pod 起来时能算出自己的 RANK,能解析出 MASTER_ADDR 指向的那个 Pod。Kubeflow Trainer 2.3.0 在 pkg/constants/constants.go 里定义的 PET_NNODES、PET_MASTER_ADDR 等常量正是这些变量的 torchrun 别名(PET_ 前缀由 torchrun 识别为参数)。这要求平台提供两样东西:稳定的 DNS 名(Pod 重启后地址不变,否则 MASTER_ADDR 失效)与稳定的序号(Pod 与 RANK 的映射不变)。原生 Kubernetes 用 headless Service 加 StatefulSet 或 Indexed Job 可以做到,但这只是必要条件,第三章会看到还缺什么。
3. 为什么一个进程挂掉整个任务就停
分布式训练的每一步都以集合通信结束:所有 \(W\) 个进程各算一份梯度,然后 all-reduce 求平均,每个进程都要等到其他 \(W-1\) 个进程的数据到齐才能继续。这是算法上的同步点,与框架无关。一个进程挂了,其余 \(W-1\) 个进程会卡在下一次集合通信上,直到超时——在那之前它们占着 GPU 什么都不做。
torchrun 把这个事实做成了显式的策略。run.py 文档的 “Failure Modes” 一节写明:\(n\) 个 worker 中任意 \(k \le n\) 个失败,所有 worker 被停止并重启,最多 --max-restarts 次。实现上,torch/distributed/elastic/agent/server/api.py 的 SimpleElasticAgent._invoke_run() 每隔 monitor_interval 调用 _monitor_workers(),一旦返回 WorkerState.FAILED 或 UNHEALTHY,就调用 _restart_workers()——停掉本地整组、重新 rendezvous、重新分配 RANK 与 WORLD_SIZE、重新启动。因为所有节点的 agent 共享同一个 rendezvous,一个节点重启 worker 组会让其他节点在下一轮 rendezvous 中察觉并跟着重启。至于 agent 自身或整个节点消失,文档明确写:由任务管理器决定是让整个任务失败(”gang semantics”)还是补一个节点——这一步是平台的责任。
两条故障路径与两个责任方画在一起:
%% 图:两条故障路径与两个责任方:worker 挂了 torchrun 在 --max-restarts 内自己闭环,agent 或节点消失由平台按 gang 或补节点处理
flowchart TB
subgraph torch_side["torchrun 自己处理"]
W["某个 worker 进程退出或不健康"] --> M["本机 agent _monitor_workers()<br/>看到 FAILED / UNHEALTHY"]
M --> R{"已重启次数 < --max-restarts ?"}
R -- "是" --> K["_restart_workers()<br/>停掉本机整组 worker"]
K --> RZ["重新 rendezvous<br/>其他节点 agent 察觉,跟着停组重来"]
RZ --> RA["重新分配 RANK / WORLD_SIZE<br/>全部 worker 从上次 checkpoint 恢复"]
R -- "否" --> F["agent 报告任务失败"]
end
subgraph plat_side["平台(任务管理器)处理"]
A["agent 进程或整个节点消失"] --> P{"gang 语义还是补节点 ?"}
P -- "gang" --> FJ["整个任务失败<br/>释放全部 GPU,等重新排队"]
P -- "补节点" --> RP["调度一个新 Pod 顶替<br/>等它加入 rendezvous"]
end
RP --> RZ
classDef torch fill:#e8f0fe,stroke:#3b6fd1;
classDef plat fill:#fdf1e0,stroke:#d18a2b;
class W,M,R,K,RZ,RA,F torch;
class A,P,FJ,RP plat;
左边是 torchrun 在 --max-restarts 之内能自己闭环的部分:worker 挂了,本机停组、全员重新 rendezvous、重新编号、从 checkpoint 继续,平台看到的只是 Pod 还在 Running。右边是 torchrun 管不到的部分:agent 或节点没了,对应的 Pod 消失,要么整组重建(gang),要么补一个 Pod 让它回到左边的 rendezvous 路径——两种选择都要由平台做出。
对平台的推论有三条:
- 全员到齐才算开始。\(W\) 个 Pod 里只起了 \(W-1\) 个,起来的那些在 rendezvous 里等着,占着 GPU 不干活。等待的时间越长浪费越大,如果最后一个 Pod 永远等不到资源,就是死锁的开端(第三篇)。
- 失败的单位是任务,不是 Pod。Kubernetes Job 的
backoffLimit数的是单个 Pod 的重启次数;训练任务需要的是”任何一个 Pod 失败 → 整组 Pod 一起重建”。 - 重启后要能从 checkpoint 继续。
--max-restarts之内的重启由torchrun处理,但重启后的进程是全新的,它们的状态来自上一次 checkpoint。checkpoint 多久写一次,决定一次故障丢多少进度,也决定平台抢占它的代价。
4. 四条资源需求
把上面的形态翻译成对硬件的要求:
整数个 GPU,且每个进程一张。训练进程的显存占用(参数、梯度、优化器状态、激活)是按整卡规划的,没有”半张卡”的用法;也没有两个训练进程共享一张卡的用法——NCCL 的通信假定每个 rank 独占它的设备。对 4 节点 32 卡任务,需求是”4 个 Pod,每个 Pod 恰好 8 张卡,且这 8 张卡在同一节点上”。
节点间高带宽、低延迟网络。数据并行的 all-reduce 每步要在节点间交换与梯度同量级的数据,一个 70B 模型的 bf16 梯度是 140 GB 量级,按 ring all-reduce 每个节点收发约 \(2 \times\) 这个量的 \((N-1)/N\)。走 TCP overlay 的几十 Gbps 与走 RDMA 的几百 Gbps,差的是这一步要几秒还是几十秒——直接反映在 MFU 上。此外节点间的拓扑有区别:同一机柜或同一 spine 下的节点之间带宽高、跨 spine 要争抢上行链路。这两条构成第五篇(RDMA 进容器)和第三篇(拓扑感知调度)的需求来源。
周期性的大块顺序写(checkpoint)。一个训练任务每隔十几到几十分钟把完整状态写一遍,量级从几十 GB(小模型)到 1 TB 级(70B 加 Adam 状态)。写的时候训练暂停或降速,所以要求的是突发写带宽:1 TB 在 1 分钟内写完是约 17 GB/s 的聚合写入。平均带宽很低——大部分时间不写——这与按平均带宽选型的存储直觉相反。
持续的小块随机读(数据集)。训练数据是海量小文件或大文件内的随机偏移读,每步读一个 batch,量不大但持续、随机、并发(每个 rank 各自读)。它要的是 IOPS 与元数据性能,与 checkpoint 要的吞吐是两种存储画像,第五篇会讨论它们能不能用同一套存储。
5. 训练任务的生命周期
最后是时间维度。训练任务是有限时长的批处理:提交、排队、被调度、运行数小时到数周、结束或失败、释放全部资源。它的运行时间由数据量和算力决定,与外部请求无关;它在排队时不占资源,在运行时独占资源,中间没有”部分运行”的状态(弹性训练是例外,但改变 \(W\) 要重新 rendezvous,是重启而非平滑扩缩)。
提交 ──▶ 排队(等配额 / 等资源凑齐)──▶ 全员调度 ──▶ rendezvous ──▶ 运行 ──▶ 完成 / 失败 ──▶ 释放全部 GPU
│ │
│ └─ 每 T 分钟写一次 checkpoint
└─ 任一 Pod 失败 → 整组重启 → 从 checkpoint 恢复
这个生命周期对平台的含义是:排队是正常状态,需要队列、配额和公平性;运行中被打断的代价可量化——上次 checkpoint 之后的所有算力白费——所以抢占策略要知道这个代价;结束后资源整体归还,适合按任务而非按时间切片记账。
三、推理服务的形态与需求
1. 一个副本的三种形态
推理服务的基本单位是副本(replica):一个能独立服务完整请求的引擎实例。副本的物理形态取决于模型放不放得进一张卡:
形态 模型规模(bf16 权重) K8s 对象 备注
────────────────────── ────────────────────── ─────────────── ─────────────────────────────────────
一个进程,一张卡 ≲ 60 GB(7B–30B) Pod × 1 最简单;一张 80 GB 卡放权重 + KV cache
一个进程,一节点多卡 60 GB–600 GB(70B–405B) Pod × 1,多 GPU TP 在节点内走 NVLink;--tensor-parallel-size=N
多个 Pod,一个副本 单节点放不下 Pod × M(一组) TP / PP 跨节点;M 个 Pod 是一个整体,缺一不可
第三种形态与训练任务的形状一样:一组 Pod 强耦合、同时起、同时停、有 leader 有 worker。但它是长驻服务而不是批处理,需要滚动升级、按组重启、按组扩缩。Deployment 的副本是单 Pod,StatefulSet 的序号是单 Pod——原生对象里没有”多 Pod 一个副本”的概念,这是 LeaderWorkerSet 存在的原因(第六篇)。
本文核心问题里的 TP=2 服务属于第二种:每个副本是一个 Pod,请求 2 张卡,最好在 NVLink 相连的一对上。
2. prefill 与 decode
一个 LLM 请求在引擎里分两个阶段。prefill 把整段 prompt 一次算完,生成第一个 token,是计算密集的——几千个 token 并行通过模型,GPU 的算力被填满。decode 之后每步只生成一个 token,每步都要把全部权重从显存读一遍,是显存带宽密集的——GPU 算力大部分时间空着,瓶颈是每秒能读多少次权重。
这个差别对平台有两个后果。第一,同一张卡上 prefill 与 decode 混跑时互相干扰:一个长 prompt 的 prefill 会让正在 decode 的所有请求那一步变慢,用户看到的是输出突然卡一下。第二,两个阶段的最优硬件不同:prefill 要算力,decode 要显存带宽和容量。PD 分离(prefill 与 decode 跑在不同的 Pod 组里,中间传 KV cache)把它们拆开,各自扩缩——这意味着一个服务有两种副本、两套扩缩容指标、副本之间还要传数据,第六篇和第七篇会处理它的部署与路由形态。本文只需要知道:推理服务的”副本”可能不止一种。
对应两个阶段的两个延迟指标贯穿交付层三篇:TTFT(time to first token,prefill 阶段加排队时间)与 TPOT(time per output token,decode 每步的时间)。vLLM v0.28.0 在 vllm/v1/metrics/loggers.py 的 PrometheusStatLogger 里以 vllm:time_to_first_token_seconds 和 vllm:inter_token_latency_seconds 两个直方图暴露它们。
3. 显存是硬约束
一个副本的显存由两部分组成:权重(模型大小 × 每参数字节,固定)与 KV cache(每个在处理的 token 的 key/value,随并发与上下文长度增长)。引擎启动时按 --gpu-memory-utilization(vLLM v0.28.0 vllm/config/cache.py 的 CacheConfig.gpu_memory_utilization,默认 0.92)预留这一比例的显存,权重占掉一块,剩下的全部划给 KV cache。KV cache 的大小决定同时能服务多少请求;KV cache 满了,新请求进等待队列,vllm:num_requests_waiting 上升,TTFT 拉长。
同一张 80 GB 卡上,权重占多少直接决定 KV cache 剩多少:
80 GB 卡,--gpu-memory-utilization=0.92 → 引擎预留 73.6 GB,其余留给 CUDA context 等
7B bf16 ┌───────────┬────────────────────────────────────────────────┬─────┐
│ 权重 14GB │ KV cache ~60 GB │ 空闲│
└───────────┴────────────────────────────────────────────────┴─────┘
30B bf16 ┌────────────────────────────────────────────────┬───────────┬─────┐
│ 权重 60 GB │ KV ~14 GB │ 空闲│
└────────────────────────────────────────────────┴───────────┴─────┘
0 10 20 30 40 50 60 70 80 GB
KV cache 决定同时能服务多少 token;两张图的 KV 段差 4 倍多,意味着同样并发下 30B 的副本数要多几倍。这带来三条平台约束:
- 不能超卖。显存不像 CPU 可以时间片轮转,也不像内存可以 swap。两个引擎进程被放到同一张卡上、各自按 92% 预留,第二个直接 OOM。平台要么给整卡,要么用有显存隔离的切分方式(第四篇)。
- 模型越大,权重占比越高,能服务的并发越低。一张 80 GB 的卡跑 7B bf16 模型,权重 14 GB、KV cache 可用约 60 GB;跑 30B 模型,权重 60 GB、KV cache 只剩十几 GB。同样的卡,后者的副本数要多好几倍才能撑住同样的并发。容量规划不能只数卡。
- 小模型浪费大。7B 模型 14 GB 权重,如果流量不高,KV cache 用不到 60 GB,这张卡的大部分显存与算力空着。这是切分(MIG / HAMi / 时间片)的需求来源——但切分与”不能超卖”直接冲突,第四篇的全部内容是在这两者之间找位置。
4. 扩容一个副本要多久
一个 Web 服务的副本扩容以秒计:调度、拉一个几百 MB 的镜像、进程起来、就绪探针通过。推理副本的扩容多了一个主项:把几十到几百 GB 的权重从存储读进显存。
阶段 典型耗时 取决于
──────────────────────── ────────────────── ────────────────────────────────────
调度 + 等 GPU 空出 秒到分钟 有没有空卡;要不要等节点扩容(云上加节点是分钟级)
拉镜像 分钟级(10 GB 级镜像) 镜像大小;节点有没有缓存;registry 带宽
加载权重到显存 分钟级(70B ≈ 140 GB) 存储读带宽(对象存储 / PFS / 本地 NVMe);节点网卡
引擎初始化(profiling、图捕获)几十秒到分钟 模型与引擎配置
预热 几十秒 第一批请求触发的编译、缓存
一个 70B 模型的副本从”决定扩容”到”能接流量”以分钟计,常见的数字是 5–10 分钟。这有两个后果。第一,扩缩容必须提前触发:等队列已经排起来再扩,新副本就绪时高峰已经过了;这要求预测式或阈值很低的扩容策略(第六篇)。第二,缩容到零很贵:省下的是空闲时的 GPU 费用,付出的是下一个请求要等几分钟。就绪探针要等到权重加载与预热完成才能返回成功,否则请求会被路由到一个还在加载的副本上。
5. 负载信号在引擎内部
Kubernetes 原生的 HPA 以 CPU 与内存利用率为默认信号。对推理服务这两个信号都无意义:GPU 上的 kernel 在跑时 CPU 几乎空闲,内存(宿主机内存)也与负载无关。GPU 利用率(DCGM 的 DCGM_FI_DEV_GPU_UTIL)看起来相关,但它只表示”这段时间内有没有 kernel 在跑”,一个 decode 请求就能让它接近 100%,与”还能接多少请求”无关(第八篇)。
真正能说明负载的数字在引擎内部。vLLM v0.28.0 的 vllm/v1/metrics/loggers.py 里 PrometheusStatLogger 定义的指标中,与扩缩容和路由直接相关的是:
vllm:num_requests_running 正在 decode 的请求数
vllm:num_requests_waiting 在等待队列里的请求数 ← 扩容的主信号
vllm:kv_cache_usage_perc KV cache 使用比例 0–1 ← 饱和度;路由的副本选择依据
vllm:prefix_cache_hits / _queries prefix cache 命中与查询次数 ← 同前缀请求去同副本的收益
vllm:num_preemptions 因显存不足被抢占(重算)的请求数
vllm:time_to_first_token_seconds TTFT 直方图
vllm:inter_token_latency_seconds TPOT 直方图
vllm:request_queue_time_seconds 请求在队列里等了多久
平台要做的是把这些指标从每个副本采出来、聚合、接到扩缩容控制器(KEDA 的 Prometheus scaler 或 HPA 的 external metrics)和路由器(Endpoint Picker 直接抓每个副本的 /metrics)。这条链路上每个环节——采集周期、聚合方式、阈值——都会影响扩容延迟和路由质量。指标由引擎定义、由平台消费,是引擎与交付层之间的接口,第六、七、八篇反复用到。
6. 推理服务的生命周期
与训练相反,推理服务是长驻的:它的运行时间由业务决定,负载随时间波动,副本数随负载变化,版本要在不停服的情况下更新。
部署 ──▶ 副本就绪(加载权重)──▶ 接流量 ──▶ 随负载扩缩 ──▶ 滚动升级(新旧版本并存)──▶ ... ──▶ 下线
│
└─ 低谷时缩到最少副本或零;高峰前提前扩容
它对平台的含义:没有”结束”,成本是持续的,空闲副本的费用与忙时一样;扩缩是常态,每一次扩容都是一次几分钟的冷启动;升级是有状态的——一个副本在处理流式请求时被终止,用户看到的是回复中断,所以替换副本要等在途请求完成。
四、两组矛盾的需求
1. 对照表
把第二、三章的需求并排放:
维度 训练任务 推理服务
─────────────── ──────────────────────────────────────── ────────────────────────────────────────
资源粒度 整卡,整节点,越多越好 整卡或一部分卡;小模型只要一张卡的几分之一
调度单位 W 个 Pod 一组,全员到齐才开始 每个副本独立就绪;多 Pod 副本时组内 gang
拓扑 极度敏感:节点内 NVLink、节点间同机柜 TP 在节点内敏感;副本之间无关,越分散越好(容灾)
时长 有限:小时到周 无限:长驻
资源变化 运行中不变(改 W 要重启) 副本数随负载分钟级变化
被打断的代价 丢上次 checkpoint 之后的进度 丢在途请求;一个副本重启是分钟级冷启动
负载信号 无(跑满就是目标) 引擎内部的队列长度与 KV cache 使用率
网络 节点间 RDMA 带宽是瓶颈 节点间流量小;南北向的请求流量与流式长连接
存储 周期性突发写 + 持续随机读 启动时一次性大块顺序读(权重)
排队 正常状态,可以等几小时 不能等:排队直接是用户看到的 TTFT
成本视角 按任务:卡数 × 时长 按服务:副本数 × 时长,折算到每百万 token
2. 三对矛盾
独占 vs 共享。训练要整卡整节点独占,NCCL 假定它是设备唯一的用户;推理的小模型要几个服务共享一张卡才划算。同一个集群里两者共存,切分策略只能按节点池划:这几台切、那几台不切,改一台机器的 MIG 配置要清空它上面的所有进程。
拓扑 vs 弹性。训练要 32 张卡尽量聚在拓扑上相近的位置,宁可多等一会儿也不要跨 spine;推理要副本随时能扩出来,哪里有空卡就用哪里,分散反而好。一个偏好”凑齐再给”的调度器与一个偏好”有就给”的调度器,在同一个资源池上要做的决定相反。
批处理 vs 长驻。训练的资源用完归还,适合排队、配额、公平分享;推理常驻,占着的资源不会自己释放,只能靠缩容。训练排队时被抢占是可接受的(丢一次 checkpoint 的进度),推理副本被抢占是用户可见的故障。混部时谁能抢谁、抢的代价怎么算,是配额与优先级设计的核心。
3. 共同点
矛盾之下有三个共同点,它们是平台设计能统一的部分:
- 两者都要”多 Pod 一个单位”。训练的 \(W\) 个 Pod、跨节点推理副本的 \(M\) 个 Pod,都需要同时调度、同时销毁、稳定的组内寻址。这是 gang 语义与 LeaderWorkerSet / JobSet 这类”Pod 组”抽象共用的基础。
- 两者都要 GPU 有属性。训练要同型号同拓扑,推理要按显存大小选卡。device plugin 的”计数”模型对两者都不够,DRA 的属性选择对两者都有用。
- 两者的成本都要落到 GPU 时间上。训练按任务算、推理按 token 算,最后都要分摊到”哪张卡在哪段时间归谁”。可观测与成本的底层是同一份数据。
这也是为什么本系列把两者放在同一个平台里讨论:资源层的大部分机制对两者通用,差别集中在调度策略与交付层。
五、原生 Kubernetes 的假设与空缺
1. kube-scheduler:一个 Pod 一个 Pod 地调度
kube-scheduler 的工作单位是单个 Pod。它从队列里取一个 Pod,跑一遍 Filter(哪些节点放得下)与 Score(放哪个最好),选一个节点绑定,然后取下一个。它不知道这个 Pod 与另外 31 个 Pod 是一组,也不会因为第 31 个 Pod 放不下就撤回前 30 个的绑定。
Filter 阶段对 GPU 的判断在 pkg/scheduler/framework/plugins/noderesources/fit.go(Kubernetes v1.37.0)的 Fits() 与 fitsRequest():把 Pod 请求的每种资源与节点 allocatable 减去已分配量比较,任一不足就返回一个 InsufficientResource,Reason 字段对扩展资源就是 Insufficient <资源名>。开头那行 Insufficient nvidia.com/gpu 就是这里生成的。对调度器来说,nvidia.com/gpu 只是一个字符串键加一个整数——与 example.com/foo 没有任何区别。
这个模型对训练任务的后果是:32 个 Pod 逐个调度,前 30 个成功、后 2 个 Pending,前 30 个占着 240 张卡等待;另一个任务的 Pod 恰好拿走了剩下的卡,两个任务都凑不齐、都不释放——死锁。用一个更小的池把这个状态画出来:
池:6 台 8 卡节点 = 48 张卡;任务 A、B 同时提交,各要 4 个 Pod × 8 卡
节点1 节点2 节点3 节点4 节点5 节点6
调度顺序 A-0 B-0 A-1 B-1 A-2 B-2 ← 逐 Pod 交替绑定
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│A 8卡│ │B 8卡│ │A 8卡│ │B 8卡│ │A 8卡│ │B 8卡│ 48 张卡全部被占
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
Pending A-3 等第 4 个节点 B-3 等第 4 个节点
A 的 24 张卡在 rendezvous 里等 A-3,B 的 24 张卡在等 B-3;
两边都不释放、都凑不齐 → 死锁。
gang 调度的做法:凑不齐 4 个节点,就一个 Pod 也不绑定。
原生的缓解手段只有 PriorityClass 与抢占,但抢占也是按单 Pod 决定的。gang scheduling 必须由一个知道”组”的调度器或准入控制器实现,这是第三篇。
kube-scheduler 也不知道拓扑。它有 nodeAffinity 与 podAffinity,可以表达”这些 Pod 要在有某个 label 的节点上”,但不能表达”这 4 个 Pod 要在同一个机柜里,哪个机柜都行”——podAffinity 的 topologyKey 只能把 Pod 往已有 Pod 所在的域聚,第一个 Pod 落哪里是随机的,而且它是逐 Pod 判断,不会为了整组的拓扑退回重选。
2. device plugin:计数模型
节点上 allocatable 里的 nvidia.com/gpu 是 device plugin 上报的。机制的核心是两个 gRPC 调用(Kubernetes v1.37.0 staging/src/k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1/api.proto):ListAndWatch 把设备列表流式推给 kubelet,kubelet 据此更新节点 status.capacity / status.allocatable;Allocate 在 Pod 启动时被调用,返回要注入容器的设备文件与环境变量。kubelet 侧的 pkg/kubelet/cm/devicemanager/manager.go 的 ManagerImpl 维护每个资源名的设备集合与已分配映射。
这个模型的表达能力是每种资源名一个整数。它能说”这个节点有 8 个 nvidia.com/gpu“,不能说”其中 4 个是 A100、4 个是 H100”、”这 8 个的 NVLink 拓扑是什么”、”每个有多少显存”。Pod 侧也只能说”我要 2 个”,不能说”我要 2 个有 NVLink 直连的”或”我要 1 个显存 ≥ 40 GB 的”。要区分型号只能靠节点 label 加 nodeSelector,把属性从设备挪到节点上,前提是节点内的卡同构。
它也不能共享:一个设备 ID 只能分配给一个容器。要让两个 Pod 用同一张卡,device plugin 只能”撒谎”——把一张卡上报成 N 个虚拟设备(时间片模式),代价是没有任何隔离。
DRA(resource.k8s.io/v1,Kubernetes v1.37.0 的 staging/src/k8s.io/api/resource/v1/types.go)用 ResourceSlice 发布带属性的设备、用 ResourceClaim 里的 CEL 表达式按属性选择,是对这个模型的替代。device plugin 与 DRA 的机制、边界与当前状态是第二篇的内容;本文只需要记住:默认情况下 GPU 在 Kubernetes 里是一个不透明的整数。
3. 一张网卡
CNI 给每个 Pod 一张 veth 网卡,接到 overlay 或 underlay 网络。NCCL 要的不是这张网卡:它要打开 /dev/infiniband/* 下的 RDMA 设备、注册 GPU 显存做 GPUDirect RDMA、绕过内核网络栈。这两件事默认的 Pod 都做不到——设备文件不在容器里,RDMA 设备的网络命名空间归属也要配置。给 Pod 第二张网卡(Multus)、把 RDMA 设备作为扩展资源分配给 Pod(RDMA device plugin)、在宿主机上装对的驱动(Network Operator),三件事缺一个 NCCL 就回退到 TCP,带宽掉一个数量级而不报错。第五篇处理这条链。
存储侧原生的 PVC 与 CSI 抽象足够:checkpoint 写到 PVC、数据集从 PVC 读。缺的不是接口而是容量规划:Kubernetes 不知道这个卷要在 1 分钟内吃下 1 TB 的突发写,也不知道扩容 6 个副本时会有 6 个 Pod 同时读同一份 140 GB 的权重。这是存储选型的问题,第五篇给算术。
4. Deployment、Service 与 HPA
交付侧的三个原生对象各有一个不成立的假设。
Deployment 假设副本是一个 Pod,Pod 之间可互换、无序、独立就绪。跨节点的 TP 副本是 \(M\) 个 Pod、有 leader / worker 角色、组内要稳定寻址、要一起滚动。StatefulSet 给了序号与稳定 DNS,但序号是 Pod 级的,没有”组”。
Service 假设后端可互换,kube-proxy 按轮询或随机分发。推理副本不可互换:一个副本 KV cache 已满、另一个空着,轮询会把请求送到满的那个排队;同一前缀的请求去同一副本能命中 prefix cache,轮询完全不管。请求还是流式长连接,时长差百倍,连接数均衡不等于负载均衡。Gateway API 的 HTTPRoute 能按路径与 header 路由、能按权重分流,但选哪个后端 Pod 还是 Service 的事——除非把后端换成 InferencePool(第七篇)。
HPA 假设信号是 CPU / 内存,或通过 metrics API 接入的自定义指标。前者对推理无意义;后者要一条从引擎 /metrics 到 Prometheus 到 metrics adapter 的管线,HPA 自身不提供。HPA 也不能缩到零。KEDA 补的正是这两条(第六篇)。
5. 空缺清单
汇总起来,原生 Kubernetes 在 AI 负载面前有三种性质的空缺:
性质 表现 填法
───────────── ────────────────────────────────────────────────────── ─────────────────────────────
缺一个插件 GPU 不被识别(Insufficient nvidia.com/gpu);RDMA 设备不在容器里 device plugin / Operator,机制不变
缺一个概念 "一组 Pod"(gang、多 Pod 副本);"设备的属性";"配额与队列" 新 CRD + 控制器,或调度器扩展;改变对象模型
缺一个信号 扩缩容看不到引擎队列;路由看不到副本状态;成本看不到 token 指标管线 + 扩展点(KEDA、EPP、OpenCost)
第一类装上就好,第二类要改用新的对象来表达工作负载,第三类要把引擎与平台之间的信息通道建起来。后面七篇大致按这个难度递进。
六、平台的两层拆分
1. 资源层:输入与输出
资源层的任务是把裸节点变成引擎能直接用的运行环境。
输入 节点:装了驱动的 GPU、RDMA 网卡、本地 NVMe;引擎的请求:N 张某型号的卡、M 个 Pod 一组、要 RDMA、要挂某个卷
输出 一组已调度、已启动的 Pod,容器里看得到 GPU 与 RDMA 设备、挂上了存储、彼此能以稳定 DNS 名互访、环境变量齐全
它内部又分四个子层,自下而上对应第二到第五篇:
- 设备层(第二篇):让容器看到 GPU。驱动与 CUDA 的兼容契约、Container Toolkit 把设备文件与用户态库注入容器、device plugin 或 DRA 把设备变成可调度的资源、GPU Operator 把这一切装到每个节点、镜像里放什么不放什么。GPU Operator v26.7.0 的
ClusterPolicy(api/nvidia/v1/clusterpolicy_types.go的ClusterPolicySpec)是这一层的总开关。 - 调度层(第三篇):决定哪组 Pod 在什么时候上哪些节点。Kueue v0.19.2 是”调度器之前的配额闸门”——
ClusterQueue/LocalQueue/ResourceFlavor/ cohort(apis/kueue/v1beta2/clusterqueue_types.go的ClusterQueueSpec.CohortName)决定一个 Workload 何时被准入,准入后交给 kube-scheduler;Volcano v1.15.2 是”一个更懂批处理的调度器”——PodGroup(MinMember)与Queue(capability/deserved/guarantee)加 action-plugin 流水线,替换 kube-scheduler 做 gang 与公平分享。任务的表达由 Kubeflow Trainer v2.3.0 的TrainJob/TrainingRuntime(pkg/apis/trainer/v1alpha1,基于 JobSet)与 KubeRay v1.7.0 的RayJob(ray-operator/apis/ray/v1)承担。 - 切分层(第四篇):让一张卡被多个 Pod 用。MIG 是硬件分区(GPU Operator 的 MIG Manager 按
nvidia.com/mig.config节点标签配置)、时间片与 MPS 是 device plugin 的sharing配置(k8s-device-plugin v0.20.0 的api/config/v1)、HAMi v2.10.0 是软件层的显存与算力限制。 - 网络与存储层(第五篇):Network Operator v26.7.0 的
NicClusterPolicy装 RDMA 驱动栈、Multus v4.3.0 给 Pod 第二张网卡、k8s-rdma-shared-dev-plugin v1.5.4 把 RDMA 设备作为资源分配;存储通过 CSI 接并行文件系统、对象存储与缓存层。
2. 交付层:输入与输出
交付层的任务是把一组能跑的引擎 Pod 变成一个可以卖给用户的服务。
输入 资源层交出的引擎 Pod(或声明式的"我要 N 个这样的副本");请求流量;租户与配额策略
输出 一个稳定的服务端点:按模型名路由、按租户限流、副本随负载伸缩、有 TTFT/TPOT 的 SLO、有按 token 的账单
三个子层自内向外对应第六到第八篇:
- Serving 层(第六篇):副本的生命周期。LeaderWorkerSet v0.10.0 的
LeaderWorkerSet(api/leaderworkerset/v1/leaderworkerset_types.go的LeaderWorkerSetSpec.LeaderWorkerTemplate)表达多 Pod 副本;KServe v0.20.0 的InferenceService(serving.kserve.io/v1beta1)面向传统模型、LLMInferenceService(serving.kserve.io/v1alpha1,pkg/apis/serving/v1alpha1/llm_inference_service_types.go)面向 LLM 并内置 llm-d 的架构;llm-d v0.9.0 是 vLLM 与 Kubernetes 社区共同推动的 LLM Serving 栈;KubeRay 的RayService把 Ray Serve 放到 Kubernetes 上;KEDA v2.20.2 的ScaledObject(apis/keda/v1alpha1/scaledobject_types.go,triggers字段接 Prometheus scaler)按引擎指标扩缩容。 - 网关层(第七篇):请求的路由与治理。Gateway API Inference Extension v1.6.0 的
InferencePool(inference.networking.k8s.io/v1,api/v1/inferencepool_types.go,EndpointPickerRef字段指向 EPP)替代 Service 作为HTTPRoute的后端;Endpoint Picker(EPP)在 v1.6.0 已迁出到 llm-d-router v0.10.0(cmd/epp、pkg/epp),按每个副本的队列、KV cache 使用率与已加载的 LoRA 选目标;InferenceObjective在 llm-d-router 的apix/v1alpha2表达请求优先级。多租户、配额、灰度建立在这个网关之上。 - 可观测与成本层(第八篇):DCGM Exporter 4.6.0-4.8.3 的硬件指标(
etc/default-counters.csv里的DCGM_FI_DEV_GPU_UTIL、DCGM_FI_DEV_FB_USED;DCGM_FI_PROF_SM_ACTIVE在同一文件中默认注释掉,需要打开)、引擎的vllm:*指标、网关的 token 计数,三层拼成从硬件到请求的链条;OpenCost v1.121.1(pkg/costmodel的 allocation 逻辑含 GPU 字段)按 label 把 GPU 成本分摊到团队与模型。
3. 两层之间的接口
两层的分界线是“Pod 能跑了”。资源层不知道 Pod 里跑的是训练还是推理、服务的是谁、值多少钱;交付层不知道 GPU 是怎么分出来的、网卡是怎么进容器的。它们通过三样东西耦合:
资源请求 交付层用 K8s 的资源语言(nvidia.com/gpu: 2、nodeSelector、ResourceClaim)向资源层要卡;资源层不解释用途
Pod 组抽象 LeaderWorkerSet / JobSet 定义"哪些 Pod 是一组",调度层(Kueue / Volcano)据此做 gang;两层共用这个概念
指标 DCGM(资源层产出)与 vllm:*(引擎产出)在可观测层汇合;成本分摊要同时用到"谁占了卡"与"谁发了请求"
训练任务只走资源层:TrainJob 提交、Kueue 准入、Pod 起来、跑完释放,交付层的三篇对它几乎没有内容(除了第八篇的任务级可观测与成本)。推理服务两层都走:先由资源层给出 Pod,再由交付层包装成服务。这也是为什么总纲说前四篇按”一个训练任务从提交到跑起来”推进、后三篇按”一个推理请求从进入到计费”推进。
把”训练任务只走资源层”这条路展开到组件,可以看到它在哪三处会停下来等:
%% 图:训练任务从提交到运行的三个等待点:Kueue 配额排队、kube-scheduler 逐 Pod 调度、torchrun rendezvous
sequenceDiagram
participant U as 用户
participant T as Trainer 控制器
participant Q as Kueue
participant S as kube-scheduler
participant N as 节点 kubelet + torchrun
U->>T: kubectl apply TrainJob
T->>T: 生成 JobSet / Job,suspend=true
T->>Q: 出现 Workload,要 4 Pod × 8 GPU
Note over Q: 等待点 1:排队等 ClusterQueue 配额,分钟到小时
Q->>T: 准入,改 suspend=false
T->>S: 4 个 Pod 进入调度队列
Note over S: 等待点 2:逐 Pod Filter / Score,没有 gang 时可能 3 个绑定后卡住
S->>N: 绑定 Pod 到节点
N->>N: device plugin Allocate,注入设备文件与 RANK / MASTER_ADDR
Note over N: 等待点 3:torchrun rendezvous 等 min_nodes 到齐,先到的占卡空等
N->>N: 全员到齐开始训练,每 T 分钟写 checkpoint
N-->>T: 任一 Pod 失败 → 组级重启或任务失败
N-->>Q: 任务结束,Workload 完成,配额与 GPU 释放
这条链上没有任何一个组件同步阻塞另一个:每一步都是控制器各自 reconcile,”等待”表现为某个对象停在中间状态——Workload 未准入、Pod Pending、worker 卡在 rendezvous。三个等待点里只有第一个是平台有意让它等(排队是配额的正常语义),后两个是浪费:等待点 2 占着卡等剩余 Pod 调度,等待点 3 占着卡等 rendezvous。Kueue 的准入把”凑齐再放行”提前到等待点 1,Volcano 的 gang 在等待点 2 一起绑定,两者都是为了把后两处的空等压到接近零——这是第三篇的核心。
4. 组件全景图按层落位
把总览里的图展开成表,每个组件标注它填的是第五章哪类空缺、解决第二、三章哪条需求:
层 组件(版本) 填的空缺 解决的需求 篇
──────── ────────────────────────────────────────── ───────────────────────── ──────────────────────────────── ──
设备 NVIDIA GPU Operator v26.7.0 缺插件:驱动、Toolkit、插件 容器看到 GPU;节点标签有型号 2
k8s-device-plugin v0.20.0 缺插件:GPU 作为资源 整数个 GPU 可请求 2
DRA(Kubernetes v1.37.0,resource.k8s.io/v1) 缺概念:设备属性 按显存 / 型号 / 拓扑选卡 2
NVIDIA Container Toolkit v1.20.0 缺插件:设备注入容器 容器内有 /dev/nvidia* 与 libcuda 2
调度 Kueue v0.19.2 缺概念:队列、配额、gang 准入 全员到齐才开始;团队配额与借用 3
Volcano v1.15.2 缺概念:批调度器 gang、公平分享、拓扑 3
Kubeflow Trainer v2.3.0 缺概念:训练任务对象 RANK / MASTER_ADDR 注入;整组重启 3
KubeRay v1.7.0 缺概念:Ray 集群对象 Ray 任务与服务的表达 3、6
切分 MIG(GPU Operator MIG Manager) 缺概念:一张卡的一部分 小模型共卡,硬件隔离 4
时间片 / MPS(device plugin sharing) 缺概念:一张卡的一部分 开发环境共卡,无 / 弱隔离 4
HAMi v2.10.0 缺概念:按显存与算力比例请求 小模型共卡,软件隔离 4
网络存储 NVIDIA Network Operator v26.7.0 缺插件:RDMA 驱动栈 节点间 RDMA 5
Multus CNI v4.3.0 缺概念:第二张网卡 RDMA 网卡进 Pod 5
k8s-rdma-shared-dev-plugin v1.5.4 缺插件:RDMA 设备作为资源 /dev/infiniband 进容器 5
CSI(PFS / 对象存储 / 缓存层) 缺规划:突发写与并发读带宽 checkpoint 写、数据集读、权重分发 5
Serving LeaderWorkerSet v0.10.0 缺概念:多 Pod 一副本 跨节点 TP / PP 副本;PD 分离 6
KServe v0.20.0 缺概念:推理服务对象 InferenceService / LLMInferenceService 6
llm-d v0.9.0 缺概念:LLM Serving 栈 PD 分离、KV 感知路由的整体方案 6、7
KEDA v2.20.2 缺信号:按引擎指标扩缩 vllm:num_requests_waiting 驱动扩容 6
网关 Gateway API Inference Extension v1.6.0 缺概念:InferencePool 副本不可互换的后端集合 7
llm-d-router v0.10.0(EPP) 缺信号:按副本状态选后端 KV cache / 队列 / LoRA 感知路由 7
可观测 DCGM Exporter 4.6.0-4.8.3 缺信号:硬件指标 分配率 vs 使用率 8
OpenCost v1.121.1 缺信号:成本分摊 每团队、每模型、每百万 token 的成本 8
这张表是全系列的”机制线”。每一篇会把它所在的行展开成一章。
5. 同位替代:Slurm 与 Ray
两个系统经常被问到”它们在这张图上哪里”。答案是:它们不是图上的一层,而是资源层调度部分的替代实现——所以叫同位替代。
Slurm 是 HPC 集群二十年的标准答案。sbatch 提交、partition 是队列、GRES 表达 GPU、--exclusive 独占节点、backfill 调度填空隙、拓扑插件按交换机层级放任务——第三章训练任务的全部需求它都原生满足,gang 是它的默认语义而非扩展。它的短板恰好是推理侧:没有 Service、没有滚动升级、没有自动扩缩容、容器化是后来加的。HPC 出身的训练团队选 Slurm 是自然的,问题出在同一个集群还要跑推理服务的时候。Slinky 项目的 slurm-operator v1.2.2(api/v1beta1 的 Controller、NodeSet、LoginSet 等 CRD)把 Slurm 的控制面与计算节点作为 Kubernetes 对象来运行,是两个世界的一种桥接方式;第三篇会把它与 Kueue、Volcano 放在同一个核心问题下对照。
Ray 是另一个方向:它自己有调度器(placement group、资源标签、actor),自己管进程组,自己做弹性。Ray Train 跑训练、Ray Serve 跑推理,都在 Ray 的抽象里。放到 Kubernetes 上,KubeRay v1.7.0 的 RayCluster / RayJob / RayService(ray-operator/apis/ray/v1 的 RayClusterSpec 等)申请节点级资源,Ray 在拿到的节点里再做细粒度调度——两层调度器。它适合 Python 逻辑重、多阶段流水线的场景;代价是 Ray 拿到的资源在 Kubernetes 看来已经”用掉了”,两层之间的利用率信息不通。
两者的定位可以用一句话概括:Slurm 替代的是第三篇的调度层(并且顺带替代了任务表达),Ray 替代的是第三篇的调度层加第六篇的 Serving 层(但都在自己的边界内)。本系列以 Kubernetes 生态为主线,两者只在第三篇与第六篇作对照。
七、核心问题:两张需求表
1. 4 节点 32 卡的训练任务
设定:一个 70B 级模型的预训练,4 台 8 卡节点,节点内 TP=8、节点间 DP=4;每 30 分钟写一次 checkpoint;所属团队配额 32 卡,另一团队配额也是 32 卡,共用一个 64 卡池。
# 需求 原生 Kubernetes 能否满足 补缺篇
── ─────────────────────────────────────────────────── ────────────────────────────────────────────────────────────── ─────
1 4 个 Pod,每个恰好 8 张卡,全在一个节点上 部分:resources.limits 能表达 8,但 nvidia.com/gpu 要先由插件上报 2
2 32 张卡型号一致(都是 H100,不能混 A100) 部分:靠 nodeSelector 按节点标签选;标签要 GFD 打;设备本身无属性 2
3 8 张卡之间 NVLink 全连接(整节点独占即可保证) 是(整节点独占时);非整节点时插件不保证给出拓扑最优的 8 张 2、4
4 4 个 Pod 同时调度,凑不齐就一个也不占 否:kube-scheduler 逐 Pod 调度,会出现 30/32 占着等 3
5 4 个节点在同一机柜 / 同一 spine 下 否:podAffinity 不能表达"任意一个同域",也不为整组回退 3
6 团队配额 32 卡;对方空闲时可借用、对方要用时归还 否:ResourceQuota 是硬上限,没有队列、借用与归还 3
7 Pod 与 RANK 的稳定映射;MASTER_ADDR 在 worker 起来前可解析 是:Indexed Job + headless Service;但需要控制器注入变量 3
8 任一 Pod 失败,整组重建并从 checkpoint 恢复 否:Job 的 backoffLimit 是单 Pod 语义,没有组级重启 3
9 被抢占时优先抢刚写完 checkpoint 的任务 否:PriorityClass 抢占是单 Pod、不知道 checkpoint 3
10 节点间 RDMA;NCCL 不回退到 TCP 否:CNI 只给一张 veth;/dev/infiniband 不在容器里 5
11 checkpoint 突发写:1 TB 级在 1 分钟内落盘 接口是(PVC);带宽否:K8s 不做存储容量规划 5
12 数据集持续随机读,32 个 rank 并发 接口是(PVC);性能取决于存储 5
13 任务结束整体释放 32 张卡 是:Job 完成即释放 —
14 任务级 GPU 利用率、每 rank 的 step 时间、排队时长 否:需要 DCGM + 按任务聚合的指标管线 8
15 成本按任务分摊到团队 否:需要成本模型与 label 分摊 8
15 条里原生完全满足的只有第 13 条。第 1、2、3、7、11、12 条是”接口有、能力缺”——Kubernetes 提供了字段,但字段背后的东西(设备上报、节点标签、存储带宽)要平台补。其余 8 条是模型层面的缺失,集中在调度(4–9)与网络(10)。
2. TP=2、副本数动态变化的推理服务
设定:一个 30B 级模型,bf16 权重约 60 GB,TP=2 放在两张 80 GB 卡上;白天 6 个副本、夜间 1 个;同一模型名下要灰度一个 FP8 版本;两个租户各有 TPM 配额。
# 需求 原生 Kubernetes 能否满足 补缺篇
── ─────────────────────────────────────────────────── ────────────────────────────────────────────────────────────── ─────
1 每副本一个 Pod、2 张卡、同节点、最好 NVLink 相连 部分:2 张卡能请求;是否 NVLink 相连取决于插件的分配策略 2、4
2 显存独占:这 2 张卡上不能有别的进程 是(整卡分配时);一旦切分共享,隔离由切分方案决定 4
3 小流量时段该模型用不满 2 张 80 GB 卡,想让别人用剩余部分 否:device plugin 不能把一张卡的一部分给别人 4
4 副本数按 vllm:num_requests_waiting 扩缩 否:HPA 默认只看 CPU / 内存;自定义指标要自建管线 6
5 高峰前提前扩容(一个副本就绪要 5 分钟以上) 否:HPA 是反应式的;需要预测或低阈值策略 6
6 夜间缩到 1 甚至 0,且第一个请求能接受几分钟冷启动 部分:缩到 1 可以;缩到 0 HPA 不支持 6
7 就绪探针等到权重加载与预热完成 是:readinessProbe 打引擎的 /health 6
8 扩容时 6 个 Pod 并发读同一份 60 GB 权重 接口是;带宽否 5
9 滚动升级时等在途流式请求完成再终止旧副本 部分:terminationGracePeriodSeconds + preStop;引擎要配合 drain 6
10 请求不轮询:按每副本的 KV cache 使用率与队列长度选副本 否:Service 是轮询 / 随机;不知道副本状态 7
11 同前缀请求去同一副本(prefix cache 命中) 否:Service 无会话亲和到内容 7
12 按请求体的 model 字段路由到对应模型的副本集合 否:HTTPRoute 按路径 / header 路由,不解析 body 7
13 BF16 与 FP8 两个版本按 9:1 分流,按 header 定向 部分:HTTPRoute 权重与 header 匹配可做;后端仍要能区分版本 7
14 租户 A 每分钟 100 万 token、租户 B 30 万;超了排队而非拒绝 否:没有 token 计数,没有租户概念,没有排队 7
15 TTFT / TPOT 分位数、goodput、每百万 token 成本 否:需要引擎指标 + 网关计数 + 成本模型 8
15 条里原生满足的是第 2(整卡时)、第 7 两条,”接口有、能力缺”的是第 1、6、8、9、13 条,其余 8 条是模型层面的缺失,集中在扩缩容(4–6)与路由(10–14)。
3. 哪几条是原生满足不了的
两张表合起来,原生 Kubernetes 模型层面满足不了的需求可以归为五组,每组对应一篇:
组 训练表 推理表 机制 篇
────────────────────────────────── ───────────── ───────────── ─────────────────────────────────── ──
GPU 是不透明整数:无属性、无拓扑、不可分 1、2、3 1、3 device plugin 上报 + GFD 标签;DRA;切分 2、4
没有"一组 Pod":无 gang、无组级重启 4、7、8 — Kueue / Volcano;TrainJob;LWS 3、6
没有队列、配额借用、拓扑与抢占策略 5、6、9 — ClusterQueue / cohort / TAS;PodGroup 3
一张网卡;存储只有接口没有规划 10、11、12 8 Multus + RDMA plugin;CSI 与存储选型 5
交付信号缺失:扩缩容、路由、租户、成本 14、15 4–6、10–15 KEDA;InferencePool + EPP;DCGM + OpenCost 6、7、8
值得注意的是训练与推理的空缺几乎不重叠:训练卡在调度与网络,推理卡在扩缩容与路由。只有”GPU 是不透明整数”是两者共同的空缺——这也是为什么第二篇是两条阅读路径共同的第一站。
反过来看,原生满足的部分也不少:Pod 的资源请求语法、headless Service 的稳定 DNS、Indexed Job 的序号、readinessProbe、PVC 抽象、HTTPRoute 的权重分流。平台组件几乎都建立在这些原生能力之上而非绕开它们——Kueue 用的是 Job 的 suspend 字段,LeaderWorkerSet 生成的是 StatefulSet,InferencePool 挂在 HTTPRoute 下面。扩展而非替换是这一层生态的共同选择,它的代价在第九章。
八、全系列术语表
后续七篇会反复使用下面这些词,各篇的总览会复述用到的部分:
术语 含义 首次展开
────────────────────── ──────────────────────────────────────────────────────────────────────── ────────
gang(gang scheduling) 一组 Pod 要么全部调度要么全部不调度;Volcano PodGroup 的 minMember、Kueue 的 Workload 准入 第三篇
cohort Kueue 中一组 ClusterQueue 的集合,成员之间可以借用彼此的空闲配额(ClusterQueueSpec.CohortName) 第三篇
ResourceFlavor Kueue 中资源的"口味":一组节点标签与污点对应一种硬件(如 H100 与 A100 各一个 flavor) 第三篇
Workload Kueue 为每个被管理的 Job 创建的准入对象,记录它要多少配额、被哪个 ClusterQueue 准入 第三篇
TAS Kueue 的 Topology-Aware Scheduling:Topology CRD(v1beta1)定义层级,Workload 按层级紧凑放置 第三篇
PodGroup / Queue Volcano 的调度单位与队列;Queue 有 capability / deserved / guarantee 三个配额语义 第三篇
TrainJob Kubeflow Trainer 的训练任务对象,引用 TrainingRuntime,底层生成 JobSet 第三篇
MIG profile 一张 GPU 切成的硬件分区规格,如 1g.5gb、3g.20gb;device plugin 上报为 nvidia.com/mig-1g.5gb 第四篇
时间片 / MPS device plugin 的 sharing 配置:一张卡上报为 N 个副本;时间片无隔离,MPS 合并 CUDA context 第四篇
HAMi 软件层 GPU 切分:nvidia.com/gpumem、nvidia.com/gpucores 表达显存与算力比例 第四篇
GPUDirect RDMA 网卡直接读写 GPU 显存,NCCL 跨节点通信不经过主机内存 第五篇
NetworkAttachmentDefinition Multus 的 CRD,定义 Pod 可以额外挂的一张网卡 第五篇
LeaderWorkerSet(LWS) 一个 leader 加 N 个 worker 作为一个副本单元的 Kubernetes 对象;多节点推理的标准表达 第六篇
PD 分离 prefill 与 decode 跑在不同的 Pod 组里,中间传 KV cache;两组独立扩缩 第六篇
InferencePool Gateway API Inference Extension 的 CRD:一组同模型同硬件的 Pod,替代 Service 作为路由后端 第七篇
EPP(Endpoint Picker) Envoy ext_proc 外部处理器,按每个副本的队列、KV cache、LoRA 状态选目标 Pod;llm-d-router 实现 第七篇
InferenceObjective 表达请求优先级的扩展 API(llm-d-router apix/v1alpha2) 第七篇
TTFT / TPOT 首 token 延迟(排队 + prefill)/ 每输出 token 时间(decode 每步);vllm:time_to_first_token_seconds、vllm:inter_token_latency_seconds 第三章
goodput 单位时间内满足 SLO(TTFT 与 TPOT 都在阈值内)的请求或 token 数;与吞吐的差是"跑了但没用"的部分 第八篇
分配率 / 使用率 分配率:被 Pod 请求占用的 GPU 比例;使用率:DCGM_FI_PROF_SM_ACTIVE 这类真实负载;差距是平台改进空间 第八篇
DCGM_FI_DEV_GPU_UTIL "有 kernel 在跑"的时间比例,不是算力利用率;与 SM_ACTIVE 的区别是第八篇的起点 第八篇
九、代价与边界
1. 引擎需求 → K8s 空缺 → 平台机制 → 代价
全系列每篇都会有这样一张四栏表,本篇给的是全局版,后面各篇展开各自的行:
引擎需求 K8s 空缺 平台机制 代价
───────────────────────── ───────────────────────── ─────────────────────────────────── ─────────────────────────────────────
容器看到 GPU,版本匹配 GPU 不被识别 GPU Operator + device plugin / DRA 驱动、Toolkit、插件、CUDA 四层版本契约;每层一个升级窗口
W 个 Pod 全员到齐 逐 Pod 调度 Kueue 准入 / Volcano 批调度 排队时间换无死锁;两套调度器的对象模型要学一套
同机柜、同 NVLink 域 调度器不知拓扑 TAS / 拓扑插件 + 节点标签 等更久换更快;标签来源与准确性成为新依赖
团队配额、空闲借用 ResourceQuota 是硬上限 ClusterQueue / cohort / Queue 借来的资源可能被收回(抢占);配额策略本身要运维
小模型共卡 一卡只能给一个容器 MIG / 时间片 / MPS / HAMi 隔离与利用率的取舍;改配置要清空整卡;NCCL 不能跨 MIG
NCCL 走 RDMA 一张 veth 网卡 Multus + RDMA plugin + Network Operator 多一套网络配置与驱动栈;回退到 TCP 不报错、难发现
checkpoint 突发写 PVC 无带宽语义 PFS / 对象存储 + 缓存层 按峰值带宽买存储;缓存一致性
多 Pod 一副本 Deployment 是单 Pod LeaderWorkerSet / JobSet 滚动升级以组为单位,一次替换 M 个 Pod
按引擎指标扩缩 HPA 看 CPU KEDA + Prometheus 指标管线的延迟叠加在冷启动上;阈值调参
不轮询、感知 KV cache Service 轮询 InferencePool + EPP 多一跳 ext_proc;EPP 自身要高可用;抓指标有开销
按 token 限流与计费 没有租户与 token 概念 网关的配额与计量 预扣与结算的复杂性;流式断开的计费争议
分配率与使用率的差距 没有 GPU 指标 DCGM + vllm:* + OpenCost 高基数指标的存储成本;分摊规则的争议
2. 每叠一层的代价
上表的”代价”列有三种共同模式,值得单独说。
版本契约的层数。一个能用 GPU 的 Pod 依赖驱动、Container Toolkit、device plugin、CUDA Runtime 四层各自的版本,加上 Kubernetes 自身、Operator、调度器扩展、CRD 的 API 版本。每一层都有自己的发布节奏与兼容矩阵。总纲的版本基线表里有二十多个项目,这不是本系列的选择,而是这一层生态的现状:升级任何一个都要检查它与相邻层的契约。
两套对象模型。用了 Kueue 就要理解 Workload 与 ClusterQueue,用了 Volcano 就要理解 PodGroup 与 Queue,用了 LeaderWorkerSet 就不能直接用 Deployment 的滚动升级参数。每个 CRD 都是对原生对象的一次包装,排障时要穿过这层包装找到底下的 Pod。第五章说这一层生态选择了”扩展而非替换”,好处是原生工具(kubectl describe、事件、标签)都还能用,代价是对象数量翻倍——一个 TrainJob 背后是 JobSet、Job、Pod 三层,一个 LLMInferenceService 背后是 LeaderWorkerSet、StatefulSet、Pod、InferencePool、HTTPRoute。
信号延迟的叠加。推理扩容的链路是:引擎指标每 N 秒被 Prometheus 抓一次 → KEDA 每 M 秒查一次 → HPA 决定 → 调度 → 拉镜像 → 加载权重 → 就绪。每一段的延迟相加才是”从负载上升到新副本可用”的时间。平台每加一个环节,这个数字就多一项;第六篇的核心问题就是把它分解并压缩。
3. 什么时候不该上这一层
平台层不是免费的,几种情况下它的代价大于收益:
- 单团队、固定负载、几台机器。三台 8 卡机跑一个团队的训练,
torchrun直接上 SSH 或用 Slurm 就够了,gang 与配额没有多租户就没有意义。 - 只有推理、只有小模型、副本数固定。一个 Deployment 加 Service 加 HPA(就算看 CPU 也无所谓,因为不扩)可能足够;InferencePool 与 EPP 的收益在副本多、请求长短悬殊、prefix 重复率高时才明显。
- 云上托管服务能接受黑盒。各云的 GPU 节点池替你做了第二篇(驱动与插件),托管推理服务替你做了第六、七篇的大部分;代价是排障时看不到内部、换厂商时经验不迁移。本系列讨论的是自建路线,托管服务在相关处会标注”替你做了哪一步”。按层看,托管方案替掉的主要是两头(设备层与交付层的黑盒部分),中间的调度、切分与规划仍然留在你手上:
| 层(篇) | 自建要装的 | 托管方案替你做了什么 | 仍然留给你的 |
|---|---|---|---|
| 设备(2) | GPU Operator、device plugin / DRA、Container Toolkit | 云 GPU 节点池预装驱动、Toolkit、device plugin,节点自带型号标签 | 驱动 / CUDA 版本跟随云镜像节奏;DRA 通常要自己开启 |
| 调度(3) | Kueue / Volcano、Kubeflow Trainer | 托管 Kubernetes 基本不做;个别云有托管队列 / 批调度产品 | 配额、gang、拓扑、抢占策略几乎全部自建 |
| 切分(4) | MIG Manager、时间片 / MPS、HAMi | 节点池可选固定 MIG 规格 | 动态改 profile、软件切分、隔离与利用率的取舍 |
| 网络存储(5) | Network Operator、Multus、RDMA plugin、CSI | RDMA 实例自带驱动与 CNI 配置;托管并行文件系统提供 CSI | 突发写与并发读的容量规划;NCCL 是否真走了 RDMA 的验证 |
| Serving(6) | LeaderWorkerSet、KServe / llm-d、KEDA | 托管推理服务:副本、扩缩、就绪全部黑盒 | 冷启动与扩缩策略不可调;自定义引擎参数受限 |
| 网关(7) | Inference Extension + EPP | 托管模型 API 自带路由、限流、按 token 计费 | 多模型统一入口、跨厂商一致的租户配额 |
| 可观测与成本(8) | DCGM Exporter、OpenCost | 云监控给 GPU 基础指标;账单按实例小时 | 引擎指标、每百万 token 成本、分配率 vs 使用率 |
4. 本系列的边界
本系列只讨论引擎之下的资源层与引擎之侧的交付层,以下内容作为”需求来源”一句带过,不展开:
- 引擎内部:训练框架的并行策略与状态切分、checkpoint 的格式与异步写法;推理引擎的请求调度、KV cache 管理、PD 分离在引擎内的实现。本文第二、三章用到的只是它们对外的形状——进程组、环境变量、显存构成、指标名。
- 通信与网络协议内部:NCCL 的算法与 channel、RDMA verbs、InfiniBand 与 RoCE 的差别。第五篇只讨论如何让 RDMA 设备在容器里可用、如何验证 NCCL 走了正确的路径。
- GPU 硬件与 kernel 性能:第四篇讨论 MIG 时用到”SM 与显存带宽被分区”这个事实,不展开它对 kernel 的影响。
- Kubernetes 一般原理:Pod、Deployment、CRD、Operator、CNI、CSI 假定已知。
- 数据平台、实验管理、应用层:数据怎么来、模型怎么评、RAG 与 Agent 怎么写,都在本系列之外。
边界的另一侧是硬件:本系列以 Kubernetes 上的 NVIDIA GPU 为主线;AMD 有对应的 device plugin 与 Operator,机制高度对应,在第二、四篇提及差异;其他加速器不在范围内。
十、本文小结
1. 要点回顾
- 训练任务是 \(W\) 个强耦合的进程:
torchrun的 agent 通过 rendezvous 互相发现,注入MASTER_ADDR/MASTER_PORT/WORLD_SIZE/RANK/LOCAL_RANK。每一步以集合通信结束,任一进程失败其余全部卡住,所以SimpleElasticAgent._invoke_run()的策略是任一 worker 失败则整组重启;agent 或节点消失时”整个任务失败还是补节点”由平台决定。 - 训练的四条资源需求:整数个 GPU 且每进程一张;节点间高带宽低延迟且拓扑相近;周期性突发顺序写(checkpoint,1 TB 级在 1 分钟内);持续小块随机读(数据集)。它是有限时长的批处理,排队是常态,被打断的代价是上次 checkpoint 之后的进度。
- 推理副本有三种形态(单卡、单 Pod 多卡、多 Pod 一副本),prefill 计算密集、decode 显存带宽密集。显存是硬约束——权重加 KV cache 不能超卖;扩容以分钟计,主项是权重加载;负载信号在引擎内部:
vllm:num_requests_waiting、vllm:kv_cache_usage_perc、vllm:time_to_first_token_seconds、vllm:inter_token_latency_seconds。 - 两类负载的需求在独占 vs 共享、拓扑 vs 弹性、批处理 vs 长驻三处直接冲突;共同点是都要”多 Pod 一个单位”、都要 GPU 有属性、成本都落到 GPU 时间。
- 原生 Kubernetes 的空缺分三种:缺一个插件(GPU 与 RDMA 设备不被识别)、缺一个概念(一组 Pod、设备属性、队列与配额、多 Pod 副本)、缺一个信号(引擎指标驱动的扩缩容、路由、成本)。kube-scheduler 逐 Pod 调度,
noderesources/fit.go的Fits()只把nvidia.com/gpu当整数比较;device plugin 的ListAndWatch/Allocate只能上报计数。 - 平台分两层:资源层(设备、调度、切分、网络存储)把裸节点变成能跑的 Pod;交付层(Serving、网关、可观测)把 Pod 变成有 SLA 与账单的服务。分界线是”Pod 能跑了”;两层通过资源请求、Pod 组抽象、指标三样东西耦合。训练只走资源层,推理两层都走。
- Slurm 与 Ray 是资源层调度部分的同位替代:Slurm 原生满足训练的全部需求但缺推理侧;Ray 自带调度器与 Serve,放到 Kubernetes 上是两层调度。
- 核心问题的两张表:32 卡训练任务 15 条需求中原生完全满足 1 条,模型层面缺失集中在调度(gang、拓扑、配额、组级重启、抢占策略)与网络(RDMA);TP=2 推理服务 15 条中原生满足 2 条,缺失集中在扩缩容(指标、提前、缩零)与路由(副本状态、model 字段、租户配额)。两者唯一共同的空缺是”GPU 是不透明整数”。
- 平台每叠一层的代价:版本契约的层数、对象模型翻倍、信号延迟叠加。单团队固定负载、只有小模型固定副本、或能接受托管黑盒时,不该上这一层。
2. 本篇涉及的源码与 CRD 位置
| 路径 | 内容 |
|---|---|
PyTorch 2.13.0 torch/distributed/run.py |
模块文档:--nnodes / --nproc-per-node / --rdzv-backend / --rdzv-endpoint / --rdzv-id / --max-restarts;环境变量 MASTER_ADDR、MASTER_PORT、WORLD_SIZE、RANK、LOCAL_RANK、LOCAL_WORLD_SIZE、TORCHELASTIC_RUN_ID;”Failure Modes” 与 “Membership Changes” 两节 |
PyTorch 2.13.0 torch/distributed/elastic/agent/server/api.py |
SimpleElasticAgent._invoke_run()(监控循环:SUCCEEDED → _exit_barrier();FAILED / UNHEALTHY → _restart_workers() 或停止)、_rendezvous()、_monitor_workers();local_elastic_agent.py 的 LocalElasticAgent 是本机实现 |
PyTorch 2.13.0 torch/distributed/elastic/rendezvous/dynamic_rendezvous.py |
RendezvousSettings.min_nodes / max_nodes、_DistributedRendezvousOpExecutor;rendezvous/api.py 的 RendezvousHandler.next_rendezvous();c10d_rendezvous_backend.py 为默认后端 |
vLLM v0.28.0 vllm/v1/metrics/loggers.py |
PrometheusStatLogger:vllm:num_requests_running、vllm:num_requests_waiting、vllm:kv_cache_usage_perc、vllm:prefix_cache_hits / vllm:prefix_cache_queries、vllm:num_preemptions、vllm:time_to_first_token_seconds、vllm:inter_token_latency_seconds、vllm:request_queue_time_seconds |
vLLM v0.28.0 vllm/config/cache.py、vllm/config/parallel.py |
CacheConfig.gpu_memory_utilization(默认 0.92);ParallelConfig.tensor_parallel_size |
Kubernetes v1.37.0 pkg/scheduler/framework/plugins/noderesources/fit.go |
Fits()、fitsRequest()、InsufficientResource(Reason 为 Insufficient <资源名>) |
Kubernetes v1.37.0 staging/src/k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1/api.proto |
ListAndWatch、GetPreferredAllocation、Allocate 三个 rpc;pkg/kubelet/cm/devicemanager/manager.go 的 ManagerImpl.Allocate() / GetCapacity() |
Kubernetes v1.37.0 staging/src/k8s.io/api/resource/v1/types.go |
DRA 的 ResourceSlice、ResourceClaim、ResourceClaimTemplate、DeviceClass(第二篇展开) |
GPU Operator v26.7.0 api/nvidia/v1/clusterpolicy_types.go |
ClusterPolicySpec |
Kueue v0.19.2 apis/kueue/v1beta2/clusterqueue_types.go、resourceflavor_types.go、apis/kueue/v1beta1/topology_types.go |
ClusterQueueSpec.CohortName、ReclaimWithinCohort / BorrowWithinCohort;ResourceFlavorSpec.NodeLabels;Topology CRD |
Volcano v1.15.2 staging/src/volcano.sh/apis/pkg/apis/scheduling/v1beta1/types.go、pkg/scheduler/plugins/gang/gang.go |
PodGroupSpec.MinMember;QueueSpec.Capability / Deserved / Guarantee;gang 插件 |
Kubeflow Trainer v2.3.0 pkg/apis/trainer/v1alpha1/trainjob_types.go、trainingruntime_types.go、pkg/constants/constants.go |
TrainJobSpec.RuntimeRef;TorchMLPolicySource.NumProcPerNode、NumNodes;TorchEnvNumNodes = "PET_NNODES" 等 torchrun 环境变量常量 |
KubeRay v1.7.0 ray-operator/apis/ray/v1/ |
RayClusterSpec、RayJobSpec、RayServiceSpec |
Slinky slurm-operator v1.2.2 api/v1beta1/ |
ControllerSpec、NodeSetSpec、LoginSetSpec |
LeaderWorkerSet v0.10.0 api/leaderworkerset/v1/leaderworkerset_types.go |
LeaderWorkerSetSpec.LeaderWorkerTemplate;api/disaggregatedset/v1/ 为 PD 分离的独立 API group |
KServe v0.20.0 pkg/apis/serving/v1alpha1/llm_inference_service_types.go |
LLMInferenceService(serving.kserve.io/v1alpha1) |
Gateway API Inference Extension v1.6.0 api/v1/inferencepool_types.go |
InferencePool(inference.networking.k8s.io/v1)、EndpointPickerRef;EPP 实现在 llm-d-router v0.10.0 cmd/epp、pkg/epp;InferenceObjective 在 llm-d-router apix/v1alpha2 |
KEDA v2.20.2 apis/keda/v1alpha1/scaledobject_types.go、pkg/scalers/prometheus_scaler.go |
ScaledObjectSpec.Triggers;Prometheus scaler |
DCGM Exporter 4.6.0-4.8.3 etc/default-counters.csv |
DCGM_FI_DEV_GPU_UTIL、DCGM_FI_DEV_FB_USED 默认开启;DCGM_FI_PROF_SM_ACTIVE 默认注释 |
OpenCost v1.121.1 pkg/costmodel/allocation.go |
allocation 合并逻辑中的 GPU 字段(第八篇展开) |
3. mini-platform 本篇增量
练手项目 mini-platform/ 是一个从零搭起的最小 AI 平台,八篇各加一批文件。本篇只做两件事:把集群搭起来,提交一个注定 Pending 的 GPU Pod 并读懂它的事件。不装任何 GPU 相关组件——第二篇装上 GPU Operator 之后,同一份 manifest 会变成 Running,前后对照是理解 device plugin 的最短路径。
cluster/nodes.md——节点清单与约束:
mini-platform / cluster/nodes.md -- node inventory for the whole series
角色 数量 建议规格 说明
────────────── ──── ───────────────────────────────── ──────────────────────────────────────────────
control-plane 1 4 vCPU / 8 GB,无 GPU kubeadm init;带 node-role.kubernetes.io/control-plane 污点
gpu-worker 3 每台 1 张 NVIDIA GPU(云上单卡按需实例即可) kubeadm join;本篇不装驱动之外的任何 GPU 组件
硬件对后续篇目的影响:
- 第二、三、六、七、八篇:任意 NVIDIA GPU 即可(T4 / L4 / A10 都行)
- 第四篇 MIG 部分:需要 A100 或 H100;没有则只做 HAMi 与时间片
- 第五篇 RDMA 部分:需要 IB 或 RoCE 网卡的实例;没有则用 host network 做配置走读
Kubernetes 版本:v1.37.0(DRA 的 resource.k8s.io/v1 已 GA;第二篇会用到)
容器运行时:containerd(第二篇 Container Toolkit 与 CDI 的配置以它为准)
CNI:任意(Calico / Cilium / Flannel);第五篇加 Multus 时保留它作为默认网络
kubeadm 的最小命令序列(控制面节点):
kubeadm init --kubernetes-version v1.37.0 --pod-network-cidr <CNI 要求的网段>
# 安装 CNI
kubeadm token create --print-join-command # 在三台 worker 上执行输出的 join 命令
本篇结束时的预期状态:
kubectl get nodes # 4 个 Ready
kubectl get node <gpu-worker> -o jsonpath='{.status.allocatable}'
# 输出里只有 cpu / memory / ephemeral-storage / pods / hugepages-*,没有 nvidia.com/gpu
probes/pending-gpu-pod.yaml——一个请求 GPU 的最小 Pod:
# mini-platform / probes/pending-gpu-pod.yaml
# 在裸集群(未装 device plugin)上提交,预期 Pending;第二篇装完 GPU Operator 后同一份 manifest 预期 Running。
apiVersion: v1
kind: Pod
metadata:
name: gpu-probe
namespace: default
labels:
app: gpu-probe
spec:
restartPolicy: Never
containers:
- name: probe
image: nvidia/cuda:12.8.1-base-ubuntu22.04
command: ["nvidia-smi", "-L"]
resources:
limits:
nvidia.com/gpu: 1
两个细节值得注意。第一,nvidia.com/gpu 只写在 limits 里:扩展资源要求 requests 与 limits 相等,只写 limits 时 Kubernetes 会把 requests 设为同值。第二,命令是 nvidia-smi -L,第二篇里它会列出容器内可见的那张卡;本篇它不会有机会运行。
提交并观察:
kubectl apply -f probes/pending-gpu-pod.yaml
kubectl get pod gpu-probe # STATUS 一直是 Pending
kubectl describe pod gpu-probe
describe 输出末尾的 Events 形态如下(示意,节点数与措辞随集群与版本略有差异):
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 12s default-scheduler 0/4 nodes are available: 1 node(s) had untolerated taint
{node-role.kubernetes.io/control-plane: }, 3 Insufficient nvidia.com/gpu.
preemption: 0/4 nodes are available: 1 Preemption is not helpful for
scheduling, 3 No preemption victims found for incoming pod.
逐段解读:
0/4 nodes are available:Filter 阶段没有一个节点通过。FailedScheduling事件由 kube-scheduler 发出,Pod 会留在调度队列里周期性重试,所以Age会不断刷新而事件只有一条。1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: }:控制面节点因污点被排除,与 GPU 无关。3 Insufficient nvidia.com/gpu:三台 worker 都被noderesources/fit.go的fitsRequest()判为该扩展资源不足。注意这里的措辞是”不足”而不是”不存在”——对调度器来说,节点allocatable里没有这个键与这个键等于 0 是同一回事。三台机器上各有一张物理 GPU,但没有任何组件把它上报给 kubelet。preemption: ... No preemption victims found:调度器尝试了抢占,但抢占只能腾出已被其他 Pod 占用的资源;这个资源在节点上根本没有登记,杀掉谁都没用。
验证根因:
kubectl get nodes -o custom-columns='NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu'
# GPU 列全部为 <none>
这就是第五章第 2 节说的”默认情况下 GPU 在 Kubernetes 里是一个不透明的整数”的零值形态。下一篇装上 GPU Operator,allocatable 里出现 nvidia.com/gpu: 1,同一个 Pod 被调度、nvidia-smi -L 打印出一张卡——然后我们再问:它是怎么知道容器里该有哪些设备文件和哪几个 .so 的。
清理:
kubectl delete -f probes/pending-gpu-pod.yaml
十一、自测
-
32 卡训练任务在原生 kube-scheduler 下最典型的失败形态是什么?为什么应用层重试解决不了?
答案
凑不齐 32 卡时已起的 Pod 空转等 rendezvous、超时后重建、再等——活锁;两个大任务各占一半互相卡死。因为决定“要么全起要么都不起”只能在调度 / 准入层做,Pod 已经起了再重试只是重复占卡。
-
torchrun给每个进程注入哪四个环境变量?agent 发现一个 worker 失败时默认做什么?答案
MASTER_ADDR/MASTER_PORT/WORLD_SIZE/RANK(加LOCAL_RANK);SimpleElasticAgent._invoke_run()让整组 worker 重启(任一失败其余在集合通信上卡住,单独重启一个没用);agent 本身或节点消失则交给平台决定是任务失败还是补节点。 -
推理副本的扩容为什么“以分钟计、主项是权重加载”?这对扩缩容指标意味着什么?
答案
调度 + 拉镜像 + 从对象存储加载几十 GB 权重 + 引擎初始化(profile、CUDA graph 捕获),8 分钟量级;所以不能等 GPU 利用率高了再扩——要用引擎内部的领先指标(
num_requests_waiting、kv_cache_usage_perc、TTFT)并提前触发。 -
device plugin 的
ListAndWatch/Allocate只能做什么?导致哪三个局限?答案
上报设备 ID 列表与健康、按 kubelet 分配的 ID 注入设备;局限:只计数不带属性(型号、显存、拓扑)、不能表达跨 Pod 的共享、节点内 NVLink 偏好只是建议(
GetPreferredAllocation)。 -
平台的“资源层”与“交付层”分界线在哪?训练与推理各走哪层?什么情况下不该自建平台?
答案
分界线是“Pod 能跑了”:资源层管设备、调度、切分、网络存储;交付层管 Serving、网关、可观测与账单。训练只走资源层,推理两层都走。单团队固定负载、只有小模型固定副本、或能接受托管黑盒时,多叠一层的版本契约与信号延迟不值。
下一篇
容器里的 GPU:驱动、CUDA、device plugin 与镜像。本篇的 Insufficient nvidia.com/gpu 是从调度器视角看到的空缺;下一篇从节点视角把它填上:内核驱动、用户态库、CUDA Runtime 与容器运行时四层的版本契约,Container Toolkit 与 CDI 如何把设备注入容器,device plugin 的 ListAndWatch / Allocate 如何把 GPU 变成 allocatable 里的一个整数,GPU Operator 的 ClusterPolicy 如何把这一切装到每个节点,以及 DRA 用 ResourceClaim 与 CEL 表达式如何让这个整数重新有属性。核心问题:宿主机驱动 535、镜像里 CUDA 12.4 的 PyTorch、代码调用了 CUDA 12.4 新增的 API,这个组合能跑吗?
-
训练任务(4 节点 32 卡)约 15 条需求:整数个 GPU 且每进程一张;32 个 Pod 要么同时起、要么都不起(gang)——
torchrun的 rendezvous 等不齐就超时重建、活锁;节点间 RDMA 且拓扑相近;组级重启;队列与配额、抢占策略;周期性突发顺序写(checkpoint)与持续小块随机读。原生 Kubernetes 完全满足的只有 1 条——kube-scheduler 逐 Pod 决策,Fits()只把nvidia.com/gpu当整数比较,device plugin 只能上报计数,没有 Pod 组、没有设备属性、没有队列、RDMA 设备不被识别(第二章、第五章)。推理服务(TP=2、副本数动态):多 Pod 一副本的副本抽象;显存是硬约束不能超卖;扩容以分钟计、要按引擎内部指标提前扩(vllm:num_requests_waiting、vllm:kv_cache_usage_perc、TTFT、ITL);按副本状态与 model 字段路由、租户配额、缩到零;原生满足 2 条,缺失集中在扩缩容信号与路由(第三章)。空缺分三种:缺插件(GPU / RDMA 设备)、缺概念(Pod 组、设备属性、队列配额、多 Pod 副本)、缺信号(引擎指标驱动的扩缩容、路由、成本);唯一共同的空缺是「GPU 是不透明整数」。平台由此分两层——资源层把裸节点变成能跑的 Pod,交付层把 Pod 变成有 SLA 与账单的服务;训练只走资源层,推理两层都走(第四章、第六章、第七章)。 ↩
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/ai-platform-engine-requirements-and-architecture.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。