4、多机多卡同步训练实战:多机通信原理、torchrun 启动脚本、环境变量设置、多机训练代码实战

好,咱们终于聊到多机多卡了。

说实话,单机多卡训练,你只要搞定一台机器上的 GPU 通信就行。但多机训练,说白了就是让几台机器像一支军队一样协同作战。我刚开始搞多机训练时,踩过的坑比代码行数还多。今天我把这些经验掰开揉碎了讲给你。

4.1 多机通信原理:机器之间怎么“说话”

多机训练的核心,是让每台机器上的 GPU 能交换梯度。你想想看,如果每台机器只算自己那部分数据,最后不合并梯度,那模型就学歪了。

PyTorch 用的通信后端是 NCCL(NVIDIA Collective Communications Library)。它专门为 GPU 设计,走的是 InfiniBand 或者 RoCE 网络。我个人习惯用 InfiniBand,延迟低、带宽大,但贵。如果你只有千兆以太网,嗯,那训练速度会慢得让你怀疑人生。

通信模式是 AllReduce。每台机器算完梯度后,NCCL 会把所有机器的梯度加起来,再广播回去。这个过程叫“规约-广播”。

关键点:多机通信的瓶颈不在 GPU 算力,而在网络带宽。我曾经在 4 台机器上跑 ResNet-50,千兆网卡只能跑到 40% 的 GPU 利用率,换成 InfiniBand 直接拉满到 95%。

这里我画了一张图,帮你理解多机通信的流程:

多机多卡 AllReduce 通信流程 机器 1 (RANK=0) GPU 0 梯度: [g0, g1, g2] GPU 1 梯度: [g3, g4, g5] 机器 2 (RANK=1) GPU 2 梯度: [g6, g7, g8] GPU 3 梯度: [g9, g10, g11] NCCL AllReduce 聚合梯度: [g0+g6, g1+g7, ..., g5+g11] 每台机器都拿到完整梯度 广播回 GPU 0,1 广播回 GPU 2,3 所有 GPU 用相同梯度更新参数 → 模型一致

我的经验:多机训练时,尽量让每台机器的 GPU 数量一致。我曾经混用 4 卡和 8 卡的机器,结果慢的那台成了瓶颈,整体效率反而下降。保持对称,性能最优。

4.2 torchrun 启动脚本:一键拉起多机训练

PyTorch 官方推荐用 torchrun 来启动分布式训练。它帮你处理了进程管理、环境变量设置、错误重启等脏活累活。

先看一个典型的多机启动命令:

# 在机器 1 上执行(主节点)
torchrun --nnodes=2 --nproc_per_node=4 \
  --rdzv_endpoint=192.168.1.100:29500 \
  --rdzv_backend=c10d \
  train.py

# 在机器 2 上执行(从节点)
torchrun --nnodes=2 --nproc_per_node=4 \
  --rdzv_endpoint=192.168.1.100:29500 \
  --rdzv_backend=c10d \
  train.py

参数说明:

参数 含义 我的建议
--nnodes 总机器数 写死,不要用变量
--nproc_per_node 每台机器的 GPU 数 一般等于 GPU 数量
--rdzv_endpoint 主节点的 IP:端口 用内网 IP,别用 127.0.0.1
--rdzv_backend 发现后端 c10d 就够了

注意:所有机器的启动命令必须完全一样!我曾经在机器 2 上把 --nnodes 写成了 1,结果等了半小时才发现进程根本没连上。嗯,这种低级错误犯一次就够了。

4.3 环境变量设置:MASTER_ADDR, MASTER_PORT, WORLD_SIZE, RANK

torchrun 会自动帮你设置这些环境变量,但理解它们很重要。我见过太多人因为不理解这些变量而出错。

环境变量 含义 示例值
MASTER_ADDR 主节点 IP 192.168.1.100
MASTER_PORT 主节点端口 29500
WORLD_SIZE 总进程数 = nnodes × nproc_per_node 8
RANK 当前进程的全局编号 0 ~ 7
LOCAL_RANK 当前进程在本地机器上的编号 0 ~ 3

举个例子:2 台机器,每台 4 卡,那么:

  • 机器 1 的进程 RANK 是 0,1,2,3,LOCAL_RANK 是 0,1,2,3
  • 机器 2 的进程 RANK 是 4,5,6,7,LOCAL_RANK 是 0,1,2,3

核心逻辑:RANK 是全局唯一的,用于区分不同进程。LOCAL_RANK 是本地唯一的,用于绑定 GPU 设备。你想想看,如果不用 LOCAL_RANK,你怎么知道当前进程该用哪块 GPU?

4.4 多机训练代码实战

好,代码来了。这是一个完整的多机训练脚本,我加了详细的注释。

import torch
import torch.distributed as dist
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
from torch.utils.data.distributed import DistributedSampler

# 1. 初始化进程组
def init_distributed():
    dist.init_process_group(backend='nccl')
    torch.cuda.set_device(dist.get_rank() % torch.cuda.device_count())

# 2. 模拟数据集
class FakeDataset(Dataset):
    def __init__(self, size=1000):
        self.data = torch.randn(size, 3, 224, 224)
        self.labels = torch.randint(0, 10, (size,))
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]

# 3. 简单模型
class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(3*224*224, 10)
    
    def forward(self, x):
        return self.fc(x.view(x.size(0), -1))

# 4. 训练函数
def train():
    init_distributed()
    
    rank = dist.get_rank()
    local_rank = rank % torch.cuda.device_count()
    
    # 每个进程绑定不同的 GPU
    device = torch.device(f'cuda:{local_rank}')
    
    # 模型放到 GPU 上
    model = SimpleModel().to(device)
    # 包装成分布式模型
    model = nn.parallel.DistributedDataParallel(model, device_ids=[local_rank])
    
    # 数据集:每个进程只看到一部分
    dataset = FakeDataset()
    sampler = DistributedSampler(dataset)
    dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)
    
    optimizer = optim.SGD(model.parameters(), lr=0.01)
    criterion = nn.CrossEntropyLoss()
    
    for epoch in range(5):
        sampler.set_epoch(epoch)  # 每个 epoch 打乱数据
        for data, target in dataloader:
            data, target = data.to(device), target.to(device)
            
            optimizer.zero_grad()
            output = model(data)
            loss = criterion(output, target)
            loss.backward()
            optimizer.step()
        
        # 只在主进程打印
        if rank == 0:
            print(f'Epoch {epoch}, Loss: {loss.item():.4f}')
    
    dist.destroy_process_group()

if __name__ == '__main__':
    train()

避坑指南:我曾经在代码里忘了调用 sampler.set_epoch(epoch),结果每个 epoch 的数据顺序都一样,模型过拟合得一塌糊涂。记住,分布式训练时,每个 epoch 都要重新打乱数据!

启动方式:

# 机器 1
torchrun --nnodes=2 --nproc_per_node=4 --rdzv_endpoint=192.168.1.100:29500 train.py

# 机器 2
torchrun --nnodes=2 --nproc_per_node=4 --rdzv_endpoint=192.168.1.100:29500 train.py

重要:所有机器的代码必须完全一致!不要试图在机器 1 上改模型结构,机器 2 上改学习率。分布式训练要求所有进程的模型结构、优化器配置完全相同。我曾经犯过这个错,结果梯度对不上,训练直接崩溃。

最后说一句,多机训练调试起来比单机麻烦得多。我的习惯是先在一台机器上用 --nnodes=1 跑通,确认代码没问题,再扩展到多机。这样能省下大量排查网络问题的时间。

好了,多机多卡同步训练的核心内容就这些。记住:通信是瓶颈,环境变量是基础,torchrun 是你的好帮手。动手试试吧!

可可精品资料库