把单卡换到多卡时踩过的坑

别急着给把单卡换到多卡时踩过的坑下定义,先看这次卡在哪。

很多人一上来就讲把单卡换到多卡时踩过的坑的全景图;我更想先把这次卡住的点说清楚。

为什么需要多卡训练

最开始训练一个 7B 参数模型的时候,我还天真地以为 24G 显存的 3090 能勉强跑起来。batch_size 设到 1,gradient accumulation 步数设到 32,显存还是爆了。不是模型参数占显存,是激活值占显存。越大模型,激活值占的显存比例越高,这不是靠 batch_size 调小就能解决的问题。

多卡训练主要解决两个问题:一是显存不够,二是训练太慢。显存问题可以通过模型并行(把模型切分到多张卡)或者数据并行(把数据分到多张卡)解决。速度问题主要靠数据并行,每张卡处理一部分数据,然后同步梯度。

数据并行:DDP 还是 FSDP

PyTorch 提供了两种主流的数据并行方案:DistributedDataParallel (DDP) 和 FullyShardedDataParallel (FSDP)。DDP 是最经典的方案,每张卡都有一份完整的模型副本,训练时各自计算梯度,然后通过 all-reduce 同步。FSDP 是更激进的方案,它把模型参数、梯度和优化器状态都分片到多张卡上,能显著降低显存占用。

先用 DDP 跑起来再说,简单直接。Docker 环境是 Ubuntu 22.04 + Python 3.10 + PyTorch 2.1.0 + CUDA 12.1,两块 3090 通过 PCIe 4.0 x16 连接。一开始以为只要把训练脚本改改就能用,结果第一轮就卡住了。

DDP 的第一道坎:NCCL 初始化

import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler

def setup_ddp(rank, world_size):
    # 初始化进程组
    dist.init_process_group(
        backend='nccl',
        init_method='tcp://127.0.0.1:12345',
        rank=rank,
        world_size=world_size
    )
    # 设置当前设备的 CUDA 设备
    torch.cuda.set_device(rank)

def cleanup_ddp():
    dist.destroy_process_group()

def train(rank, world_size):
    setup_ddp(rank, world_size)

    # 创建模型并移动到当前设备
    model = MyModel()
    model = model.to(rank)
    model = DDP(model, device_ids=[rank])

    # 数据集和采样器
    dataset = MyDataset()
    sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
    dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)

    # 训练循环
    for epoch in range(num_epochs):
        sampler.set_epoch(epoch)  # 确保每个 epoch 数据打乱不同
        for batch in dataloader:
            # 训练逻辑
            pass

    cleanup_ddp()

if __name__ == '__main__':
    world_size = torch.cuda.device_count()
    torch.multiprocessing.spawn(train, args=(world_size,), nprocs=world_size)

第一次跑就报错:RuntimeError: NCCL error: unhandled system error。查了半天,发现是 NCCL 版本问题。PyTorch 2.1.0 默认的 NCCL 2.18.1 在某些环境下不太稳定,升级到 2.19.3 后问题解决。

# 重新编译 NCCL
git clone https://github.com/NVIDIA/nccl.git
cd nccl
make -j8
sudo make install

# 重新安装 PyTorch
pip install torch --no-cache-dir

DDP 的第二道坎:梯度同步时机

DDP 会在反向传播时自动同步梯度,但这里有个坑。如果你的代码里有手动操作梯度的逻辑(比如梯度裁剪),需要确保在同步之后操作。一开始我就在同步前做了梯度裁剪,导致不同卡的梯度不一致,训练效果很差。

# 错误的梯度裁剪时机
for batch in dataloader:
    loss = model(batch)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # 错误:在同步前裁剪
    optimizer.step()

# 正确的梯度裁剪时机
for batch in dataloader:
    loss = model(batch)
    loss.backward()
    # DDP 在这里自动同步梯度
    optimizer.step()
    # 梯度裁剪应该放在 optimizer.step() 之后
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

DDP 的第三道坎:Batch Size 和学习率调整

多卡训练时,每张卡的 batch_size 设为 32,两卡并行相当于总 batch_size 是 64。这时候需要线性调整学习率。原始学习率是 1e-4,多卡训练时要调整为 2e-4。这个不难理解,但容易忘。忘了的话,训练收敛会慢很多,甚至不收敛。

import math

# 线性缩放规则
base_lr = 1e-4
world_size = 2
adjusted_lr = base_lr * world_size

optimizer = torch.optim.AdamW(model.parameters(), lr=adjusted_lr)

模型并行:DDP 搞不定的时候

当模型大到连 DDP 都跑不起来的时候(比如 70B 参数模型),就需要模型并行了。模型并行把模型的不同层分布到不同的 GPU 上,每个 GPU 只存储一部分模型参数和计算结果。最常用的是流水线并行(Pipeline Parallelism)和张量并行(Tensor Parallelism)。

流水线并行:把模型切成几段

流水线并行把模型按层切成几段,每段放到不同的 GPU 上。比如一个 24 层的 Transformer,可以切成 4 段,每 6 层一个 GPU。

from torch.distributed.pipeline.sync import Pipe

# 创建模型分段
model = nn.Sequential(
    nn.Linear(1024, 1024),
    nn.ReLU(),
    # ... 更多层
)

# 分割模型到多张卡
chunks = 4  # 分成 4 段
model = Pipe(model, chunks=chunks)

# 训练
for batch in dataloader:
    output = model(batch)  # 自动在多卡间流水线执行
    loss = criterion(output, target)
    loss.backward()

流水线并行的问题是会有空闲时间。比如 GPU 1 在计算第 1 个 batch 的前半部分时,GPU 2 是空闲的;GPU 2 在计算第 1 个 batch 的后半部分时,GPU 1 又空闲了。这个叫 “bubble”,会降低整体效率。

张量并行:把矩阵乘法拆开

张量并行更底层一点,把矩阵乘法操作拆开到多个 GPU 上。比如一个 1024x1024 的矩阵乘法,可以拆成两个 1024x512 的矩阵乘法,分别在不同的 GPU 上计算,然后把结果拼起来。

# 简化的张量并行示例
class ColumnParallelLinear(nn.Module):
    def __init__(self, in_features, out_features, world_size):
        super().__init__()
        self.out_features_per_gpu = out_features // world_size
        self.weight = nn.Parameter(torch.randn(in_features, self.out_features_per_gpu))
        self.world_size = world_size

    def forward(self, x):
        # 本地计算
        local_output = torch.matmul(x, self.weight)
        # 聚合所有 GPU 的结果
        outputs = [torch.zeros_like(local_output) for _ in range(self.world_size)]
        dist.all_gather(outputs, local_output)
        return torch.cat(outputs, dim=-1)

张量并行的实现比较复杂,一般用现成的库。Megatron-LM 和 DeepSpeed 都有成熟的张量并行实现。

DeepSpeed:一站式解决方案

当自己实现模型并行太麻烦时,DeepSpeed 是个好选择。它提供了 ZeRO(Zero Redundancy Optimizer)优化,可以把模型参数、梯度和优化器状态都分片存储,大大降低显存占用。

import deepspeed

# DeepSpeed 配置
ds_config = {
    "train_batch_size": 32,
    "gradient_accumulation_steps": 1,
    "optimizer": {
        "type": "AdamW",
        "params": {
            "lr": 1e-4,
            "betas": [0.9, 0.999],
            "eps": 1e-8,
            "weight_decay": 0.01
        }
    },
    "scheduler": {
        "type": "WarmupLR",
        "params": {
            "warmup_min_lr": 0,
            "warmup_max_lr": 1e-4,
            "warmup_num_steps": 1000
        }
    },
    "fp16": {
        "enabled": True,
        "loss_scale": 0,
        "initial_scale_power": 16,
        "loss_scale_window": 1000,
        "hysteresis": 2,
        "min_loss_scale": 1
    },
    "zero_optimization": {
        "stage": 2,
        "allgather_partitions": True,
        "allgather_bucket_size": 2e8,
        "overlap_comm": True,
        "reduce_scatter": True,
        "reduce_bucket_size": 2e8,
        "contiguous_gradients": True
    },
    "gradient_clipping": 1.0
}

# 初始化 DeepSpeed
model_engine, optimizer, _, _ = deepspeed.initialize(
    model=model,
    model_parameters=model.parameters(),
    config=ds_config
)

# 训练循环
for batch in dataloader:
    loss = model_engine(batch)
    model_engine.backward(loss)
    model_engine.step()

DeepSpeed 的 ZeRO 有三个阶段:

  • Stage 1:分片优化器状态
  • Stage 2:分片梯度和优化器状态
  • Stage 3:分片模型参数、梯度和优化器状态

Stage 3 最省显存,但通信开销也最大。我的实践是先用 Stage 2,如果显存还不够再用 Stage 3。

多卡训练的性能瓶颈往往不在计算,而在通信。GPU 之间的通信带宽决定了训练能有多快。3090 只支持 PCIe,没有 NVLink,这意味着卡间通信要通过 CPU 和内存,带宽大概在 16 GB/s 左右。A100 有 NVLink,带宽能达到 600 GB/s,差距很大。

通信优化主要有几个点:

  1. 梯度累积减少通信频率:梯度累积步数设大一点,减少同步次数
  2. 混合精度训练:FP16 计算和通信,减少数据量
  3. 通信和计算重叠:在计算的同时进行通信,隐藏延迟
# 混合精度训练 + DDP
scaler = torch.cuda.amp.GradScaler()

for batch in dataloader:
    optimizer.zero_grad()
    with torch.cuda.amp.autocast():
        loss = model(batch)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

实测下来,两块 3090 的 DDP 训练相比单卡,性能提升大概在 1.6-1.8 倍左右,达不到理想的 2 倍。主要瓶颈就是 PCIe 带宽。如果用 NVLink 连接的 A100,提升会更接近线性。

踩坑记录:那些不起眼但致命的细节

随机数种子

多卡训练时,不同进程的随机数种子必须不同,否则每个卡会处理相同的数据。

import torch
import numpy as np
import random

def set_seed(seed, rank):
    random.seed(seed + rank)
    np.random.seed(seed + rank)
    torch.manual_seed(seed + rank)
    torch.cuda.manual_seed(seed + rank)
    torch.cuda.manual_seed_all(seed + rank)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

# 使用时
set_seed(42, rank)

评估和验证

DDP 训练时,验证也需要处理。要么只在主进程做验证,要么确保验证数据的采样器也正确设置。

# 只在主进程做验证
if rank == 0:
    model.eval()
    with torch.no_grad():
        for batch in val_dataloader:
            # 验证逻辑
            pass
    model.train()

内存泄漏

多卡训练时,内存泄漏问题会被放大。一个卡泄漏一点点,多卡加起来就可能把系统内存撑爆。用 torch.cuda.empty_cache() 可以手动清理缓存,但不要频繁调用,会影响性能。

# 定期清理缓存
if step % 100 == 0:
    torch.cuda.empty_cache()

实战对比:单卡 vs DDP vs DeepSpeed ZeRO-2

在训练一个 7B 参数模型时,我做了一次简单的对比测试。环境是两块 3090,每个方案跑 1000 步。

方案显存占用训练速度配置复杂度
单卡OOMN/A
DDP46G1.0x
DeepSpeed ZeRO-236G0.95x中高

DDP 与 ZeRO-2 的显存和速度差异,用并排对比比单看表格更清楚:

7B 模型双 3090 环境下 DDP 与 DeepSpeed ZeRO-2 的显存占用和相对训练速度对比

ZeRO-2 用约 5% 的速度代价换来了明显的显存节省,在单卡 OOM 的场景下这笔交换很划算。

DDP 显存占用最高,因为每张卡都存了完整的模型参数和优化器状态。DeepSpeed ZeRO-2 通过分片优化器状态,显存占用降低了 22% 左右,训练速度略慢一点,但可以接受。

什么时候该用多卡

不是所有场景都适合多卡训练。小模型、小数据集,单卡足够。只有下面几种情况才值得折腾多卡:

  1. 显存不够:模型太大,单卡跑不起来
  2. 训练太慢:大模型训练周期太长,需要加速
  3. 大规模数据:数据量大到可以充分利用多卡

如果只是想尝试新技术,单卡也能学个七七八八。多卡训练的坑多,调优成本高,需要权衡。

一点收尾

从单卡到多卡,不是技术栈的简单扩展,而是对分布式计算的一次重新学习。通信开销、同步机制、资源管理,每个环节都可能成为瓶颈。但这些问题一旦解决,训练能力的提升是实实在在的。

多卡训练不是银弹,有些时候甚至会让事情变复杂。但在大规模模型训练这件事上,它几乎是必经之路。至少现在是这样。也许哪一天,单卡显存大到不需要分片,通信快到不需要优化,这些问题就不存在了。但那天还没到,我们还得继续折腾。

版权声明: 本文首发于 指尖魔法屋-把单卡换到多卡时踩过的坑https://blog.thinkmoon.cn/post/233-model-parallel-distributed-training-single-to-multi-gpu/) 转载或引用必须申明原指尖魔法屋来源及源地址!