把单卡换到多卡时踩过的坑
别急着给把单卡换到多卡时踩过的坑下定义,先看这次卡在哪。
很多人一上来就讲把单卡换到多卡时踩过的坑的全景图;我更想先把这次卡住的点说清楚。
为什么需要多卡训练
最开始训练一个 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。
通信优化:瓶颈在 PCIe 还是 NVLink
多卡训练的性能瓶颈往往不在计算,而在通信。GPU 之间的通信带宽决定了训练能有多快。3090 只支持 PCIe,没有 NVLink,这意味着卡间通信要通过 CPU 和内存,带宽大概在 16 GB/s 左右。A100 有 NVLink,带宽能达到 600 GB/s,差距很大。
通信优化主要有几个点:
- 梯度累积减少通信频率:梯度累积步数设大一点,减少同步次数
- 混合精度训练:FP16 计算和通信,减少数据量
- 通信和计算重叠:在计算的同时进行通信,隐藏延迟
# 混合精度训练 + 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 步。
| 方案 | 显存占用 | 训练速度 | 配置复杂度 |
|---|---|---|---|
| 单卡 | OOM | N/A | 低 |
| DDP | 46G | 1.0x | 中 |
| DeepSpeed ZeRO-2 | 36G | 0.95x | 中高 |
DDP 与 ZeRO-2 的显存和速度差异,用并排对比比单看表格更清楚:

ZeRO-2 用约 5% 的速度代价换来了明显的显存节省,在单卡 OOM 的场景下这笔交换很划算。
DDP 显存占用最高,因为每张卡都存了完整的模型参数和优化器状态。DeepSpeed ZeRO-2 通过分片优化器状态,显存占用降低了 22% 左右,训练速度略慢一点,但可以接受。
什么时候该用多卡
不是所有场景都适合多卡训练。小模型、小数据集,单卡足够。只有下面几种情况才值得折腾多卡:
- 显存不够:模型太大,单卡跑不起来
- 训练太慢:大模型训练周期太长,需要加速
- 大规模数据:数据量大到可以充分利用多卡
如果只是想尝试新技术,单卡也能学个七七八八。多卡训练的坑多,调优成本高,需要权衡。
一点收尾
从单卡到多卡,不是技术栈的简单扩展,而是对分布式计算的一次重新学习。通信开销、同步机制、资源管理,每个环节都可能成为瓶颈。但这些问题一旦解决,训练能力的提升是实实在在的。
多卡训练不是银弹,有些时候甚至会让事情变复杂。但在大规模模型训练这件事上,它几乎是必经之路。至少现在是这样。也许哪一天,单卡显存大到不需要分片,通信快到不需要优化,这些问题就不存在了。但那天还没到,我们还得继续折腾。
版权声明: 本文首发于 指尖魔法屋-把单卡换到多卡时踩过的坑(https://blog.thinkmoon.cn/post/233-model-parallel-distributed-training-single-to-multi-gpu/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。