生产级分布式训练实战 - 第2节

单机多卡训练基础:从CUDA到DDP的进化之路

大家好,我是你们的老朋友。今天咱们聊聊单机多卡训练的基础。说实话,这块内容我讲了不下几十次,但每次都有新感悟。你想想看,从单卡到多卡,看似只是多了几张卡,但背后的思维模式完全不一样了。

我个人习惯把单机多卡训练分成三个层次:CUDA编程模型是地基,DataParallel是新手村,DistributedDataParallel才是真正的战场。咱们一层层往上走。

CUDA编程模型回顾

先别急着跳过去。我知道很多人觉得CUDA是搞底层的人学的,咱们做训练的不用管。嗯,这个想法我也有过,直到有一次排查一个诡异的显存泄漏问题,折腾了两天,最后发现是CUDA stream没同步。从那以后,我老老实实把CUDA编程模型补了一遍。

核心概念:Host vs Device

说白了,CUDA把计算分成了两个世界:

  • Host:CPU端,负责控制逻辑、数据准备
  • Device:GPU端,负责大规模并行计算

数据要从Host搬到Device,算完再搬回来。这个搬运过程,就是训练中最容易出性能瓶颈的地方。

Kernel Launch与Stream

CUDA的kernel launch是异步的。什么意思?就是你调用一个核函数,CPU不会等它算完,而是直接往下执行。这本来是好事,能充分利用CPU和GPU的并行性。但问题来了——如果你不小心,就会在数据还没算完的时候去读结果。

我曾经踩过的坑: 在自定义算子里忘了加cudaDeviceSynchronize(),结果每次跑出来的loss都不一样。排查了整整一天,最后发现是异步执行导致的脏数据。所以,同步点一定要心里有数。

内存层次结构

CUDA的内存模型,我建议你记住这张图:

Global Memory (显存) - 所有线程可访问 容量大(GB级) | 延迟高(几百cycle) | 所有Grid共享 Shared Memory (共享内存) - Block内共享 容量小(KB级) | 延迟低(几十cycle) | 手动管理 Registers (寄存器) - 线程私有 容量极小(几十B) | 延迟1cycle | 编译器自动分配

这张图我每次培训都会画。你注意看,越往下容量越小、速度越快。写CUDA代码的核心思路就是:尽量把数据往快了放。

PyTorch DataParallel原理与局限

好,现在咱们进入PyTorch的世界。DataParallel(简称DP)是PyTorch最早提供的多卡方案。用法很简单:

model = nn.DataParallel(model, device_ids=[0,1,2,3])
outputs = model(inputs)  # 一行代码搞定多卡

是不是很爽?但爽的背后有代价。

DP的工作原理

DP的工作流程,我总结为三步:

  1. Scatter:把输入batch切分成多个小batch,分发到各GPU
  2. Replicate:把模型复制到每个GPU上
  3. Gather & Reduce:各GPU算完梯度后,汇总到主卡(device_ids[0])

问题就出在第三步。所有梯度都要先汇集到主卡,主卡更新完参数,再广播回去。这意味着:

  • 主卡的显存压力巨大(要存所有卡的梯度)
  • 通信瓶颈明显(所有流量经过主卡)
  • 主卡的计算和通信无法重叠
一句话总结DP的局限: 它把多卡训练做成了"单卡+辅助卡"的模式,而不是真正的并行。

我在项目中遇到的DP问题

记得有一次,我用4张V100跑一个BERT-large模型。用DP跑,batch size只能开到16,再大主卡就OOM了。后来换成DDP,batch size直接翻到64,训练速度提升了将近3倍。这就是DP的"主卡瓶颈"在作祟。

PyTorch DistributedDataParallel(DDP)原理

DDP才是真正的分布式训练方案。它的核心思想是:各卡独立计算,只在梯度同步时通信。

DDP的核心机制

DDP的工作流程,我习惯用这张图来解释:

GPU 0 (Rank 0) 前向+反向计算 梯度: g0 模型副本0 GPU 1 (Rank 1) 前向+反向计算 梯度: g1 模型副本1 GPU 2 (Rank 2) 前向+反向计算 梯度: g2 模型副本2 GPU 3 (Rank 3) 前向+反向计算 梯度: g3 模型副本3 AllReduce 梯度同步 g_avg = (g0 + g1 + g2 + g3) / 4 各卡用 g_avg 更新本地模型参数

看到没?DDP的关键在于AllReduce操作。各卡算完自己的梯度后,通过AllReduce把梯度平均,然后各卡独立更新参数。这样就没有了主卡瓶颈。

DDP的三大优势

对比维度 DataParallel DistributedDataParallel
通信模式 主从式(主卡收集再广播) 对等式(AllReduce)
显存均衡 主卡显存占用远高于其他卡 各卡显存基本均衡
扩展性 4卡以上性能下降明显 可扩展到数百卡
计算通信重叠 不支持 支持(反向传播时同步梯度)
我的建议: 新项目直接上DDP,别在DP上浪费时间。除非你只是做快速原型验证,且只用2张卡。

DDP的启动方式:torchrun vs mp.spawn

好,现在咱们聊聊怎么启动DDP。主要有两种方式:torchrun和mp.spawn。

方式一:torchrun(推荐)

torchrun是PyTorch官方推荐的启动方式。用法很简单:

# 训练脚本 train.py
import torch
import torch.distributed as dist

def main():
    dist.init_process_group("nccl")
    local_rank = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local_rank)
    
    model = MyModel().cuda()
    ddp_model = DDP(model, device_ids=[local_rank])
    # ... 训练逻辑

if __name__ == "__main__":
    main()

# 命令行启动
# torchrun --nproc_per_node=4 train.py

torchrun会自动帮你设置好环境变量:

  • LOCAL_RANK:当前进程在本机上的编号(0,1,2,3)
  • RANK:全局编号(多机时有用)
  • WORLD_SIZE:总进程数

我个人特别喜欢torchrun的一点是:它帮你处理了失败重启的逻辑。如果某个进程挂了,torchrun会自动重启所有进程。这在长时间训练时特别有用。

方式二:mp.spawn(手动控制)

mp.spawn是更底层的启动方式,适合需要精细控制的场景:

import torch.multiprocessing as mp

def train_worker(local_rank, world_size):
    dist.init_process_group(
        "nccl",
        rank=local_rank,
        world_size=world_size
    )
    torch.cuda.set_device(local_rank)
    model = MyModel().cuda()
    ddp_model = DDP(model, device_ids=[local_rank])
    # ... 训练逻辑

if __name__ == "__main__":
    world_size = 4
    mp.spawn(train_worker, args=(world_size,), nprocs=world_size)
我曾经踩过的坑: 用mp.spawn时,记得在train_worker函数里加上dist.barrier()做同步。有一次我忘了加,结果rank 0都开始算下一个batch了,rank 3还在初始化,直接报错。torchrun默认帮你做了这些,但mp.spawn需要你自己处理。

两种方式的对比

特性 torchrun mp.spawn
易用性 ⭐⭐⭐⭐⭐ 一行命令启动 ⭐⭐⭐ 需要手动管理进程
失败重启 自动支持 需要自己实现
多机扩展 原生支持(--nnodes) 需要额外配置
调试灵活性 较低(环境变量自动设置) 较高(可自定义rank分配)
我的选择: 生产环境用torchrun,简单可靠。只有做特殊调试或者需要自定义进程拓扑时,才用mp.spawn。

好了,单机多卡的基础就聊到这儿。记住一句话:DP是玩具,DDP才是工具。下一章咱们会深入DDP的通信优化,包括梯度压缩、异步通信这些实战技巧。到时候见。

可可精品资料库