2. 数据并行入门

2.1 数据并行:最朴素的分布式思路

数据并行,说白了就是「把数据切开,分给多张卡一起算」。

我刚开始接触分布式训练时,第一反应就是:一张卡跑不动,那就多上几张卡呗。但怎么分?怎么合?这里头有门道。

数据并行的核心逻辑很简单:

  • 每张卡都有一份完整的模型副本
  • 把训练数据切成若干份,每张卡拿一份
  • 各自算各自的梯度
  • 把梯度汇总,更新模型

你想想看,如果一张卡处理一个 batch 需要 1 秒,那 4 张卡理论上就能在 1 秒内处理 4 个 batch。当然,这是理想情况。实际中因为通信开销,会有一些损耗。

关键点:数据并行不改变模型结构,只改变数据流向。模型有多大,每张卡就得装多大。

2.2 AllReduce 算法:梯度的「快递系统」

数据并行里最核心的问题就是:梯度怎么汇总?

每张卡算完自己的梯度后,需要把梯度「凑」到一起,求个平均。这个「凑」的过程,就是 AllReduce 干的活。

我最早用 PyTorch 时,以为梯度汇总就是简单的「把梯度发到一张卡,算完再广播回去」。后来发现,这种做法叫「参数服务器」,效率其实不高。

AllReduce 有几种经典实现:

算法 思路 通信量 我的评价
Ring AllReduce 卡与卡组成环,数据像接力棒一样传递 2*(N-1)*数据量 最常用,带宽利用率高
Tree AllReduce 树形结构,逐层归约 2*log2(N)*数据量 适合小规模集群
Butterfly AllReduce 两两交换,并行度高 log2(N)*数据量 延迟低,但实现复杂

Ring AllReduce 是我用得最多的。它分两步走:

  1. Scatter-Reduce 阶段:每张卡把自己的梯度切成 N 份(N 是卡数),然后沿着环传递。每传一次,累加一次。最后每张卡都有一份完整的累加结果,但只针对其中一块数据。
  2. AllGather 阶段:把累加好的结果广播给所有卡。每张卡最终拿到完整的平均梯度。

嗯,这里要注意:Ring AllReduce 的通信量跟卡数无关,只跟数据量有关。这意味着卡越多,相对效率越高。我在项目中遇到过 64 卡训练,Ring AllReduce 的表现比参数服务器好了一个数量级。

我的经验:如果你用 PyTorch DDP,底层默认就是 Ring AllReduce(NCCL 后端)。你不需要手动实现,但理解它的原理对调优很有帮助。

2.3 PyTorch DDP:开箱即用的数据并行

PyTorch 的 DistributedDataParallel(DDP)是我最推荐的分布式训练工具。它封装了数据并行的所有细节,你只需要改几行代码。

为什么不用 DataParallel(DP)?我刚开始也踩过这个坑。DP 是单机多卡,但它的实现有缺陷:

  • 主卡(GPU:0)要负责汇总和广播,容易成为瓶颈
  • 每张卡的计算结果都要经过主卡,通信开销大
  • 不支持多机

DDP 就不一样了。它用 AllReduce 做梯度同步,每张卡地位平等,没有主卡瓶颈。而且支持多机多卡,扩展性很好。

来看一个最简单的 DDP 示例:

import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel as DDP

def train(rank, world_size):
    # 初始化进程组
    dist.init_process_group(
        backend='nccl',
        init_method='tcp://127.0.0.1:23456',
        rank=rank,
        world_size=world_size
    )
    
    # 创建模型,放到对应 GPU
    model = MyModel().to(rank)
    ddp_model = DDP(model, device_ids=[rank])
    
    # 创建数据加载器(每个进程拿不同数据)
    dataset = MyDataset()
    sampler = torch.utils.data.distributed.DistributedSampler(
        dataset, num_replicas=world_size, rank=rank
    )
    dataloader = DataLoader(dataset, sampler=sampler, batch_size=32)
    
    optimizer = torch.optim.SGD(ddp_model.parameters(), lr=0.01)
    
    for epoch in range(10):
        sampler.set_epoch(epoch)  # 打乱数据
        for data, target in dataloader:
            data, target = data.to(rank), target.to(rank)
            
            optimizer.zero_grad()
            output = ddp_model(data)
            loss = loss_fn(output, target)
            loss.backward()
            optimizer.step()
    
    dist.destroy_process_group()

if __name__ == '__main__':
    world_size = 4
    mp.spawn(train, args=(world_size,), nprocs=world_size)

这段代码里,我特别想强调几个点:

  • DistributedSampler:它负责给每个进程分配不同的数据。如果不加这个,所有卡都在算同一批数据,那就白忙活了。
  • set_epoch:每个 epoch 都要调用一次,否则数据打乱顺序会一样。
  • device_ids:指定模型放在哪张卡上。DDP 要求每张卡有自己的模型副本。

我曾经踩过的坑:DDP 的 batch_size 是每张卡的 batch_size。如果你设了 batch_size=32,4 张卡实际的总 batch_size 是 128。学习率也要相应调整,一般按卡数线性缩放。

2.4 数据并行的知识体系

下面这张图是我自己梳理的数据并行核心逻辑,你看一眼就能明白整体脉络:

数据并行核心逻辑 训练数据 数据分片 0 数据分片 1 数据分片 2 数据分片 3 模型副本 0 模型副本 1 模型副本 2 模型副本 3 AllReduce 梯度同步 更新模型参数

从这张图你能看到,数据并行的流程其实很清晰:数据分片 → 各自前向/反向 → AllReduce 同步梯度 → 更新模型。每一步都有对应的 PyTorch API 帮你搞定。

2.5 启动 DDP 的两种方式

DDP 的启动方式有两种,我分别说一下:

方式一:torchrun(推荐)

# 单机 4 卡
torchrun --nproc_per_node=4 train.py

# 多机 8 卡(每机 4 卡)
torchrun --nnodes=2 --nproc_per_node=4 --master_addr=192.168.1.1 train.py

方式二:手动 spawn

mp.spawn(train, args=(world_size,), nprocs=world_size)

我个人习惯用 torchrun。它自动帮你处理了环境变量、进程管理、故障恢复等琐事。你只需要在代码里读一下环境变量就行:

local_rank = int(os.environ['LOCAL_RANK'])
rank = int(os.environ['RANK'])
world_size = int(os.environ['WORLD_SIZE'])

小技巧:用 torchrun 启动时,代码里不需要写 init_process_group 的 init_method 参数。torchrun 会自动设置好 MASTER_ADDR 和 MASTER_PORT 环境变量。

2.6 避坑指南

最后,分享几个我实际踩过的坑:

  • BatchNorm 的问题:DDP 默认会对 BatchNorm 做同步,但同步 BN 会引入额外的通信开销。如果模型很大,可以考虑用 SyncBatchNorm 或者干脆用 LayerNorm。
  • 随机种子:每张卡的随机种子要不一样,否则数据打乱结果相同。用 torch.utils.data.distributed.DistributedSampler 会自动处理。
  • 梯度累积:如果用了梯度累积,记得在累积完后再调用 optimizer.step()。DDP 的梯度同步发生在 loss.backward() 时,不是 step() 时。
  • 模型保存:只让 rank 0 保存模型,否则会写冲突。保存时用 model.module.state_dict() 而不是 model.state_dict()。

数据并行是分布式训练的起点,也是最容易上手的方式。搞懂了 DDP,后面再学模型并行、流水线并行就会轻松很多。