CoreNet 多节点分布式训练实战:从 DDP/FSDP 集群配置到 Slurm 作业脚本

原创2026-10-01 22:25:251,873 阅读
文章标签:深度学习计算机视觉NLP多模态模型训练大模型

CoreNet 多节点分布式训练实战:从 DDP/FSDP 集群配置到 Slurm 作业脚本

CoreNet 原生支持基于 PyTorch DDP 与 FSDP 的多节点多卡分布式训练。本篇指南以 tutorials/guide_slurm_and_multi_node_training.md 为骨架,结合仓库源码逐项拆解 corenet-train 的分布式启动链路、--ddp.* 参数的真实语义,并给出可直接套用的手动启动脚本与 Slurm 提交模板,帮助你在一台或多台 GPU 主机上正确编排 rank、world size 与初始化地址,跑通大规模训练任务。

前置知识:corenet-train 入口与配置文件

本文假设你已经熟悉 corenet-train 的基本用法(配置文件、--common.* 参数等)。如果还比较陌生,建议先阅读 从零开始训练新模型的教程。

corenet-train 是 CoreNet 提供的训练命令入口。从源码结构看,它由 corenet/cli/entrypoints.py 中的 entrypoint 映射注册:corenet-train 对应 corenet.cli.main_train 模块的 main_worker 函数,并最终由 setup.py 生成对应的 console script。也就是说,corenet-train 与 corenet train 子命令在行为上是等价的。

训练参数由 corenet/options/opts.py 中的 get_training_arguments() 统一组装,其中与分布式训练直接相关的是 arguments_ddp()(见 opts.py)注册的 --ddp.* 参数组。因此,无论手动启动还是通过 Slurm 启动,最终都会走到同一条参数解析与初始化路径。

CoreNet 的分布式训练能力:DDP 与 FSDP

CoreNet 支持两种主流分布式训练方案:

  • DDP(DistributedDataParallel):默认方案。训练流水线在检测到 GPU 数量 ≥ 1 时自动将模型包装为 torch.nn.parallel.DistributedDataParallel,见 default_train_eval.py。
  • FSDP(FullyShardedDataParallel):面向超大模型,把模型参数、梯度与优化器状态分片到各设备。需要在配置中指定 --train-eval-pipeline.name fsdp_train_eval_pipeline,对应 fsdp_train_eval.py 中注册的 FSDPTrainEvalPipeline。

两种方案共用同一套多节点编排方式:每个节点上以 GPU 为粒度计算 rank,因此下面的启动命令同样适用于多节点多卡(multi-node-multi-gpu)与多节点单卡(multi-node-single-gpu)集群。

手动多节点启动:环境变量与启动命令详解

以一个 4 节点 × 8 GPU 的集群为例,官方推荐的手动启动方式如下(原文命令,逐行注释已补全):

# Assuming 4-node x 8-GPU Cluster:
export CFG_FILE="path/to/config.yaml"         # 改为你的配置文件路径
export GPU_PER_NODE="8"                       # 每个节点上可用的 GPU 数量
export NUM_NODES="4"                          # 集群中的节点(主机/机器)数量
export NODE_RANK="0"                          # 取值 [0, $NUM_NODES-1],每个节点必须使用唯一 rank
export MAIN_IP_PORT="tcp://IP_OF_RANK0:PORT"  # 将 IP_OF_RANK0 改为 rank 0 节点的主机名(须能被其他节点访问),PORT 改为空闲端口

export WORLD_SIZE=$((NUM_NODES * GPU_PER_NODE))
export GPU_RANK=$((NODE_RANK * GPU_PER_NODE))

corenet-train --common.config-file $CFG_FILE --common.results-loc results --ddp.rank $GPU_RANK --ddp.world-size $WORLD_SIZE --ddp.dist-url $MAIN_IP_PORT --ddp.backend nccl

环境变量的含义与计算方法

变量 含义 说明
CFG_FILE 配置文件路径 通过 --common.config-file 传入
GPU_PER_NODE 每节点 GPU 数 决定每个节点负责的 rank 数量
NUM_NODES 节点总数 4 节点集群中取 4
NODE_RANK 节点序号 每个节点取 [0, NUM_NODES-1] 中的唯一值
MAIN_IP_PORT 进程组初始化地址 形如 tcp://<rank0主机>:<端口>,PyTorch 的 init_method
WORLD_SIZE 全局进程总数 NUM_NODES × GPU_PER_NODE,即 4×8=32
GPU_RANK 该节点首个进程的全局 rank NODE_RANK × GPU_PER_NODE,即节点 0→0、节点 1→8、节点 2→16、节点 3→24

关键注意点:rank 是 GPU 索引而非节点索引

正如原文档的 NOTE 所强调的:从上面 WORLD_SIZE 与 GPU_RANK 的计算方式可以看出,--ddp.rank 与 --ddp.world-size 期望的是 GPU 索引(全局 rank),而不是节点索引。但 corenet-train 仍然需要在每个节点上各执行一次。

以 4 节点 × 8 GPU 为例,四个节点上的参数应分别为:

  • 节点 0:NODE_RANK=0 → GPU_RANK=0
  • 节点 1:NODE_RANK=1 → GPU_RANK=8
  • 节点 2:NODE_RANK=2 → GPU_RANK=16
  • 节点 3:NODE_RANK=3 → GPU_RANK=24

WORLD_SIZE 恒为 32。这样,PyTorch 进程组中 rank 0~31 与 32 块 GPU 一一对应。

深入源码:--ddp.* 参数的真实语义与启动链路

--ddp.* 参数定义

--ddp.* 参数组在 corenet/options/opts.py 中定义,完整列表如下:

参数 类型 默认值 说明
--ddp.rank int 0 分布式训练的全局 rank(节点 rank)
--ddp.world-size int -1 DDP 世界大小;默认 -1 表示使用所有 GPU
--ddp.dist-url str None DDP 初始化 URL(tcp://host:port)
--ddp.dist-port int 30786 仅在未指定 --ddp.dist-url 时使用的端口(默认注释中同时出现 30786/30768,以实际代码为准)
--ddp.device-id int None 设备 ID
--ddp.backend str nccl DDP 通信后端,默认 nccl
--ddp.find-unused-params flag False 让 DDP 查找未使用参数,便于调试

此外还有 --ddp.use-deprecated-data-parallel(旧版 DataParallel,仅供调试,后续将被废弃),以及内部使用的 ddp.use_distributed(由 launcher 根据 GPU 数量自动设置,见 default_train_eval.py)。

从命令行到进程组的调用链

一次多节点训练的启动链路可以拆解为:

  1. 入口:corenet-train 调用 corenet/cli/main_train.py 中的 main_worker,解析出全部参数,并通过 TRAIN_EVAL_PIPELINE_REGISTRY 按 --train-eval-pipeline.name(默认 default)构建训练流水线对象,然后调用 launcher。
  2. launcher 编排:DefaultTrainEvalPipeline.launcher(见 default_train_eval.py)先完成设备设置、创建结果目录(common.results-loc + common.run-label),再根据 GPU 数量决定是否启用分布式:启用时通过 torch.multiprocessing.spawn 为每张 GPU 派生一个子进程(nprocs=num_gpus)。
  3. 子进程初始化 rank:每个子进程进入 _launcher_distributed_spawn_fn(见 default_train_eval.py),把 --ddp.rank(此时为空)重置为 ddp.start_rank + device_id,即把节点的 GPU_RANK 分配到该节点内的各张 GPU 上。
  4. 初始化进程组:调用 corenet/utils/ddp_utils.py 中的 distributed_init()。若未指定 --ddp.dist-url,会自动生成 tcp://<本机主机名>:<端口>;随后用 dist.init_process_group(backend=..., init_method=ddp_url, world_size=..., rank=...) 建立进程组,并执行一次 dummy all-reduce 来初始化 NCCL 通信器。

这一步解释了为什么手动启动脚本中 --ddp.rank 必须传全局 GPU rank:torch.multiprocessing.spawn 只在单个节点内分配设备序号,跨节点的全局 rank 全靠你通过 --ddp.rank 预先给定。这也是文档要求"每个节点各运行一次、且 NODE_RANK 唯一"的根本原因。

在 Slurm 集群上运行:模板脚本逐行解析

以下为官方提供的 Slurm 提交模板(原文脚本,逐行注释已补全)。请注意:官方明确声明 CoreNet 尚未在 Slurm 上做过完整测试,使用前需要针对你的集群环境验证。

#!/bin/bash
#SBATCH --job-name=your-job-name
#SBATCH --nodes=4                # 改为集群中的节点数(主机/机器数量)
#SBATCH --ntasks-per-node=1      # 即使多 GPU 节点也无需改动这一行
#SBATCH --gpus-per-node=gpu:8    # 改为每个节点可用的 GPU 数量

export CFG_FILE="path/to/config.yaml"         # 改为你的配置文件路径

export GPU_PER_NODE="$SLURM_GPUS_PER_NODE"
export NUM_NODES="$SLURM_NNODES"
export NODE_RANK="$SLURM_PROCID"
# Inspired by https://discuss.pytorch.org/t/distributed-training-on-slurm-cluster/150417/7
export MAIN_PORT=$(expr 10000 + $(echo -n $SLURM_JOBID | tail -c 4))
export MAIN_IP="$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n 1)"
export MAIN_IP_PORT="tcp://$MAIN_IP:$MAIN_PORT"
export WORLD_SIZE=$((NUM_NODES * GPU_PER_NODE))
export GPU_RANK=$((NODE_RANK * GPU_PER_NODE))

corenet-train --common.config-file $CFG_FILE --common.results-loc results --ddp.rank $GPU_RANK --ddp.world-size $WORLD_SIZE --ddp.dist-url $MAIN_IP_PORT --ddp.backend nccl

Slurm 模板设计要点

  • --ntasks-per-node=1:每个节点只启动一个 corenet-train 进程。多 GPU 场景下,该进程内部会通过 torch.multiprocessing.spawn 再派生出与 GPU 数量相等的子进程,因此不需要为每张 GPU 单独建 task。
  • $SLURM_GPUS_PER_NODE:Slurm 自动注入的每节点 GPU 数,直接作为 GPU_PER_NODE。
  • $SLURM_NNODES:本次作业分配的节点数,即 NUM_NODES。
  • $SLURM_PROCID:Slurm 为每个 task 分配的全局进程 ID,在 --ntasks-per-node=1 下恰好等于节点序号(0、1、2、3),因此可以直接作为 NODE_RANK。
  • MAIN_PORT 生成技巧:取 SLURM_JOBID 末尾 4 位加上 10000,得到一个随作业 ID 变化的端口,降低多作业并发时端口冲突的概率。
  • MAIN_IP:通过 scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n 1 取出作业节点列表中的第一个节点(即 rank 0 节点)作为 MAIN_IP。

最终,corenet-train 在每个节点上以各自唯一的 GPU_RANK(0/8/16/24)启动,并通过统一的 tcp://$MAIN_IP:$MAIN_PORT 汇聚到 rank 0 节点完成进程组初始化。

补充:FSDP 多节点训练的注意事项

如果模型规模较大,需要在多节点上使用 FSDP,应:

  1. 在配置文件中设置 --train-eval-pipeline.name 为 fsdp_train_eval_pipeline;
  2. 仍需按上述方式正确设置 --ddp.rank、--ddp.world-size、--ddp.dist-url,因为 FSDP 依赖 DDP 的进程组:见 fsdp_train_eval.py,其中明确断言 ddp.use_distributed 必须为真。

从 fsdp_trainer.py 的构造函数可以确认,使用 FSDP 时有两个限制:

  • 不支持梯度累积(common.accum_freq > 1 会报错);
  • 不支持 EMA(ema.enable 会报错)。

此外,FSDP 的混合精度由 FSDP 内部机制处理,common.mixed-precision(autocast + GradScaler)在 FSDP 流水线中不适用,fsdp_train_eval.py 中 gradient_scaler 属性直接抛出 NotImplementedError。

常见问题与调试建议

  • 端口不可达:MAIN_IP_PORT 中的端口必须对集群内所有节点开放。排查时可在 rank 0 节点上执行 nc -l <PORT> 或检查防火墙/安全组规则。
  • rank 计算错误:最常见的错误是把节点索引直接传给 --ddp.rank。请始终使用 GPU_RANK = NODE_RANK × GPU_PER_NODE。
  • 每个节点只启动一次:不要在一个节点上为每张 GPU 各运行一遍 corenet-train,正确做法是每节点运行一次,由内部 torch.multiprocessing.spawn 按 GPU 数量派生子进程。
  • 验证进程组是否建立:训练日志中会打印 distributed init (rank X): tcp://...,可据此确认各 rank 是否正确加入进程组(见 ddp_utils.py)。
  • 调试用参数:遇到梯度同步异常时,可尝试 --ddp.find-unused-params(见 opts.py)辅助定位;切勿使用 --ddp.use-deprecated-data-parallel 进行正式训练,它仅供调试且即将被移除。
  • Slurm 兼容性:如前所述,官方未在 Slurm 上完整测试该模板,正式大规模作业前建议先用小节点数、小配置做冒烟测试。

总结

CoreNet 的多节点训练本质上是一个"把节点/GPU 拓扑翻译成 PyTorch 进程组参数"的过程:通过 WORLD_SIZE = NUM_NODES × GPU_PER_NODE 与 GPU_RANK = NODE_RANK × GPU_PER_NODE 两个公式,把每个节点映射到全局 rank 区间;再借助 tcp://rank0:port 的初始化地址让所有进程汇合。理解了 entrypoints.py、opts.py、ddp_utils.py 与 default_train_eval.py 中的启动链路后,无论是手动 export 启动,还是通过 Slurm 脚本提交,都能准确控制集群中的每一个训练进程,为后续 DDP/FSDP 的大规模训练打下可靠基础。

登录后查看全文
corenet