分布式训练这件事,一旦从单机八卡跨到多机,问题就从"模型写没写对"变成了"调度、网络、存储、容错"四件事同时压过来。Kubernetes 上跑 PyTorch 分布式,本质上就是把这四件事交给集群去管:谁先起、谁是 0 号节点、显卡怎么分、数据从哪读、断了之后从哪个 checkpoint 续。这篇内容会从选型、镜像、YAML 逐字段拆解、通信调优到排错,把一条能真正跑起来的多机多卡链路讲透,适合已经会用torchrun单机跑、但一上集群就卡在Waiting for rank 0或者 NCCL 超时的开发者,也适合负责给算法团队搭训练平台的同学。
1. 先确认这套训练到底该不该搬进集群
1.1 单机多卡与多机多卡的分水岭
很多人是被显存逼上集群的,这个动机本身没问题,但判断标准得换一下。我的经验是看两条线:一是单机 8 卡在你能接受的 batch size 下,一轮训练时间是否已经长到影响迭代节奏;二是数据量增长后,单机 8 卡是否已经装不下完整的数据并行切分。如果单机 8 卡跑一个 epoch 只要几十分钟,那多机带来的通信开销很可能把收益吃光。多机并行的效率不是线性叠加的,8 卡到 16 卡,实际吞吐能到 1.7 倍就算不错,到 32 卡可能只剩 1.5 倍甚至更低,具体取决于模型大小、梯度同步量和网络带宽。
换个角度说,模型参数量在 1 亿以下、单卡放得下完整模型、只是数据量大,优先考虑的是把数据加载流水线做扎实,而不是加机器。真正该上多机的典型场景是:单个模型用 8 卡 + 梯度累积仍然要吃几天,或者单卡显存放不下哪怕一个 micro-batch。这时候多机的价值才立得住,因为你是拿通信换时间,而不是拿通信换一个本来就够用的结果。
还有一类隐性成本容易被忽略:集群上的 GPU 是共享资源,你的任务随时可能被别人挤占,也可能因为节点维护被驱逐。所以上集群之前就要想好 checkpoint 策略,否则一次抢占丢掉三天训练量,比单机慢一点更让人崩溃。
1.2 容器化训练真正解决的三个麻烦
把训练塞进容器,收益不在"跑起来"这一步,而在"反复跑起来"。第一是环境一致性,CUDA 驱动由宿主机负责,但 CUDA runtime、cuDNN、NCCL、PyTorch 版本、Python 依赖全部封在镜像里,换一台机器不用再担心libnccl.so找不到或者版本不匹配。第二是资源隔离与配额,GPU 用nvidia.com/gpu这种扩展资源来申请,由设备插件统一分配,谁占了几张卡一目了然,不用再靠人工约定"这台机器归你"。第三是失败恢复,Pod 挂了由控制器重建,配合持久化存储上的 checkpoint,任务能从断点续上,而不是从零开始。
但容器化也带来两个新麻烦,必须提前知道。一是共享内存,容器默认/dev/shm只有 64MB,DataLoader 的 worker 进程一多就会报Bus error,这个坑几乎人人踩过。二是网络命名空间,容器内的网卡名、IP 和宿主机不一样,NCCL 自动选网卡经常选错,导致本来该走高速互联的流量走了普通网络,性能直接腰斩。
提示:如果你现在的训练脚本在裸机上跑得好好的,先别急着改代码。80% 的上集群失败都是环境和编排配置问题,跟模型代码无关。
2. 编排方案选型:原生命令加 Job,还是专用 Operator
2.1 两种路线的能力边界对照
Kubernetes 上跑 PyTorch 分布式,主流就两条路。一条是用原生Job或StatefulSet承载 Pod,容器启动命令里直接调torchrun,靠环境变量注入 rank 信息。另一条是引入专门的训练 Operator,用自定义资源描述训练任务,Operator 负责创建 Pod、注入MASTER_ADDR、WORLD_SIZE、RANK这些变量,还支持弹性伸缩。
| 维度 | 原生 Job + torchrun | 训练 Operator |
|---|---|---|
| 上手成本 | 低,会写 YAML 就行 | 中,要先装 CRD 和控制器 |
| rank 信息注入 | 自己拼环境变量或 DNS | 自动注入 |
| 弹性扩缩容 | 需要自己做 rendezvous | 原生支持 |
| 与调度器联动 | 需自行配置 gang 调度 | 多数已内置 |
| 排错可见性 | Pod 日志直接看 | 多一层资源状态,需熟悉 |
| 适合团队 | 任务形态固定、规模小 | 任务多、需要平台化 |
这个表不是让你二选一搞对立,而是帮你判断当前阶段该付多少学习成本。团队只有三五个算法同学、每周跑两三次训练,用 Operator 属于拿大炮打蚊子;反过来,如果有几十个任务天天排队,手工维护 Job 的 YAML 会把人耗死。
2.2 我倾向先用原生 Job 跑通的原因
我自己的做法是先用原生 Job 把整条链路跑通,再决定要不要上 Operator。理由很实际:原生方案把每个环节都摊在明面上,MASTER_ADDR是谁、WORLD_SIZE是几、Pod 之间能不能解析域名,全部你得自己确认一遍。这个过程很痛,但痛完之后你对分布式训练的运行机理会有完全不同的理解。等链路稳了,再迁到 Operator,你就知道它帮你省掉的是哪部分工作,出问题时也能一眼看出是哪一层的事。
具体到 rank 信息的注入,最稳的办法是给 Pod 挂一个 headless Service,让每个 Pod 拥有稳定的 DNS 名,比如<job 名>-0.<service 名>。这样 0 号 Pod 的地址不依赖它先启动、也不依赖它的 IP,其他 Pod 只要拼出这个域名就能找到它。这里有个非常关键的字段:publishNotReadyAddresses: true。不加这个,Pod 没进入 Ready 状态时 DNS 里查不到它,而 0 号 Pod 恰恰要等所有 worker 起来才开始建组,于是所有 Pod 互相等待,形成死锁,日志停在Waiting for rank 0一动不动。这个坑我在第一次部署时耗了整整一个下午。
3. 镜像构建:那些让你反复重建镜像的细节
3.1 CUDA、NCCL、PyTorch 三者的版本对齐
镜像的基础层建议直接用官方的 PyTorch 镜像,选带 CUDA runtime 的版本,别自己从nvidia/cuda手动装。原因是官方镜像里 CUDA、cuDNN、NCCL、PyTorch 的版本组合是验证过的,你自己拼很容易出现 PyTorch 编译时链接的 NCCL 版本和运行时加载的不是同一个,症状是多机训练能启动但集合通信报错,排查起来非常折磨。
宿主机驱动版本要单独确认。容器里的 CUDA runtime 版本不能高于驱动支持的上限,这个约束由驱动本身决定。部署前在目标节点上跑一次nvidia-smi,看右上角的 CUDA Version,那个数字就是这台机器能支持的最高 runtime 版本。镜像里写的 CUDA 版本只要不超过它就没问题。
还有一个坑是多机场景下 NCCL 的版本必须完全一致。只要有一个节点的镜像里 NCCL 版本不同,集合通信初始化阶段就可能挂掉,报的错还特别含糊。所以镜像 tag 要固定到具体版本号,绝对不能用latest,滚动更新一次就可能出现新旧镜像混跑。
3.2 共享内存、文件句柄与 ulimit 的隐形配置
这三个东西平时在裸机上都不用管,进了容器全是坑。共享内存的问题最典型:DataLoader 设了num_workers=8,容器里/dev/shm只有 64MB,worker 往共享内存写 batch 数据时直接崩,报Bus error或者DataLoader worker exited unexpectedly。解决办法是在 Pod 里挂一个内存介质的emptyDir覆盖/dev/shm,大小按单 batch 大小 × worker 数 × 预取数估,一般给到宿主机内存的一半比较稳妥,比如 64GB 内存的机器给 32GB。
文件句柄上限也容易出事。多机训练时要建大量 TCP 连接,如果nofile太小,会出现Too many open files,表现为训练跑着跑着某个 rank 突然失联。在容器的securityContext里把ulimit的nofile调到 65536 以上,同时确认宿主机内核参数fs.file-max够大。
还有一个不常被提到的是net.core.somaxconn和 TCP 拥塞控制参数。如果集群规模较大、节点间连接数多,建议在宿主机层面把net.ipv4.tcp_rmem、tcp_wmem调高一些。这些不是容器内能改的,需要节点初始化时统一处理。
3.3 数据集要不要打进镜像
结论是不要。镜像只放代码和依赖,数据走挂载。把几百 GB 的数据集打进镜像,会让镜像分层变得巨大,每次拉取都慢,节点磁盘也扛不住。正确做法是数据放在共享存储上,通过 PVC 挂载进容器,或者用对象存储加缓存层。
如果数据在共享存储上读得慢,可以在每个节点上先做一次本地缓存,把当前 epoch 需要的数据预取到节点本地盘,训练时读本地。这个方案在数据量可控时效果很明显,代价是要写一点缓存管理逻辑。另外一个常被忽略的点是:如果多个 worker 同时从同一个网络存储读同一个文件,会形成热点,读取速度可能比单机还慢。分段读、每个 rank 读不同 shard,是更稳的做法。
4. 作业清单落地:一份能跑的多机 YAML 拆解
4.1 让所有 Worker 自动拿到 0 号节点地址
先看 Service 的定义,这是整条链路的地基:
apiVersion: v1 kind: Service metadata: name: train-headless spec: clusterIP: None publishNotReadyAddresses: true selector: app: train-job ports: - name: rdzv port: 29500clusterIP: None表示这是 headless Service,不做负载均衡,DNS 查询会直接返回 Pod 的 IP 列表。publishNotReadyAddresses: true是前面提到的关键,保证 Pod 还没 Ready 时也能被解析到。端口 29500 是 PyTorch 默认的 rendezvous 端口,可以改成别的,但改的话启动参数里要同步改。
有了这个 Service,Pod 的 DNS 名就是<pod 名>.<service 名>.<namespace>.svc.cluster.local。用 StatefulSet 或索引式 Job 保证 Pod 名带序号,0 号 Pod 的地址就可以直接拼出来,不需要任何服务发现逻辑。
启动命令里这样写:
torchrun \ --nnodes=4 \ --nproc_per_node=8 \ --node_rank=${RANK} \ --master_addr=train-job-0.train-headless \ --master_port=29500 \ train.py --config /workspace/config.yaml其中RANK通过 downward API 从 Pod 的序号注入。如果你用的是索引式 Job,可以通过metadata.annotations['batch.kubernetes.io/job-completion-index']拿到索引号;用 StatefulSet 的话,hostname的最后一段就是序号,启动脚本里cut一下即可。
注意:
--nnodes写的是节点数不是进程数,--nproc-per-node才是每台机器的进程数,通常等于 GPU 数。这两个参数写反了是最常见的低级错误。
4.2 GPU 资源申请的写法与限制
GPU 的申请方式和 CPU 完全不同,它必须是整数,而且requests和limits必须写成同一个值,否则调度器会拒绝创建 Pod。原因在于 GPU 是不可压缩资源,不支持超卖。
resources: limits: nvidia.com/gpu: 8 memory: 200Gi cpu: 60 requests: nvidia.com/gpu: 8 memory: 200Gi cpu: 60CPU 请求值建议按"GPU 数 × 8 到 12"来给,因为数据加载和预处理都在 CPU 上,给少了 GPU 会周期性空转。但也不能给太满,把整台机器的 CPU 全占了会影响系统进程,反而拖慢。
这里还要提一句设备插件。集群里必须有 NVIDIA 设备插件在跑,否则nvidia.com/gpu这个资源根本不存在,Pod 会一直 Pending 并报"资源不足"。检查方法很简单,kubectl describe node <节点名>看 Allocatable 里有没有nvidia.com/gpu,没有就是插件没装好或者驱动有问题。
4.3 重启策略与超时设置的取舍
训练任务的重启策略比较讲究。用 Job 的话,restartPolicy一般设成Never,让 Pod 失败后保留现场方便查日志;设成OnFailure会在同一个 Pod 里重启容器,好处是省掉重新调度的开销,坏处是失败现场的日志会被覆盖。
backoffLimit也别设太大,尤其是多机任务。一个 worker 挂了,其他 worker 会一直等在集合通信里,最后全组超时。这时候如果控制器疯狂重启失败的 Pod,会让整个任务反复卡住。我的做法是把backoffLimit设成 0 到 2,配合外层的监控告警,挂了就人工看一眼再决定是否重跑。
超时控制要写在训练代码里。给init_process_group传一个合理的 timeout,比如 30 分钟:
import datetime import torch.distributed as dist dist.init_process_group( backend="nccl", timeout=datetime.timedelta(minutes=30), )不设的话默认是 30 分钟,其实也还行,但如果是每轮迭代很快的模型,这个默认值太长,一个节点挂了要等半小时才报错,白白浪费资源。反过来,如果模型每轮要跑很久,超时设太短会误杀正常任务,得按单轮耗时往上留余量。
5. 通信链路与拓扑:NCCL 跑不快多半卡在这里
5.1 HostNetwork 与容器网络的选择
容器网络默认走的是 overlay 网络,多一层封装就多一层开销。对于集合通信这种对延迟敏感的场景,封装带来的额外延迟和可能的 MTU 问题会明显影响性能。所以多机训练我一般优先考虑hostNetwork: true,让容器直接用宿主机的网络栈。
代价有两个。一是端口冲突,多个 Pod 在同一节点上都想用 29500 就会打架,所以要保证一个节点只跑一个训练 Pod,或者给每个 Pod 分配不同的端口段。二是 DNS 解析要调整,用了 hostNetwork 后 Pod 的 DNS 策略要设成ClusterFirstWithHostNet,否则解析不到集群内的服务名。
如果集群网络本身支持高性能模式,比如有专门的 RDMA 网络方案,那也可以不用 hostNetwork,让容器网络直接跑在高速网络上。这种情况需要网络方案本身支持设备透传,配置复杂度高一些,但隔离性更好。
5.2 NCCL 环境变量的实际调参清单
NCCL 的行为高度依赖环境变量,默认值在容器里经常是错的。下面这几个是我每次部署都会显式设置的:
env: - name: NCCL_DEBUG value: "INFO" - name: NCCL_SOCKET_IFNAME value: "eth0" - name: NCCL_IB_DISABLE value: "1" - name: NCCL_NET_GDR_LEVEL value: "2" - name: NCCL_P2P_LEVEL value: "NVL" - name: NCCL_NSOCKS_PERTHREAD value: "4" - name: NCCL_SOCKET_NTHREADS value: "4"逐条说理由。NCCL_DEBUG=INFO是排查必备,启动时会打印它选了哪张网卡、用了什么协议、拓扑结构是什么样,几乎所有 NCCL 问题都能从这个日志里看出端倪。NCCL_SOCKET_IFNAME必须显式指定,因为容器里可能有一堆虚拟网卡,NCCL 自动选经常选到lo或者某个虚拟网桥上,然后性能惨不忍睹。NCCL_IB_DISABLE=1是在没有高速互联的情况下必须设的,否则 NCCL 会尝试初始化 IB 设备,超时后才回退,白白浪费几分钟启动时间。
NCCL_P2P_LEVEL=NVL表示允许通过 NVLink 做点对点通信。如果是同一台机器上的多卡,这个能让通信走 NVLink 而不是绕 PCIe。NCCL_NSOCKS_PERTHREAD和NCCL_SOCKET_NTHREADS是调整 socket 并发数的,默认值偏保守,在多机场景适当调大能提升带宽利用率,但也别调太高,会引起 CPU 争抢。
还有一个NCCL_ALGO和NCCL_PROTO,这两个一般不用动,让 NCCL 自己选。只有在特定模型上发现通信成为瓶颈时,才需要手动指定 ring 或 tree 算法做对比测试。
5.3 多网卡环境下的设备选择
高性能机器上经常有多张网卡,一张管理网、一张或几张数据网。这时候光设NCCL_SOCKET_IFNAME不够,还得确认流量走的是带宽最大的那张。做法是在 NCCL_DEBUG 日志里找NET/IB或NET/Socket那几行,看它选中的设备名和 IP 是不是你期望的那个。
如果机器上有 RDMA 设备,需要把设备暴露进容器:
resources: limits: rdma/hca: 1同时容器需要相应能力,并且宿主机上要装好对应的驱动和用户态库。这块配置比较重,如果集群没有现成方案,建议先用普通网络把流程跑通,再逐步优化。别一上来就追求极致带宽,先把正确性拿到手。
另外,GPU 的拓扑位置也影响通信效率。同一台机器上,GPU 到网卡的 PCIe 路径不同,跨 NUMA 节点的通信会明显慢。理想情况下,让 NCCL 选中的网卡和 GPU 在同一个 NUMA 节点下。这个在NCCL_DEBUG日志的拓扑信息里能看到,如果发现跨了 NUMA,可以考虑用 CPU 管理器的静态绑核策略,把 Pod 的 CPU 和 GPU 绑定到同一个 NUMA 域。
6. 数据与检查点:训练中断之后能不能不从头再来
6.1 共享存储的读写特性对 DataLoader 的影响
共享文件系统的元数据操作通常比本地盘慢一个数量级。DataLoader 如果用num_workers > 0并且每个样本都要open()一次文件,元数据请求会瞬间打满存储端,表现为训练启动阶段极慢,或者干脆卡住。
几个缓解手段,按性价比排序:一是把小样本数据集预处理成大文件格式(比如打包成若干个几百 MB 的分片),每个 worker 顺序读分片而不是随机开文件;二是用persistent_workers=True避免每个 epoch 重启 worker 进程;三是把prefetch_factor调大一点,让读取和计算重叠更充分。这几项加起来,启动时间往往能从十几分钟降到一两分钟。
还有一个容易忽略的点:多个 worker 同时读同一个大文件时,如果存储不支持并发读优化,会互相拖慢。所以分片数量要够,至少是总 worker 数的几倍,让每个 worker 读不同的分片。
6.2 Checkpoint 落盘与断点恢复的组织方式
多机训练的 checkpoint 绝对不能每个 rank 都存一份完整的,那会把存储写爆。标准做法是只在 rank 0 上保存,其他 rank 通过集合通信把状态汇总过去。
if dist.get_rank() == 0: torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, "step": global_step, }, f"/ckpt/run-{run_id}/epoch-{epoch}.pt") dist.barrier()dist.barrier()很重要,保证所有 rank 都等到保存完成再继续,否则可能出现 rank 0 还在写文件、其他 rank 已经开始下一轮训练,训练进程把存储压满影响写入。
恢复的时候要区分两种情况。完整恢复是把模型、优化器、调度器状态全读回来,适合计划内的重启。部分恢复是只读模型权重,适合微调或者换优化器配置。代码里用一个resume_from参数控制就好。
checkpoint 的保留策略也要设计。存太多把 PVC 撑爆,存太少回退太远。我的做法是保留最近 3 个加一个每 N 轮的最佳模型,同时给 PVC 设配额上限,超了触发告警。这样既保证能回退,又不会无限增长。
提示:写到网络存储之前先写本地临时文件再
mv过去,避免写入过程中任务被驱逐留下半截文件。恢复时先校验文件大小或加载一次,别直接信任。
7. 排错实录:几个卡了我很久的现场
7.1 全部 Pod 都在等 rank 0 的完整排查链路
这个现象的表现是所有 Pod 都 Running,日志里清一色Waiting for rank 0,没有任何报错。排查顺序我总结成四步。
第一步,进任意一个 worker Pod,nslookup train-job-0.train-headless。如果解析不到,问题在 Service 层。常见原因有三个:Service 的 selector 和 Pod 的 label 对不上;忘了加publishNotReadyAddresses: true;Pod 还没被 Endpoint 收录。逐个核对。
第二步,如果能解析,nc -zv train-job-0.train-headless 29500。连不上说明 0 号 Pod 的进程没监听,可能是它的启动命令里--node_rank传错了,或者它在等数据加载卡住了。
第三步,看 0 号 Pod 的日志。如果它自己也在等别人,那说明--nnodes和实际的 Pod 数对不上,比如声明 4 个节点但只起了 3 个 Pod,rank 0 会一直等第 4 个。
第四步,如果上面都正常,检查 DNS 策略。用了 hostNetwork 但没设ClusterFirstWithHostNet的话,Pod 用的是宿主机 DNS,根本解析不到集群内服务名,这个坑特别隐蔽,因为nslookup会返回外部 DNS 的失败结果而不是明显报错。
7.2 Pod Pending 与 GPU 资源碎片
Pending 的原因要看kubectl describe pod的 Events。如果写的是"Insufficient nvidia.com/gpu",说明集群里虽然有空闲 GPU,但凑不出一个节点上的 8 张连续可用卡。这种情况在共享集群里很常见,节点 A 剩 3 张、节点 B 剩 5 张,看着总量够,实际没法满足单 Pod 要 8 张的需求。
解决办法有两个方向。调度层面,用支持 gang 调度的调度器,保证整组 Pod 要么全部调度成功要么全部等待,避免部分 Pod 占着卡干等。资源层面,把大任务和小任务分池,大任务专用几个节点不混跑,避免碎片化。
还有一种 Pending 是资源名写错了,比如写成nvidia.com/gpu.count或者漏了命名空间前缀,这种情况 Events 里会明确提示资源不存在,比较容易发现。
7.3 显存不足与真实泄漏的区分方法
报CUDA out of memory之后第一步不是改 batch size,而是判断是"一开始就不够"还是"跑着跑着才不够"。前者是配置问题,降 batch 或者上梯度累积就能解决;后者大概率是代码里有东西在累积。
判断方法很简单,在每个 epoch 结束打印torch.cuda.memory_allocated()和torch.cuda.max_memory_allocated()。如果 allocated 逐轮上升,就是有引用没释放,常见于把 tensor 存进了 list 或者 dict 做统计,或者 loss 的反向图没释放。如果 allocated 稳定但 max 不断上涨,那是碎片问题,可以试试PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。
还有一个多机特有的现象:某个 rank 报 OOM,其他 rank 都正常。这通常意味着数据分布不均,那个 rank 拿到的样本特别长。检查 DataLoader 的采样器有没有做长度分桶,或者 padding 策略是否合理。分布式场景下最长样本会决定整体显存峰值,这个是单机时不明显的问题。
8. 部署跑稳之后才慢慢体会到的几件事
我自己的体会是,把训练搬上集群这件事,前 20% 的工作量在写 YAML,后 80% 在调环境和排错。所以第一次部署别追求最优性能,先让任务能从头跑到尾,哪怕慢一点。跑通之后再逐项优化:先看启动时间,把镜像大小、数据读取、NCCL 初始化逐段计时;再看单轮耗时,对比单机多卡的基线,算出多机扩展效率;最后才去动拓扑绑定、NUMA 亲和这些深水区。
另一个很值的习惯是给训练脚本加一层轻量的状态上报,每隔 N 步往一个共享位置写一次当前 step 和 loss。任务挂了之后,从这些记录里能立刻判断是卡在数据加载、前向计算还是同步阶段,比翻几百兆的日志快得多。
最后提醒一句,NCCL_DEBUG=INFO别长期开着,日志量很大,正式跑的时候关掉,只在排错时打开。而且这个变量在多机场景下会让每个 rank 都输出一份,记得配合日志聚合一