第一次把 PyTorch 的 DDP 训练搬进 K8s,绝大多数人卡住的地方不是模型代码,而是环境。torchrun 在物理机上跑得好好的,打成镜像丢进 Pod 之后,日志停在一行 NCCL 初始化提示上,几十分钟不动,最后给你一个 collective timeout。你去翻代码,代码没错;你去看 GPU,GPU 也在。问题出在 K8s 这一层:Pod 之间的地址怎么互相知道、共享内存给了多少、GPU 是怎么被声明和分配的、跨节点走的是哪张网卡。这些在裸机上由运维默认值替你兜住的东西,在 K8s 里全都变成了你要显式写出来的配置。
这篇东西讲的就是怎么把PyTorch 分布式训练稳定地部署在K8s上。我会从方案选型一路讲到 YAML 怎么写、参数怎么算、NCCL 报错怎么对着排查,最后给一份可以照着改的完整配置。适合三类人看:手上有几张卡想跑单机多卡的、准备上多机多卡但被调度和网络折磨过的、以及已经在跑但性能只有理论值一半、想找找瓶颈在哪的。基础概念我会顺带讲清楚,不需要你提前精通 K8s 或 NCCL,但至少要写过torchrun,知道RANK和WORLD_SIZE大概是什么。
1. 先搞清楚:能跑起来和跑得好是两码事
1.1 从单机多卡到多机多卡,多出来的活是谁的
单机多卡的时候,世界很小。所有进程在同一台机器上,localhost就能互相找到,卡之间走 NVLink 或者 PCIe,通信带宽几十到几百 GB/s,torchrun --nproc_per_node=8 train.py一行命令就完事。这时候你根本不用关心网络,因为网络这一层被主板吃掉了。
多机多卡是另一回事。两台机器之间只有一张网卡,带宽从 NVLink 的几百 GB/s 掉到 25G/100G 以太网的几 GB/s 甚至几百 MB/s,差了整整一个数量级。梯度同步是每步都要做的事,通信一旦成为瓶颈,你加机器不但不提速,反而更慢。所以多机训练的第一原则不是"堆卡",而是先确认通信链路够不够宽。我自己总结的经验是:千兆网跑多机 DDP 基本没有意义,25G 勉强能跑小模型,真要上多机,100G 起步,有条件上 InfiniBand 或者 RoCE。
除了带宽,还有"身份"问题。每个进程要有一个全局唯一的RANK,要有一个大家都能连上的MASTER_ADDR,要知道总共多少个进程(WORLD_SIZE)。裸机上这套信息靠命令行参数传进去就行,因为节点是固定的。K8s 里 Pod 会被调度、会被重建、IP 会变,这套身份信息必须由编排层动态注入,这是后面所有方案的共同难点。
1.2 K8s 给你加了三个变量
第一个变量是调度。你声明要 8 张卡,K8s 保证给你 8 张,但不保证这 8 张是同一型号、不保证在同一个 NUMA 节点上、不保证和其他任务隔离。调度器只看剩余资源够不够,它不懂什么叫"模型并行需要卡间高带宽"。这就要求你在 YAML 里用节点亲和性把范围框死。
第二个变量是网络模型。Pod 有自己的 IP 和网络命名空间,默认情况下跨节点通信走的是 overlay 网络或者 CNI 插件,中间可能有一次 NAT 或者隧道封装。NCCL 对这种环境并不总是友好,尤其是需要 RDMA 的时候。你要么开hostNetwork,要么把正确的网卡名字告诉 NCCL,否则它会挑错接口,然后卡死。
第三个变量是存储。训练数据动辄几百 GB,不可能打进镜像。Checkpoint 又要能跨 Pod 共享,Pod 重建之后还得能读回来。K8s 的 PV/PVC 抽象解决了挂载问题,但读写性能是另一码事——NFS 挂上去读写小文件,DataLoader 的 worker 能把 IO 打满,GPU 空转等数据,这个坑非常常见。
把这三个变量想清楚,后面的选型和配置才有依据。不然你只是把一台物理机的问题,原封不动搬到了一个更复杂的环境里。
2. 三条落地路径,按团队规模挑
2.1 裸 Pod + torchrun:最直接,也最容易踩坑
思路很简单:写一个 Deployment 或者干脆几个独立的 Pod,每个 Pod 里跑torchrun,MASTER_ADDR写死成某个 Service 的名字。
这种做法的好处是零依赖,不需要装任何 CRD,出问题的时候kubectl exec进去随便折腾。适合的场景是:固定几个节点、任务不频繁、你自己就是运维。
问题在于"写死"这两个字。Pod 重建之后如果不是原来的 IP,MASTER_ADDR用 Service 还能兜住;但WORLD_SIZE和RANK是写死在环境变量里的,Pod 数量一变就全乱。另外 K8s 的 Deployment 是"尽力而为"的,它不保证 N 个副本同时起来——你可能 pod-0 起来跑了十分钟,pod-1 还在拉镜像,pod-0 那边已经在等集合通信了,等到超时挂掉,然后 restartPolicy 又把它拉起来,循环往复。这个场景我见过太多次,日志里全是Connection refused。
提示:如果你只是想验证多机能不能通,用裸 Pod 完全够用,但一定要把
init_process_group的 timeout 设长一点(比如 60 分钟),给镜像拉取和调度留出时间,否则你会误以为是网络问题。
2.2 原生 Job + 自写编排:中间态
比裸 Pod 进一步,用 K8s 原生的Job,配合一个 initContainer 或者小的 sidecar 去做 rendezvous。常见的做法是起一个 headless Service,各 Pod 通过 DNS 发现彼此,然后用c10d的env://或者tcp://方式初始化。
这个方案比裸 Pod 好在:Job 的completions和parallelism语义更贴近"批处理任务",跑完就结束,不会像 Deployment 那样无限重启。但你还是得自己处理"所有 Pod 都 Ready 之后才开始"这件事,原生 Job 没有这个能力。有人用 initContainer 轮询 DNS 记录数,凑够 N 个才开始,这种土办法能用,但很脆——DNS 缓存、Pod 短暂不可用都会让它误判。
2.3 PyTorchJob:目前生产环境的主流选择
这是 Kubeflow Training Operator 提供的一个 CRD,专门为 PyTorch 训练设计。你声明Master和Worker两种副本,operator 负责创建 Pod、注入环境变量、管理生命周期。
它的核心价值在于把 rendezvous 这件事收进框架。你不需要自己算 rank,operator 会按顺序给每个 Pod 分配RANK,并把MASTER_ADDR指向 master 的 Service。同时它内置了restartPolicy的语义,某个 worker 挂了可以只重启那个 worker,master 会重新发起 rendezvous。
代价是多装一个组件,并且要理解它的副本语义:Master的 replicas 通常是 1(它同时也是一个训练进程,不是纯协调者),Worker的 replicas 是 N-1。很多人第一次写会以为 Master 只是调度用的,结果写了个replicas: 0,直接起不来。
2.4 三条路径的横向对比
| 维度 | 裸 Pod + torchrun | 原生 Job | PyTorchJob |
|---|---|---|---|
| 额外组件 | 无 | 无 | Training Operator |
| rank 注入 | 手写 | 手写 | 自动 |
| 部分启动保护 | 无 | 无 | 有基础保护 |
| 单 worker 重启 | 整组重建 | 整组重建 | 可单独重启 |
| 学习成本 | 低 | 中 | 中 |
| 适合规模 | 1-2 节点验证 | 固定小集群 | 多节点长期使用 |
我个人的建议是:验证阶段用裸 Pod 把通信打通,正式跑用 PyTorchJob。中间那条原生 Job 的路线,除非你有很强的定制需求,否则不值得花时间,因为它解决的正好是 PyTorchJob 已经解决掉的那部分问题。
3. 镜像与集群前置条件,先对齐再动手
3.1 版本对齐:驱动、CUDA、PyTorch、NCCL 四件套
这是最容易被忽略、也最容易导致"莫名其妙卡死"的地方。四者的关系是:宿主机驱动版本决定了能跑的最高 CUDA 版本;镜像里的 CUDA runtime 不能超过这个上限;PyTorch 是按某个 CUDA 版本编译的;NCCL 是 PyTorch 自带的,但它的行为又受宿主机上的 IB 驱动和网卡影响。
一个典型的翻车场景:镜像里是 CUDA 12.x 编译的 PyTorch,宿主机驱动只支持到 CUDA 11.8。容器能起来,torch.cuda.is_available()返回 True,甚至单卡能跑,但ncclCommInitRank时会返回一个很含糊的错误码。所以第一步永远是查宿主机:nvidia-smi右上角那个CUDA Version是驱动支持的上限,镜像选型不能超过它。
还有一点,多机训练要求所有节点的驱动版本、GPU 型号尽量一致。混插不同型号的卡,NCCL 会在拓扑探测阶段选一个保守的路径,性能直接砍半;极端情况下会因为 kernel 编译差异导致 hang。我给集群打标签的时候一定会把 GPU 型号写进 label,调度时强约束。
3.2 代码和数据都不要打进镜像
镜像只装环境:Python、PyTorch、torchvision、常用依赖、你团队内部的 pip 包。代码用 ConfigMap 或者 initContainer 从 Git 拉,数据用 PVC 挂。理由很实在:镜像一旦打进去,改一行 print 都要重新构建推送拉取,一轮下来十分钟,调试效率归零。
具体做法我在第 7 节会给完整的 Dockerfile。这里先说一个细节:pip install torch装的是 PyPI 上的 CUDA 版本,体积大但省事;如果要特定 CUDA 版本,用官方提供的 index-url,比如--index-url https://download.pytorch.org/whl/cu121。别用conda在镜像里装,层数太多,镜像轻松冲到 10 GB,节点拉取时间比训练时间还长。
3.3 集群侧必须确认的四件事
动手之前,这四个检查项一个都不能少,我每次上新集群都按这个清单走一遍:
第一,NVIDIA device plugin 是否正常。执行kubectl get nodes -o json | grep nvidia.com/gpu,看 allocatable 里的 GPU 数量对不对。如果全是 0,说明 device plugin 没跑起来,或者节点的nvidia.com/gpu标签没打上。这个不解决,后面所有 YAML 都是白写。
第二,共享内存。容器的/dev/shm默认只有 64 MB,PyTorch 的 DataLoader 用共享内存传张量,worker 一多立刻Bus error (core dumped)。这个报错信息极具迷惑性,看起来像内存越界,其实跟代码一点关系没有。解决方式有两个:挂一个emptyDir: {medium: Memory}到/dev/shm,或者在容器里加--shm-size(K8s 里只能通过 emptyDir 实现)。
第三,节点间网络连通性。挑两个节点各起一个 Pod,互相ping和iperf3测一下。不要相信"集群是通的"这种话,overlay 网络和物理网络之间的差异经常能把带宽吃掉一半。
第四,镜像仓库可达。大规模集群上镜像拉取失败是最常见的"卡住"原因,表现出来是 Pod 一直在ContainerCreating。加一个imagePullPolicy: IfNotPresent和私有仓库的 secret,能省掉一半的排查时间。
4. GPU 资源声明与调度:让 Pod 落在对的机器上
4.1 资源请求的正确写法和三种常见误写
GPU 在 K8s 里是扩展资源,只能写limits,不能只写requests。如果你只写了requests,调度器认为这个 Pod 不需要独占 GPU,可能把两张卡分给两个 Pod,然后两边都报 OOM。
正确的写法是这样:
resources: limits: nvidia.com/gpu: 4 requests: cpu: "32" memory: "256Gi"注意我没有写requests.nvidia.com/gpu,因为写了会报错——扩展资源要求 requests 和 limits 相等,而 K8s 会自动把 limits 复制到 requests。另外 CPU 和内存一定要写足,很多人只盯着 GPU 数量,结果 CPU 给了 4 核,DataLoader 起 8 个 worker,数据预处理直接成为瓶颈,GPU 利用率长期在 30% 上下。经验值是每张 GPU 配 8 到 16 核 CPU、64 GB 内存,具体看你的数据 pipeline 有多重。
还有一个隐蔽的坑:nvidia.com/gpu的值必须是整数。你想表达"半张卡"是做不到的,除非用 MIG(Multi-Instance GPU)或者时间切片,那是另一套配置,本文不展开。
4.2 用节点亲和性和污点把范围框死
声明了要 4 张卡,调度器会找任意一台有 4 张空闲卡的机器。但你可能希望所有 worker 都在同一批高带宽的机器上,避免某台机器跨了交换机。这里用nodeAffinity加上自定义标签:
affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: gpu-model operator: In values: ["a100-80g"] - key: network-tier operator: In values: ["ib-200g"]配套的做法是在节点上打标签和污点:kubectl label node xxx gpu-model=a100-80g,kubectl taint node xxx dedicated=training:NoSchedule,然后在 Pod 里加对应的tolerations。双重保险的意思是:即使有人误调度普通业务上来,污点也会把它挡掉,不会出现训练任务和在线服务抢卡的尴尬。
4.3 Gang Scheduling:为什么必须开
这是多机训练里最关键的调度语义。默认调度器是"逐个调度"的:pod-0 有空位就先放上去,pod-1 排队等资源。结果就是 pod-0 已经启动并进入init_process_group,pod-1 因为等其他任务释放资源卡了半小时,pod-0 等到 timeout 自己退出,然后重启,pod-1 终于调度上了,pod-0 又在重启中,两个永远对不上。
Gang Scheduling 的语义是"全有或全无":要么 N 个 Pod 同时被调度,要么一个都不调度。实现方式有 Volcano 的PodGroup、Kueue 的ClusterQueue,以及较新版本调度器的 coscheduling 插件。配好之后,资源不够的时候任务会老老实实排队,而不是反复启动失败。
这个改动看起来小,但对集群稳定性的影响巨大。我之前待过的一个团队,就是因为没开 gang scheduling,一个 16 卡的任务在资源紧张的集群上跑了三天没起来,日志里几百次重启记录,最后发现是调度顺序的问题。
4.4 全局 batch 和学习率的换算
调度配好之后,还有一个必须提前算清楚的数字:全局 batch size。它等于每卡 batch × 每节点卡数 × 节点数。假设每卡 32,每节点 8 卡,2 个节点,全局就是 512。
这个数字决定了你的学习率。业界常用的线性缩放规则是:全局 batch 翻倍,学习率翻倍。比如基线是 batch 256 对应 lr 0.1,现在全局 batch 是 512,那 lr 就是 0.2。但这个规则在大 batch 下会失效,需要配合 warmup:
base_lr = 0.1 base_batch = 256 global_batch = per_gpu_bs * gpus_per_node * num_nodes lr = base_lr * global_batch / base_batch warmup_steps = 500 # 约占总步数的 5%我用过的经验是:全局 batch 超过 4096 之后,线性缩放基本不再成立,得换成平方根缩放(lr = base_lr * sqrt(global_batch / base_batch)),或者干脆不缩放、拉长训练轮次。这个没有标准答案,要靠小规模实验试出来。别照抄别人的配置,模型结构不一样,最优值差很多。
另外提醒一句:改全局 batch 的时候,别忘了同步改DataLoader的num_workers和prefetch_factor。batch 变大意味着每个 step 读的数据变多,如果数据加载跟不上,GPU 会等 IO,加机器反而更慢,这个现象在小文件多的数据集上特别明显。
5. 通信层:NCCL 才是性能命门
5.1 rendezvous 的地址到底从哪来
init_process_group需要一个 rendezvous 端点。常见写法是env://,它会去读环境变量MASTER_ADDR、MASTER_PORT、RANK、WORLD_SIZE。在 PyTorchJob 里,这四个变量由 operator 注入,你不用管。在裸 Pod 方案里,就得自己想办法。
最稳的做法是用一个 headless Service,给它一个稳定的 DNS 名,比如train-master.ml.svc.cluster.local,然后MASTER_ADDR写这个名字。headless Service 的特点是它不做负载均衡,DNS 直接解析到 Pod IP,正好符合 rendezvous 的语义。
注意:不要用普通的 ClusterIP Service 做
MASTER_ADDR。ClusterIP 会做 NAT 和负载均衡,某个 worker 的连接可能被转发到别的 Pod 上,表现是rank重复或者连接被拒绝,这种问题排查起来非常痛苦。
MASTER_PORT建议避开常用端口,用 23456 之类,减少和其他服务冲突的概率。同一节点上跑多个任务时,端口一定要错开,否则第二个任务会报Address already in use。
5.2 hostNetwork、网卡名和 RDMA
默认情况下 Pod 在独立的网络命名空间里,NCCL 探测到的网卡可能是一张虚拟网卡(比如eth0对应 veth)。如果你有 InfiniBand 或者 RoCE,这些设备不会自动出现在 Pod 里,除非用hostNetwork: true并且把设备挂进去。
开hostNetwork的代价是 Pod 共用宿主机网络,端口会冲突,所以每个节点的MASTER_PORT要单独规划。但如果你的集群有 IB 网络,这个代价是值得的——不开的话,NCCL 会静默地退回到 TCP socket,带宽从 200 Gb/s 掉到 10 Gb/s,训练速度直接砍到十分之一,而日志里只有一行不起眼的NET/Socket。
环境变量这块,我常用的组合是:
export NCCL_DEBUG=INFO export NCCL_SOCKET_IFNAME=eth0 export NCCL_IB_DISABLE=0 export NCCL_IB_HCA=mlx5_0,mlx5_1 export NCCL_NET_GDR_LEVEL=2 export NCCL_TOPO_DUMP_FILE=/tmp/nccl_topo.xmlNCCL_DEBUG=INFO打开后在日志里能看到它显式选了哪条路径、用的什么算法、ring 还是 tree。这个信息在调优时极其重要,别嫌日志长。NCCL_TOPO_DUMP_FILE会导出一份拓扑文件,确认它识别到了 NVLink 和 IB,如果里面只有 PCIe,说明配置没生效。
5.3 NCCL 报错对照表
| 报错关键字 | 大概率原因 | 处理方向 |
|---|---|---|
unhandled system error | 网卡选错或 IB 驱动缺失 | 检查NCCL_SOCKET_IFNAME,确认容器内能看到 IB 设备 |
Connection timed out | 某节点进程没起来或端口不通 | 核对 WORLD_SIZE 与实际 Pod 数,检查 NetworkPolicy |
Duplicate GPU detected | 多个进程用了同一张卡 | 检查CUDA_VISIBLE_DEVICES和LOCAL_RANK映射 |
Bus error | 共享内存不足 | 挂emptyDir: {medium: Memory}到/dev/shm |
NCCL WARN Connect to ... failed | 跨节点 DNS 解析或防火墙 | 用nc -vz手动验证端口连通性 |
这张表是我踩坑之后自己整理的,大部分"卡在初始化"的问题都能往里套。需要强调的是,报错信息本身经常具有误导性,比如Bus error看起来像段错误,实际上是 shm 不够;Duplicate GPU看起来像驱动问题,实际上是启动脚本里LOCAL_RANK算错了。看到报错先别改代码,先按表对一遍环境。
6. 数据与 Checkpoint:别让 IO 拖垮训练
6.1 数据集挂载的三种打法
第一种是直接挂 NFS/CephFS 这类共享存储。优点是所有 Pod 立即可见,不用同步;缺点是随机读写性能差,小文件多的时候 DataLoader 会卡成幻灯片。我只在数据集是单个大文件(比如打包好的.tar或者.webdataset)的时候用这种方式。
第二种是每个节点本地盘预热。用一个 initContainer 把数据从共享存储rsync到本地 NVMe,训练时读本地。这个方案性能最好,代价是启动时间变长,而且每个节点要预留足够的本地空间。大模型训练我优先推这个。
第三种是用对象存储配合流式读取,比如把样本存成一个个分片,训练时按需拉取。适合数据集大到本地放不下的场景,但要求你的数据 pipeline 自己处理缓存和重试,实现成本不低。
6.2 DataLoader 的 worker 数和共享内存的联动
num_workers不是越大越好。每个 worker 是一个独立进程,会复制一份数据集索引,内存占用翻倍。经验公式是num_workers = min(8, CPU核数 / GPU卡数),超过这个数收益递减。但真正决定上限的是/dev/shm:每个 worker 通过共享内存往主进程传 tensor,16 GB 的 shm 大概能支撑 8 个 worker 跑中等分辨率的图像增强,超过就会Bus error。
我一般的配置是:shm 给 16 GB,num_workers给 8,pin_memory=True,prefetch_factor=4。这组数字在图像分类任务上比较通用,检测分割类任务要往上加。
6.3 Checkpoint 落盘策略
多机训练动辄几十小时,中途节点故障、抢占、镜像更新都会打断。Checkpoint 必须写到 PVC 上,而且是所有 Pod 都能读到的 PVC(ReadWriteMany),不能写本地盘。
策略上有两个细节值得说。一是只让 rank 0 写,其他 rank 等barrier。如果所有 rank 同时往同一个路径写,文件会互相覆盖,恢复时读到的可能是损坏的 shard。二是异步保存,把state_dict先拷到 CPU 内存,起一个后台线程写盘,主训练流程继续往下跑。同步写盘那几十秒,GPU 是完全空闲的,长训练下来浪费的时间很可观。
if dist.get_rank() == 0: torch.save(ckpt, f"{ckpt_dir}/step_{global_step}.pt") dist.barrier()恢复的时候要记得不仅恢复模型参数,还要恢复优化器状态、学习率调度器状态和当前 step,否则学习率会从头开始,前面训练的效果打折。
7. 完整实操:两节点十六卡拉起一次训练
7.1 构建训练镜像
FROM pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime RUN pip install --no-cache-dir \ timm==1.0.3 \ tensorboard==2.16.2 \ opencv-python-headless==4.9.0.80 # 只放启动脚本,业务代码通过 ConfigMap 挂载 COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh WORKDIR /workspace ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]选runtime版本而不是devel,镜像能小一半。镜像里不装 OpenMPI,PyTorch 2.x 的torchrun已经不需要它了,装了反而可能因为版本冲突引发问题。opencv-python一定要用headless版,否则会带一堆 GUI 依赖,在容器里跑不起来。
启动脚本里做一件事:把 NCCL 相关环境变量设好,然后交给 torchrun。
#!/bin/bash set -e export NCCL_DEBUG=${NCCL_DEBUG:-WARN} export NCCL_SOCKET_IFNAME=${NCCL_SOCKET_IFNAME:-eth0} export OMP_NUM_THREADS=8 exec torchrun \ --nnodes=$WORLD_SIZE_NODES \ --nproc_per_node=$GPUS_PER_NODE \ --rdzv_backend=c10d \ --rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT \ --rdzv_id=$JOB_NAME \ train.py --config /workspace/config.yaml7.2 PyTorchJob YAML 逐段说明
apiVersion: kubeflow.org/v1 kind: PyTorchJob metadata: name: resnet-train namespace: ml spec: pytorchReplicaSpecs: Master: replicas: 1 restartPolicy: OnFailure template: metadata: labels: {app: resnet-train} spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet containers: - name: pytorch image: registry.local/ml/train:2.3.0 command: ["/bin/bash", "-c"] args: - export WORLD_SIZE_NODES=2 GPUS_PER_NODE=8 MASTER_PORT=23456; /usr/local/bin/entrypoint.sh resources: limits: nvidia.com/gpu: 8 requests: cpu: "96" memory: "512Gi" volumeMounts: - {name: dshm, mountPath: /dev/shm} - {name: ckpt, mountPath: /mnt/ckpt} - {name: data, mountPath: /mnt/data, readOnly: true} volumes: - name: dshm emptyDir: {medium: Memory, sizeLimit: 16Gi} - name: ckpt persistentVolumeClaim: {claimName: ckpt-rwx} - name: data persistentVolumeClaim: {claimName: dataset-rox} Worker: replicas: 1 restartPolicy: OnFailure template: # 与 Master 完全一致,仅资源可微调几个要点解释一下。hostNetwork: true配合dnsPolicy: ClusterFirstWithHostNet,意思是网络走宿主机但 DNS 仍然用集群的,这样 Service 名字还能解析。少了后者,Pod 会找不到kubernetes.default.svc,各种诡异的 DNS 超时。
restartPolicy: OnFailure是 operator 层面的,不是 K8s 的 Pod 级别,它控制的是"整个副本失败了要不要重建"。设成OnFailure意味着挂了会自动拉起,适合长时间训练;如果你希望失败了保留现场排查,设成Never。
sizeLimit: 16Gi这个字段很关键,它给emptyDir设了上限,防止某个进程把共享内存吃光导致节点 OOM。设太小会Bus error,设太大有风险,16 GB 是个比较稳妥的中间值。
7.3 启动后的第一分钟看什么
任务提交之后,别急着去看训练日志,先按这个顺序确认三件事:
# 1. Pod 是不是都 Running,GPU 有没有分到 kubectl get pods -n ml -l app=resnet-train kubectl describe pod resnet-train-master-0 -n ml | grep -A2 "nvidia.com/gpu" # 2. 环境变量注入对不对 kubectl exec -n ml resnet-train-master-0 -- env | grep -E "MASTER|RANK|WORLD" # 3. NCCL 到底选了哪条路 kubectl logs -n ml resnet-train-master-0 | grep -E "NET/|NCCL INFO"第三步最容易被跳过,但它决定了你的训练是 100% 速度还是 10% 速度。如果日志里出现NET/IB和mlx5,说明 RDMA 生效了;如果只有NET/Socket,那就是走 TCP,这时候要回去检查 IB 设备和NCCL_SOCKET_IFNAME。
7.4 断点续训怎么接
在训练脚本里加一段启动逻辑:先看 checkpoint 目录下有没有最近的step_*.pt,有就加载并跳过对应的 step。
def find_latest(ckpt_dir): files = glob.glob(f"{ckpt_dir}/step_*.pt") if not files: return None, 0 latest = max(files, key=lambda p: int(re.search(r"step_(\d+)", p).group(1))) step = int(re.search(r"step_(\d+)", latest).group(1)) return latest, step ckpt_path, start_step = find_latest(args.ckpt_dir) if ckpt_path: state = torch.load(ckpt_path, map_location="cpu") model.load_state_dict(state["model"]) optimizer.load_state_dict(state["optimizer"]) scheduler.load_state_dict(state["scheduler"]) start_step = state["step"] if dist.get_rank() == 0: print(f"resumed from {ckpt_path} at step {start_step}") dist.barrier()注意map_location="cpu",如果直接 load 到 GPU,每个 rank 都会往自己那张卡上塞一份,显存瞬间爆掉。先落到 CPU 再让 DDP 自己 broadcast,是最省显存的写法。
8. 排障实录:三类问题各挑一个讲
8.1 卡在初始化阶段不动
表现是 master 日志停在NCCL INFO Bootstrap : Using eth0:10.x.x.x,然后十几分钟没动静,最后超时。这个 90% 是 worker 没连上来。
排查顺序:先kubectl get pods看所有 Pod 是不是都Running,再kubectl exec进去env | grep WORLD_SIZE看数字对不对,然后手动nc -vz $MASTER_ADDR 23456测端口。如果端口通但连接不上,检查 NetworkPolicy 有没有拦,或者是不是hostNetwork下端口被别的进程占了。
还有一个我遇到过的特殊情况:某个 worker 的镜像版本比 master 旧,PyTorch 版本不一致,init_process_group握手时协议对不上,报的错是Connection reset by peer。这种问题不看版本号根本想不到。所以同一任务必须用同一个镜像 tag,别用latest。
8.2 起来就崩,报错看不懂
Bus error先查 shm。CUDA out of memory先看是不是每张卡上跑了不止一个进程,nvidia-smi在容器里能看到卡上进程的 PID,对一下数量。RuntimeError: Address already in use是端口冲突,改MASTER_PORT。
还有一种情况是 Pod 被 OOMKilled,但日志里什么都没留。这时候去看kubectl describe pod的Last State,会显示Reason: OOMKilled。CPU 内存设小了,DataLoader 的 worker 把节点内存吃干,内核直接杀进程,训练脚本连异常都来不及抛。
8.3 能跑但慢得离谱
先看 GPU 利用率,nvidia-smi dmon -s u采样一下。如果长期低于 50%,大概率是数据加载瓶颈,把num_workers翻倍试试。如果利用率高但每个 step 的耗时比单机还长,那就是通信瓶颈,去看 NCCL 日志确认走的是 IB 还是 socket。
还有一种隐蔽的情况:某个节点是"慢节点",其他节点每步都在等它。表现是整体速度被拉到最慢那个节点的水平。排查方法是看每个 rank 的 step 耗时,如果某个 rank 稳定比其他慢 20% 以上,检查那台机器的 CPU 频率、是否被其他进程干扰、网卡是否有丢包。
8.4 问题速查表
| 现象 | 优先检查 | 常见修复 |
|---|---|---|
| Pod 一直 Pending | 节点 GPU 余量、亲和性 | 放宽 nodeAffinity,或开 gang scheduling |
| 一直 ContainerCreating | 镜像拉取、PVC 挂载 | 检查 imagePullSecret 和 PVC 状态 |
| 初始化超时 | worker 数量、端口连通 | 核对 WORLD_SIZE,手动测端口 |
| Bus error | /dev/shm 容量 | 挂 emptyDir Memory 到 /dev/shm |
| OOMKilled | CPU 内存 limit | 调大 memory,降 num_workers |
| 速度只有单机水平 | NCCL 走的路径 | 确认 IB 生效,检查 NCCL_SOCKET_IFNAME |
| 恢复后 loss 异常 | checkpoint 内容不完整 | 只让 rank 0 写,加 barrier |
9. 监控与成本:把账算明白
9.1 该盯哪几个指标
训练任务最该看的是四个数:GPU 利用率、GPU 显存占用、每步耗时、网络吞吐。前两个用 DCGM Exporter 暴露成 Prometheus 指标,DCGM_FI_DEV_GPU_UTIL和DCGM_FI_DEV_FB_USED,配一个简单的 Grafana 面板就够。后两个从训练日志里打点,用 TensorBoard 或者直接写日志文件。
我的习惯是在训练脚本里每 100 步打一行结构化日志:step, loss, lr, step_time_ms, samples_per_sec。这行日志比任何监控面板都直接,出现异常的时候一眼就能看出是从哪一步开始劣化的。
9.2 成本上的取舍
多机训练不是卡越多越好。8 卡加到 16 卡,理论上提速接近 2 倍,实际能到 1.7 倍就不错了,剩下 15% 被通信吃掉。16 卡加到 32 卡,边际收益进一步下降,可能只有 1.5 倍。所以在做扩容决策之前,先测一下通信占比:把batch size固定,只改节点数,看每步耗时的变化曲线。如果 2 节点到 4 节点只快了 20%,说明你的模型通信开销太大,这时候应该考虑梯度累积或者混合并行,而不是继续堆机器。
另一个成本点是资源闲置。训练任务排队的时候,GPU 是空着的;训练跑完没及时清理,GPU 也是空着的。用 TTL 或者任务队列把生命周期管起来,比优化 NCCL 参数省的钱更多。我见过一个集群,长期有 30% 的卡被"跑完了但没删"的任务占着,清理一下直接省出好几台机器的预算。
我个人在实际操作中的体会是,这套东西最难的部分从来不是写 YAML,而是建立一个可复现的检查清单。版本对不对、shm 够不够、网络走没走 IB、gang scheduling 开没开,这四件事每次上线都过一遍,能挡掉九成的问题。剩下的那一成,靠日志和nvidia-smi一点点啃。第一次配多机训练花了两天,第二次半小时就能起来,差别就在于有没有把踩过的坑记下来。