一句话结论:K8s 把 GPU 当作 nvidia.com/gpu: 8 的整数资源来分配,但对 GPU 实际的 SM 占用、NVLink 拓扑、跨机 HCA 带宽一无所知。这种「只分配、不感知」的鸿沟贯穿调度、网络、存储、虚拟化、可观测性五个维度,是 AI 训练吞吐上不去的真正根因。

上一讲(K8s 很熟,为什么月薪卡在三万)讲的是怎么看——nvidia-smi topo -m 的六个缩写、NCCL 日志怎么读、AllReduce 的木桶效应。这一讲讲怎么修:五道鸿沟各自的工程解法。

本文对照 Volcano 源码、NVIDIA MIG User Guide、NCCL 官方文档与源码、JuiceFS 官方文档逐条核对过。核对中发现原稿有十余处需要更正,其中三处会导致读者照着配置失败,已在文中以 ⚠️ 标注。


五道鸿沟总览

维度 K8s 原生方案 AI 训练瓶颈 生产级方案 核心工具链
调度 kube-scheduler 逐 Pod 打分 部分 Pod 无法调度导致整组死锁 Gang 整组调度 + 多维配比 Volcano PodGroup
网络 TCP Socket + 默认网卡 AllReduce 延迟放大数十倍 RDMA 直通 + 拓扑亲和 NCCL + InfiniBand/RoCE
存储 直挂 NFS/S3 冷启动打满源站带宽 分布式缓存 + 预热 JuiceFS + NVMe 二级缓存
虚拟化 Device Plugin 整卡分配 资源浪费、拓扑错配 MIG 硬件切分 + 拓扑感知 NVIDIA MIG + GPU Feature Discovery
可观测性 cAdvisor + kubectl top GPU「利用率 100% 却在空等」 端到端数据面指标 DCGM + NCCL 日志 + MFU

一、调度鸿沟:别把 GPU 当 CPU 调度

1.1 传统 K8s 调度的致命问题

kube-scheduler 的 Filter + Score 机制是「逐 Pod 逐节点」的贪心算法:每个 Pod 独立参与调度,与其他 Pod 没有「整组」概念。微服务场景下任意副本可独立服务,这套机制没问题;但 AI 训练的 Worker 之间存在强同步语义——所有 Rank 必须同时进入 init_process_group,任何一个 Worker 没到位,先启动的 Worker 会一直阻塞在 barrier,持有 GPU 却不做任何有效计算。

具体场景:8 卡训练任务需要 4 个 Worker,每 Worker 占 2 张 GPU,集群剩余 5 张卡分散在 3 台机器(2+2+1)。默认调度器会先把 2 个 Worker 调度到 4 张卡上,剩下 2 个 Worker 因凑不齐 2 张卡而 Pending。最终 4 张 GPU 被两个「半启动」的 Worker 长期占用,集群 GPU 利用率显示 80%,但训练吞吐为 0。

1.2 解决思路:Gang 调度 + Volcano

Gang 调度的核心语义是「All or Nothing」:一组 Pod 要么全部成功调度,要么全部等待。Volcano 通过 PodGroup CRD 实现,关键字段是 minAvailable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
name: distributed-training
spec:
minAvailable: 4 # 4 个 Worker 必须整组启动
queue: training-queue
policies:
- event: PodEvicted
action: RestartJob # 任意 Worker 被驱逐则整组重启
tasks:
- replicas: 4
name: worker
template:
spec:
containers:
- name: trainer
resources:
limits:
nvidia.com/gpu: 2
rdma/hca: 1 # 随 Pod 申请 RDMA 网卡资源

⚠️ 更正(这条原稿说反了,而且反过来更有用)

原稿写「schedulerName 必须显式声明为 volcano,否则 Job 会静默回落到默认调度器」。实际是相反的:Volcano 有一个 mutating admission webhook,当 spec.schedulerName 为空时会自动补成 volcano——源码 patchDefaultScheduler:

1
2
3
4
if job.Spec.SchedulerName == "" {
return &patchOperation{Op: "add", Path: "/spec/schedulerName",
Value: commonutil.GenerateSchedulerName(config.SchedulerNames)}
}

所以不写也没关系。而 K8s 的行为是:完全不写 schedulerName 才落到 default-scheduler;写了一个不存在的名字,Pod 会永久 Pending,不会回落(官方文档原文:“remains in Pending state forever”)。

这意味着自检信号要反过来看:如果 Pod 一直 Pending、且 kubectl describe 里 schedulerName 指向一个不存在的调度器,说明那个调度器没起来——不是「Gang 语义静默失效」。

minAvailable 的语义属实:它表示作业被视为 Running 所需的最少 Running Pod 数,默认值 = 各 task 的 replicas 之和(webhook 里 patchDefaultMinAvailable 累加)。设成小于总 replicas 就等于允许部分启动。

YAML 字段名与层级也全部核实无误。一个惯用性提示:官方 vcjob 示例把 PodEvicted + RestartJob 放在 job 级 spec.policies(如上),放 task 级虽然合法但非常规。

1.3 进阶:多维配比与拓扑感知

Queue 与公平分享。Volcano Queue 的字段(weight / reclaimable / capability / deserved / priority / parent)核实无误:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
name: training-queue
spec:
weight: 4 # 仅在 proportion 插件下生效
reclaimable: true # 允许空闲时被其他 Queue 回收
capability:
cpu: 64
memory: 512Gi
nvidia.com/gpu: 32

proportion 插件做 Queue 级资源比例划分、drf 插件做 Dominant Resource Fairness(多维资源公平)——两个插件名与职责都属实。

⚠️ 更正两处

  1. 原稿写「Volcano 配合 gpu-topology-aware 插件」——Volcano 没有这个插件。它是 DaoCloud 的独立开源项目(DaoCloud-OpenSource/gpu-topology-aware),不是 Volcano 内置、也不是 NVIDIA 官方。Volcano 内置的拓扑相关插件是 numaaware、task-topology、network-topology-aware、group-topology-affinity。
  2. 原稿提到「Queue 的 overengineering 因子」——没有这个字段。相近的是 overcommit 插件的 overcommit-factor 参数(默认 1.2),那是插件参数,不是 Queue 字段。

1.4 实战复现:在 Kind 上验证死锁

没有 GPU 的笔记本也能复现 Gang 语义差异。用 Kind 搭 3 节点集群,用扩展资源模拟 GPU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 创建 Kind 集群
kind create cluster --config kind-gang.yaml

# 2. 给两个 Worker 节点打上扩展资源(注意 / 要转义成 ~1)
kubectl patch node kind-worker --subresource='status' --type='json' \
-p='[{"op":"add","path":"/status/capacity/nvidia.com~1gpu","value":"2"}]'
kubectl patch node kind-worker2 --subresource='status' --type='json' \
-p='[{"op":"add","path":"/status/capacity/nvidia.com~1gpu","value":"2"}]'

# 3. 用默认调度器提交 4 副本 Job(每副本 2 卡,共需 8 卡,实际只有 4 卡)
kubectl apply -f default-scheduler-job.yaml
# 观察:2 个 Running、2 个 Pending,前者持有资源但不退出

# 4. 装 Volcano 后提交相同 Job
helm install volcano volcano-sh/volcano -n volcano-system --create-namespace
kubectl apply -f volcano-gang-job.yaml
# 观察:4 个 Pod 全部 Pending,0 个 Running——GPU 没有被浪费

补充说明:kubectl patch --subresource 确实存在,而且 patch node 的 /status 正是 K8s 官方文档推荐的「手动宣告扩展资源」做法。注意两点:资源名必须带域名前缀(nvidia.com/gpu 合法);手改的 status 可能被 kubelet 后续刷新覆盖,这只是模拟手段,正规途径是设备插件。

1.5 调度层面高频踩坑

  • 把 rdma/hca 当成通用资源名:它来自 Mellanox 的 RDMA 设备插件(k8s-rdma-shared-dev-plugin 的 HCA 模式),不是 SR-IOV Network Device Plugin 的资源名——后者的资源名是用户自定义前缀的(如 mellanox.com/mlnx_sriov_rdma)。用哪个插件就按哪个插件的文档写。
  • MIG 模式下 Device Plugin 未正确上报:需要给 device plugin 传 --mig-strategy,取值是 none | single | mixed,默认 none(原稿只写了 single/mixed,漏了 none 和默认值)。为 none 时节点只上报整卡。
  • 节点驱动与 K8s 版本错配:内核升级后 /dev/nvidia* 消失,Device Plugin 报错但 DaemonSet 仍显示 Running。

二、网络鸿沟:TCP 扛不住集合通信

2.1 为什么 AllReduce 对网络如此敏感

数据并行训练的每个 step 都会对所有参数做一次 AllReduce。先把数学写对:

⚠️ 更正(这条是原稿的硬伤)

原稿写「通信复杂度为 O(2(N-1)) 步,每步数据量为 2(N-1)/N × |M|」。这是把「每卡累计发送量」当成了「每步数据量」。

正确的三组数:

量 公式
总步数 2(N−1)(ReduceScatter 阶段 N−1 步 + AllGather 阶段 N−1 步)
每步每卡发送量 |M| / N(张量切成 N 个 chunk,每步收发一个)
每卡累计发送量 2(N−1)/N × |M|

验算一下原稿的公式:代入 N=64,每步就是 1.97×|M|——比整个张量还大,显然不可能。

代入具体数字:70B 模型 FP32 梯度 = 70×10⁹ × 4 B = 280 GB;2(N−1)/N(N=64)= 126/64 ≈ 1.969。

⚠️ 口径必须写清楚:280 GB × 1.969 = 551 GB ≈ 550 GB,这是每张卡的发送量(接收量相同),不是集群总流量。

集群聚合总流量 = N × 每卡发送量 = 64 × 551 GB = 约 35.3 TB。两者差了 64 倍,混淆会让人误判网络压力。

为什么 TCP 扛不住——三个原因:

  • CPU 协议栈瓶颈:每收发一个包都要内核中断、协议栈处理、拷贝,CPU 占用极高,与训练任务抢核;
  • 滑动窗口限制:TCP 拥塞窗口在长肥管道上利用率不足;
  • 丢包放大:AllReduce 强同步,任何一个 Rank 丢包重传,所有 GPU 都要等它。

⚠️ 一处数字要改:原稿写「1% 的丢包率可能放大为 50% 以上的端到端延迟」。这个说法找不到权威出处。 检索到的最接近来源是厂商博客的「1% 丢包率会导致计算集群约 50% 的性能损失」——那是吞吐结论,不是延迟。业界常引的小丢包→RDMA 劣化原始研究是 Microsoft SIGCOMM 2016《RDMA over Commodity Ethernet at Scale》,同样是吞吐口径。建议按机制描述(丢包触发大粒度重传 → 强同步放大),不套用具体的百分比。

按「每卡 550 GB、单卡 TCP 1—2 Gbps」核算,单次 AllReduce 需 37—73 分钟,「超过 30 分钟」在该假设下成立。但要注明这个吞吐假设非常悲观——常规 10/25/100 GbE 的 TCP 实测远高于此。

2.2 RDMA + NCCL:绕过内核

RDMA 的核心是网卡(HCA)直接读写对端内存,绕过双方内核协议栈。配合 GPUDirect RDMA,网卡还能直接访问 GPU 显存:

1
2
TCP 路径:  GPU → PCIe → 主机内存 → 内核协议栈 → 网卡 → 网络 → 对端网卡 → 内核 → 主机内存 → PCIe → GPU
RDMA 路径: GPU → PCIe → 网卡 → 网络 → 对端网卡 → PCIe → GPU
方案 带宽 延迟量级 成本 适用场景
InfiniBand(NDR 400G) 400 Gbps 亚微秒级 高 大规模预训练集群
RoCEv2(以太网) 100—400 Gbps 微秒级 中 中小集群,可复用以太网
TCP(fallback) 10—100 Gbps 数十 µs 低 单机或调试

⚠️ 延迟这一列的精确数字(0.5 µs / 1.4 µs)找不到 NVIDIA 官方一手来源,且原稿没区分「交换机 port-to-port」与「端到端 MPI 延迟」。上表已改为量级表述。引用精确数字前请自行核实口径。

NCCL 在底层自动选择 Ring / Tree / CollNet 算法,并按硬件拓扑选路(NVLink 优先,跨机走 IB/RoCE,必要时混合两级环)。

2.3 K8s 中启用 RDMA

  1. 物理机启用 SR-IOV,把物理网卡切成多个 VF;
  2. 部署 sriov-network-device-plugin(或 Mellanox 的 RDMA device plugin),把 VF 上报为扩展资源;
  3. Pod 声明该资源,并通过 SR-IOV CNI 把 VF 直通到容器 netns;
  4. 容器内出现 /dev/infiniband/uverbs*,可直接调用 ibverbs API。

2.4 NCCL 关键环境变量(含官方默认值)

NCCL 有一系列环境变量需要精细配置,否则会静默回退到 Socket,性能下降一个量级:

1
2
3
4
5
6
7
8
9
10
11
env:
- name: NCCL_DEBUG
value: "INFO" # 打印通信路径选择日志
- name: NCCL_IB_HCA
value: "=mlx5_0,mlx5_1" # 注意 = 前缀的含义,见下
- name: NCCL_SOCKET_IFNAME
value: "eth0" # bootstrap 通道用哪张网卡
- name: NCCL_IB_TIMEOUT
value: "20" # 官方默认就是 20
- name: NCCL_IB_RETRY_CNT
value: "7" # 官方默认 7,取值范围 0-7

几个必须说清的语义:

变量 官方默认 关键点
NCCL_IB_HCA 不设 = 使用全部 ACTIVE 的 IB/RoCE 端口 = 前缀表示精确匹配,不加则按前缀匹配——写 mlx5_1 会同时命中 mlx5_10~mlx5_19。官方明确建议总是加 =
NCCL_IB_TIMEOUT 20(= 4.3 秒) 超时公式 4.096 µs × 2^timeout。默认值在 2.23 起是 20,2.14—2.23 是 18,更早是 14
NCCL_IB_RETRY_CNT 7 与 timeout 相乘 → 默认总等待约 30 秒
NCCL_IB_GID_INDEX AUTO(2.32u1+) 2.21—2.32 默认 -1,≤2.20 默认 0。NCCL 2.21 起会自动选 GID,官方明确说不要再手动设置
NCCL_SOCKET_IFNAME 无固定值 自动算法排除 lo/docker*,优先 ib* 接口
NCCL_IB_DISABLE 不设 = IB 启用 设 1 才禁用

⚠️ 更正:原稿写 NCCL_IB_TIMEOUT: "18"。官方默认值已经是 20(2.23 起),18 是 2.14—2.23 的旧默认。另外「大规模集群建议调大」这个方向是对的——默认 4.3 秒在大 fabric 上确实偏小。

关于 HCA 选择:源码 ncclIbInit 里只做两项过滤(端口必须 ACTIVE、链路层必须是 IB 或 Ethernet),没有任何默认白名单/黑名单,也不按速率或用途过滤。所以「不设 NCCL_IB_HCA 可能选中存储网卡」这个坑属实。

2.5 验证 RDMA 是否生效

1
kubectl logs <training-pod> | grep -E "NET/IB|NET/Socket|Using network"

真实的日志格式(对照 NCCL 源码逐字核实):

1
2
3
4
5
6
7
# RDMA 生效(注意 OOB 后缀是日志的一部分)
NCCL INFO NET/IB : Using [0]mlx5_0:1/RoCE ; OOB eth0:10.0.0.1<0>
NCCL INFO Using network IB

# 回退 Socket
NCCL INFO NET/Socket : Using [0]eth0:10.0.0.1<0>
NCCL INFO Using network Socket

⚠️ 更正:原稿写 NCCL INFO Using Network IB——大写 N 是错的,实际是 Using network IB(小写)。而且 NET/IB : Using ... 那一行后面还有 ; OOB <网卡>:<IP>,grep 时只匹配前半段会漏。

为什么这是「静默」回退:NCCL_DEBUG 默认级别是 WARN,而「IB 初始化失败、改用 Socket」这条消息是 ATTN 级(2.32 起;早期为 INFO),Using network Socket 也是 INFO 级——默认级别下都看不到。所以原稿「必须开 NCCL_DEBUG=INFO 并主动 grep 确认传输方式」的建议是对的。

补充工具:show_gids(查 GID 表)、mlxlink(查链路健康)、ibqueryerrors(查端口错误计数)——三个工具名都属实。

⚠️ GID index 映射要改:原稿写「index 3 = RoCEv1、index 4 = RoCEv2」。按 NVIDIA MLNX_OFED 官方 GID 表例子(mlx5_0),典型映射是 index 2 = RoCEv1(IPv4)、index 3 = RoCEv2(IPv4)。而且这个映射因驱动/固件/网卡而异(Broadcom 网卡的例子又是另一种),必须用 show_gids 看自己机器。再加上 NCCL 2.21+ 已自动选 GID,这个变量根本不该手动设。

2.6 网络层面高频踩坑

  • 未设 NCCL_IB_HCA,选中错误网卡:机器上可能同时有存储用 RoCE 卡和计算用 IB 卡;
  • 未设 NCCL_SOCKET_IFNAME,bootstrap 走了外网网卡:对端不可达,rank 握手挂起或极慢;
  • PFC/ECN 未配置:以太网承载 RoCE 需要交换机侧配 PFC + ECN,否则拥塞时静默丢包,AllReduce 随机挂起。

⚠️ 关于「CNI 未配 MTU → RoCE 被 VXLAN 封装后超限」:MTU 黑洞机制本身成立(VXLAN 开销 50 字节,Pod MTU = Host MTU − 50),但RDMA/RoCE 通常绕过 CNI overlay(走 SR-IOV / macvlan / host network / 独立 RDMA 网段),且 RoCE 用的是 IB 链路 MTU(1024/2048/4096),与内核 TCP MTU 不是一回事。这是特定部署下才成立的场景,不宜作为通用结论。


三、存储鸿沟:冷启动打满源站

3.1 痛点量化

70B 模型 FP16 权重约 140 GB。用 S3 CLI 直拉(出口带宽约 110 MB/s)冷启动需要 约 21 分钟;集群中 10 个节点同时拉同一份权重,S3 出口带宽被瞬间打满;Pod 被驱逐重建、节点扩容都要重新拉全量,没有任何缓存复用。

3.2 JuiceFS + 分布式缓存 + 预热

JuiceFS 把对象存储作为持久层,在计算节点本地维护块级缓存,对外提供 POSIX 接口。核心架构是元数据服务(Redis/TiKV) + 对象存储 + 本地缓存三层解耦。

关键设计差异:JuiceFS 的缓存是块级缓存,落在本地磁盘(推荐 NVMe)。CSI 场景下缓存目录在节点主机上,因此 Mount Pod 重建通常不影响缓存——这是它与 page cache 的关键区别。

⚠️ 一处需要补全:原稿把 JuiceFS 一概描述为「磁盘缓存、不随 Pod 消亡」不完整——JuiceFS 也支持 --cache-dir memory(缓存直接放客户端进程内存)或 /dev/shm,这种模式进程重启就丢。另外 CSI 有 juicefs-clean-cache: "true" 注解会在 Mount Pod 退出时清理缓存。所以「Pod 增删不影响缓存」是默认行为,不是绝对保证。

CSI 挂载参数的正确写法:

⚠️ 更正(这条会让读者照着配置失败):原稿写 juicefs/mount-options: "cache-dir=...,cache-size=..."。JuiceFS CSI 用的是 Kubernetes 标准 mountOptions 字段(YAML 列表),不是自定义键:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: juicefs-sc
provisioner: csi.juicefs.com
mountOptions:
- cache-dir=/var/jfsCache
- cache-size=102400
parameters:
csi.storage.k8s.io/provisioner-secret-name: juicefs-secret
csi.storage.k8s.io/provisioner-secret-namespace: default

官方说明:mountOptions 同时接受 JuiceFS 挂载选项和 FUSE 选项,但FUSE 选项不要写 -o 前缀,每项直接列在 YAML 列表里。

cache-size 的单位坑(原稿这条是对的,值得强调):官方默认值是 102400,单位 MiB(= 100 GiB)。如果误以为是 GiB、写 cache-size=100 想表达 100 GiB,实际只拿到 100 MiB。

分布式缓存组:--cache-group 让同名客户端组成一个缓存组,内部是一个一致性哈希环——每个数据块的归属节点由一致性哈希算出,组内共享。读请求会直接从负责该块的成员节点取。所以「任一节点缓存了、其他节点都能拉」方向正确,但机制是「哈希指定归属 + 组内共享」,不是任意节点随意互拉。

⚠️ --cache-priority 和 --cache-group 都是 企业版/云版特性,社区版只有 --cache-dir。

3.3 缓存预热

1
2
3
4
5
6
7
8
# 社区版:并发度用 -p / --threads
juicefs warmup /jfs/train-data -p 80

# 基于文件列表精确预热,后台执行
juicefs warmup -f filelist.txt --background

# 检查缓存覆盖率
juicefs warmup --check /jfs/train-data

⚠️ 两处更正:

  1. -c 是并发度参数只存在于企业版/云版;社区版源码里是 --threads(别名 -p),默认 50。
  2. warmup --check 报的是缓存覆盖率(N files checked, X of Y (Z%) cached),不是运行时 cache hit rate——它回答「目标路径里多少数据块已经缓存了」。

生产上可以在训练 Job 里加 initContainer 自动预热:

1
2
3
4
5
6
7
initContainers:
- name: cache-warmup
image: juicefs/juicefs:latest
command: ["juicefs", "warmup", "/data/training_set", "-p", "100"]
volumeMounts:
- name: jfs-volume
mountPath: /data

⚠️ 关于「从 21 分钟到 42 秒」这类实测对比表:这组数字(S3 CLI 14分20秒 / NFS 8分50秒 / JuiceFS 3分10秒 + 扩容 42 秒 / 3450 MB/s)来自一篇第三方博客的作者实测,JuiceFS 官方文档中没有这张表,也未见独立复现。方向性可信(分布式缓存 + 预热确实能大幅缩短冷启动),但引用具体数字时必须标注来源与未验证状态。

3.4 存储层面高频踩坑

  • cache-dir 设在 emptyDir 或容器可写层:Pod 删除缓存全丢,命中率恒为 0。必须指向独立持久卷(NVMe SSD);
  • cache-size 与实际数据集不匹配:默认 100 GiB 对 TB 级数据集明显不够,会频繁 LRU 淘汰。建议设为数据集大小的 1.2 倍;
  • 随机读的读放大:

⚠️ 这条原稿归因错了。JuiceFS 把两个机制分得很清楚:readahead 面向顺序读,prefetch 面向随机读(“when a block is randomly read by a small offset range, the whole block is scheduled for download asynchronously”)。所以 safetensors 随机读场景该关的是 --prefetch(设 0),不是 --readahead。10 倍放大这个量级有官方 troubleshooting 例子佐证(客户端读 200 MiB/s、S3 流量涨到 2 GiB/s),但机制归因要改。


四、虚拟化鸿沟:GPU 不是更快的 CPU

4.1 三层失效

第一层:cgroup 不生效。 cgroup 的 cpu controller 在 CFS 调度器层面限制时间片,memory controller 在内核页分配器层面限制内存。但 GPU 执行路径是 进程 → 用户态 CUDA → NVIDIA 驱动 → GPU 硬件调度器,旁路 Linux 内核调度。

第二层:Device Plugin 只上报整数。 nvidia.com/gpu: 8 只传递设备 ID 列表,不携带 NVLink 拓扑、PCIe 亲和性、显存水位。调度器不知道「GPU0 和 GPU1 通过 NVLink 互联」,可能把需要高速互联的进程调度到跨 PCIe root 的卡上。

第三层:共享 GPU 无隔离。 time-slicing 或 CUDA_VISIBLE_DEVICES 切分本质是时间片轮转,多 Pod 争抢 SM、Tensor Core、L2、显存带宽,且任何一个 Pod 触发 OOM 都可能波及其他 Pod。

4.2 MIG:硬件级切分

MIG(Multi-Instance GPU) 把一张物理 GPU 在硬件层切分为多个独立实例,每个实例有独立的 SM、L2、显存控制器和故障域。

⚠️ 更正(原稿这张表标错了代次):原稿写「A100 80GB 的典型 MIG 配置」并给出 7g.40gb / 4g.20gb / 2g.10gb / 1g.5gb——这组 profile 全部是 A100 40GB 的。NVIDIA MIG User Guide 原文:「For A100-SXM4-80GB, the profile names will change according to the memory proportion – for example, 1g.10gb, 1g.20gb, 2g.20gb, 3g.40gb, 4g.40gb, 7g.80gb」。

A100 40GB A100 80GB
1g 1g.5gb 1g.10gb
2g 2g.10gb 2g.20gb
3g 3g.20gb 3g.40gb
4g 4g.20gb 4g.40gb
7g 7g.40gb 7g.80gb

SM 比例是对的(两者都是 7/7、4/7、3/7、2/7、1/7——每片显存取总显存 1/8、每片 SM 取总 SM 1/7),错的只是显存大小与型号的对应关系。

MIG 实例在 Device Plugin 层被上报为独立资源,格式是 nvidia.com/mig-<slice>g.<mem>gb(属实)——调度器可以直接按实例粒度分配,硬件本身就完成了隔离。

「MIG 最多 7 个实例」对 A100/H100 成立,但不是全平台通例(例如 RTX PRO 6000 Blackwell 的 1g profile 只有 4 个实例)。

4.3 拓扑感知

用 nvidia-smi topo -m 看拓扑(缩写的完整定义见上一讲)。生产中通过 GPU Feature Discovery 把拓扑信息以 Node Label 暴露。

⚠️ 更正两个标签名:正确的标签是 nvidia.com/gpu.product(点号,不是连字符 gpu-product),示例值 A100-SXM4-40GB。而 nvidia.com/nvlink 不存在——与 NVLink/fabric 相关的真实标签是 nvidia.com/gpu.clique(GPUFabric ClusterUUID + CliqueID),另有 nvidia.com/gpu.family、nvidia.com/gpu.memory、nvidia.com/gpu.count 等。

4.4 用 DCGM 看真实利用率

核心指标(这四个名字都是 dcgm-exporter 实际暴露的 Prometheus 指标名,可直接查):

指标 含义
DCGM_FI_DEV_GPU_UTIL GPU 时间片利用率(Busy 或 Idle)
DCGM_FI_PROF_SM_ACTIVE SM 活跃度
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE Tensor Core 活跃度
DCGM_FI_PROF_GR_ENGINE_ACTIVE Graphics Engine 活跃度

⚠️ 补一层版本对照:这四个名字在 DCGM 源码 dcgm_fields.h 里都是旧名/别名,规范名已改成 *_UTIL_RATIO:

常用名(dcgm-exporter) DCGM 规范名
DCGM_FI_DEV_GPU_UTIL DCGM_FI_DEV_GPU_UTIL_RATIO(旧名已标 Deprecated)
DCGM_FI_PROF_SM_ACTIVE DCGM_FI_PROF_SM_UTIL_RATIO
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE DCGM_FI_PROF_TENSOR_UTIL_RATIO
DCGM_FI_PROF_GR_ENGINE_ACTIVE DCGM_FI_PROF_GR_ENGINE_UTIL_RATIO

做 Grafana 面板或跨版本对比时会踩到这个改名。

典型异常模式:SM_ACTIVE 长期 >80% 但 PIPE_TENSOR_ACTIVE <30%,说明 GPU 在用 CUDA Core 执行本应由 Tensor Core 承担的矩阵运算——这是模型代码层面的优化信号,不是基础设施问题。

⚠️ 一处口径要收:原稿说 DCGM_FI_PROF_* 系列「会影响性能」。官方口径是 low overhead,唯一的明确限制是 profiling 计数器资源会与 Nsight 等开发者 profiler 冲突(需 dcgmi profile --pause 让出)。另外部分 DCGM_FI_PROF_* 在 dcgm-exporter 默认 collector CSV 里是注释掉的,需要自行开启。


五、可观测性鸿沟:利用率 100%,却在空等

5.1 传统监控的盲区

nvidia-smi 显示的「GPU-Util 100%」,官方定义是「过去一个采样周期内,至少有一个 kernel 在 GPU 上运行」。

⚠️ 更正:原稿说采样周期是「1 秒」。官方原文是「The sample period may be between 1 second and 1/6 second depending on the product」——不是固定 1 秒。

它无法区分:有效矩阵计算、数据搬运(H2D/D2H)、NCCL 通信等待、数据加载等待。一个真实 case:所有 GPU 利用率 100%,但训练吞吐只有理论值的 1/3。Profile 后发现每个 step 里 75% 的时间 GPU 在等 NCCL 通信完成——GPU 显示 busy(有等待 kernel 在跑),实际什么有用功都没做。

5.2 正确指标:MFU + 通信占比 + Step 分解

MFU(Model FLOPs Utilization) 是唯一真正反映硬件有效算力的指标:

1
2
3
4
MFU = 实际 FLOPs/s ÷ 硬件峰值 FLOPs/s

实际 FLOPs = 6 × N_params × Tokens_per_step (PaLM 估算公式)
(6N = 2N forward + 4N backward)

⚠️ 公式要标注是近似:PaLM 论文的完整式是 6N + 12LHQT(含 dense self-attention 的额外项),6N 是忽略注意力项的近似。PaLM 自己说明该项对大模型很小,所以工程上常用 6N——但引用时宜注明是近似。

三个口径问题必须钉死:

  • 稠密 vs 稀疏算力:H100 SXM 的 BF16 官方数字是 稀疏 1979 TFLOPS、稠密约 989 TFLOPS,必须用稠密口径,否则 MFU 虚高一倍。

⚠️ 补两处限定:一是官方 datasheet 把 989 标注为「TF32 Tensor Core(with sparsity)」,BF16 列的是 1979(with sparsity)——数值上 BF16 稠密 ≈ 1979/2 ≈ 989 成立,但引官方表时要注意标注;二是 989/1979 是 H100 SXM 的数字,H100 PCIe 是 BF16 稀疏 1513 / 稠密约 756,不加「SXM」限定口径不完整。

  • 是否含通信:把 AllReduce 算进分母的 MFU 更低但更真实(端到端),只看计算时间的高但掩盖通信瓶颈。两种都报,先固定定义;
  • 精度口径:BF16 与 FP8 峰值不同,混用会测出两种 MFU。

参考值:Llama 3 405B 论文报告的整体 BF16 MFU 是 38—43%(三个配置分别 43% / 41% / 38%,属实)。

⚠️ 一处来源要改:原稿写「nanoGPT 多卡约 50%+,单卡约 35-40%」。追到原始出处是 Karpathy nanoGPT 仓库的 transformer_sizing.ipynb:单卡实测 37.14%(与「35-40%」吻合),但 50%+ 是作者写的期望目标(“we’d prefer to be somewhere around 50%+, and not just for a single GPU but for an entire DDP run”),不是实测达到的多卡 MFU。

另外「MFU 显著低于 30% 时一定存在可优化瓶颈」没有官方来源,属社区经验法则。措辞宜改为「经验上低于约 30% 通常意味着存在可排查的瓶颈」,不宜用「一定」。

BusBW 与 algbw 的区别(nccl-tests):algbw = S/t;busbw 是对 algbw 乘一个与集合操作和 rank 数相关的修正因子(AllReduce 是 2(n−1)/n),用来反映互连硬件瓶颈速率,从而可与硬件峰值带宽直接对比。看 busbw 列,不看 algbw。

Step 时间分解:把一次迭代拆成 compute / comm / data_load / other 四段。Grafana 面板建议:

1
2
3
4
Panel 1: MFU 趋势(目标 >40%)
Panel 2: Step 时间分解堆叠图
Panel 3: NCCL BusBW(目标接近网卡理论值)
Panel 4: DCGM SM_ACTIVE vs PIPE_TENSOR_ACTIVE(识别用错 kernel)

5.3 识别「空等」

1
2
# SM 活跃但 Tensor 活跃低 → 用错 kernel 或大量等待
DCGM_FI_PROF_SM_ACTIVE > 0.8 and DCGM_FI_PROF_PIPE_TENSOR_ACTIVE < 0.3

对比实验:正常训练任务的 PIPE_TENSOR_ACTIVE 与 SM_ACTIVE 同步波动;模拟空等任务(sleep + 小 kernel 交替)则 SM_ACTIVE 高、PIPE_TENSOR_ACTIVE 接近 0。


六、面试深挖:cgroup 为什么管不住 GPU?

分三层回答。

第一层:机制层——GPU 执行路径旁路内核调度。
CPU 路径是 进程 → 内核(CFS + cgroup controller)→ CPU 时间片;GPU 路径是 进程 → 用户态 CUDA → NVIDIA 驱动 → GPU 硬件调度器。Linux 内核在这条路径上只是「传话筒」。NVIDIA 驱动注册 /dev/nvidia* 字符设备,用户态通过 ioctl 提交命令——cgroup 的 device controller 可以允许/禁止访问设备节点,但无法计量、无法限制进程提交的 kernel 数量、显存申请量或 SM 占用时长(device controller 是纯访问控制,没有计量能力,这一点核实属实)。

第二层:资源模型层——Device Plugin 只有「整数」。
Kubernetes 的 ResourceName → Quantity 模型里,Device Plugin 上报的是 nvidia.com/gpu: 8,协议只传设备 ID 列表。调度器无法回答:GPU0 和 GPU1 之间是 NVLink 还是 PCIe?当前剩余多少显存?某个 Pod 实际用了多少算力?(nvidia-smi 的利用率是设备级全局值,不是 per-process。)

第三层:补救手段层——四种方案。

方案 原理 隔离强度 适用场景
MIG 硬件切分 SM / L2 / 显存控制器 硬件级强隔离 A100+ 多租户推理
Time-slicing 用户态轮转 kernel 提交 弱(无隔离) 开发测试
MPS 用户态合并多进程 kernel 提交 中等 低延迟推理共享
拓扑感知调度 调度器约束 GPU 选择 不隔离,只优化位置 单租户训练

MPS 的隔离强度值得说准:它有地址空间隔离(fully isolated GPU address spaces),故障隔离是有限的(一个 client 的致命 fault 会波及共享该 GPU 子集的所有 client),SM 是软限制(“does not reserve dedicated resources”),显存需要显式配置才有隔离。

⚠️ 一处时效性更正(原稿的结论已经过时)

原稿的收尾断言是:「只要 NVIDIA 驱动不对 cgroup v2 暴露 nvidia/memory.max 等 controller 接口,K8s 的 Pod 级别细粒度 GPU 资源 QoS 就永远是『尽力而为』,而非『硬保障』。」

这个结论现在不成立了。 NVIDIA 已通过 MPS v3 memory partitioning 提供了 cgroup v2 的 GPU 显存限制,官方原文:

“MPS v3 memory partitioning fractionalizes a GPU’s device memory across cgroups and containers… Unlike the older userspace memory limits, this feature accounts for all device memory and integrates with the Linux kernel cgroup controllers.”
“Hard limit maps to dmem.max… Soft limit maps to dmem.min.”
后端:kernel 6.14+ 有 DMEM controller 时用 dmem;否则回退到驱动内部的 misc 后端做 per-cgroup 记账。需要 cgroup v2 且 CUDA 13.4+,且不支持 MIG 设备。

接口名是内核标准的 dmem.max / dmem.min,不是 nvidia/memory.max。设置方式:nvidia-smi memory-limits --set -n <cgroup路径> --hard-limit ... 。

所以准确的结论是:GPU 显存的 cgroup 级硬限制已经存在,但很新、对内核版本(≥6.14)与 CUDA 版本(≥13.4)有要求、且不支持 MIG。旧结论不是错,是过时。


七、生产避坑清单

调度层

  • Gang 语义失效时先看 Pod 是否永久 Pending、schedulerName 是否指向不存在的调度器(不是「不写 schedulerName」);
  • MIG 模式下 device plugin 需传 --mig-strategy(none|single|mixed,默认 none);
  • 内核升级后 /dev/nvidia* 可能消失,Device Plugin 报错但 DaemonSet 仍 Running。

网络层

  • NCCL 静默回退 Socket 是最隐蔽的坑——默认 WARN 级别看不到回退消息,必须开 NCCL_DEBUG=INFO 主动 grep;
  • NCCL_IB_HCA 记得加 = 前缀(否则前缀匹配会误命中 mlx5_1x);
  • NCCL 2.21+ 不要再手动设 NCCL_IB_GID_INDEX,让它自动选;
  • PFC/ECN 未配 → 拥塞时静默丢包,AllReduce 随机挂起。

存储层

  • cache-dir 必须指向独立持久卷,不能是 emptyDir;
  • cache-size 单位是 MiB,默认 102400(=100 GiB);
  • CSI 挂载参数写在标准 mountOptions 字段,不是 juicefs/mount-options;
  • 随机读放大调的是 --prefetch,不是 --readahead;
  • 预热任务避开训练高峰,或用 -p/--threads 限流。

可观测性层

  • MFU 口径(稠密/稀疏、含不含通信、精度)必须在团队内钉死;
  • DCGM 指标名有 *_ACTIVE → *_UTIL_RATIO 的改名史,跨版本做面板要映射;
  • kubectl top 看不到 GPU——它只采 cgroup 数据。

八、集群自查速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 调度死锁:大量 Pending 意味着资源错配
kubectl get pods -A | grep -E 'Pending|ContainerCreating'

# 2. 是否真正启用 Volcano(看有没有 PodGroup 资源)
kubectl get podgroup -A

# 3. NCCL 是否走 RDMA(只有 Socket 行说明配置有问题)
kubectl logs <training-pod> | grep -E "NET/IB|NET/Socket|Using network"

# 4. 存储缓存层是否启用
kubectl get pvc -A | grep juicefs

# 5. GPU 拓扑:确认需要高速通信的进程是否在同一 NVLink 域
kubectl exec -it <gpu-pod> -- nvidia-smi topo -m

# 6. GPU 真实利用率:区分 busy 和真正在计算
curl http://<dcgm-exporter>:9400/metrics | grep -E "DCGM_FI_PROF_(SM|PIPE_TENSOR)_ACTIVE"

九、结语:跨越鸿沟的本质是建立数据面思维

AI Infra 的核心不是「学几个新工具」,而是从控制面思维转向数据面思维——关注点从「资源能否被分配」变成「数据能否被高效搬运和计算」。

五道鸿沟对应五个数据面本质:

  • 调度的本质是「同步语义的资源整组性」;
  • 网络的本质是「绕过内核的数据直通」;
  • 存储的本质是「缓存层与持久层的解耦」;
  • 虚拟化的本质是「硬件级隔离与拓扑亲和」;
  • 可观测性的本质是「从时间占用到有效算力的度量」。

当你能从一次训练的 NCCL 日志反推出物理网络拓扑,能从 MFU 的下降趋势定位到具体是通信还是数据加载瓶颈,能解释清楚 cgroup 管不住 GPU 的三层机制(以及它正在被 MPS v3 补齐)——三万薪资天花板,自然就不再是天花板。


相关阅读

核实说明:本文的技术论断逐条核对自一手来源——Volcano 源码(pkg/webhooks/admission/jobs/mutate/mutate_job.go、pkg/util/util.go、插件目录)与官方文档、Kubernetes 官方文档(扩展资源、多调度器)、NVIDIA MIG User Guide(supported-mig-profiles)、NVIDIA k8s-device-plugin 与 GPU Feature Discovery 文档、NCCL 2.32.3 官方环境变量文档与 master 分支源码(net_ib/init.cc、net_socket/common.cc、plugin/net.cc)、nccl-tests PERFORMANCE.md、PaLM 论文(arXiv:2204.02311)、Llama 3 论文(arXiv:2407.21783)、DCGM 源码 dcgm_fields.h 与 dcgm-exporter 指标文档、JuiceFS 官方命令参考与缓存指南。

原稿更正汇总:schedulerName 的 webhook 行为(说反了)、rdma/hca 的插件归属、gpu-topology-aware 不是 Volcano 插件、overengineering 字段不存在、A100 MIG profile 代次张冠李戴、--mig-strategy 漏 none、GFD 标签名(点号、nvlink 不存在)、Ring AllReduce 每步数据量写错、550GB 的口径(每卡而非集群)、NCCL_IB_TIMEOUT 默认值(20 非 18)、NCCL 日志字符串大小写与 OOB 后缀、GID index 映射、1% 丢包→50% 延迟无出处、JuiceFS CSI 参数名应为 mountOptions、warmup -c 的版本归属、warmup --check 的语义、随机读放大归因(prefetch 非 readahead)、DCGM 指标名的规范名对照、nvidia-smi 采样周期、MPS v3 已提供 cgroup v2 GPU 内存限制(原结论过时)。另有一组第三方单来源的实测数字已标注来源与未验证状态。