第2章:单机多卡编程基础

好,咱们进入正题。单机多卡,说白了就是一台机器上插了好几块GPU,怎么让它们协同干活。这章我会从CUDA编程模型讲起,再带你看看PyTorch的分布式包怎么用。嗯,都是基本功,但很重要。

2.1 CUDA编程模型回顾

先聊聊CUDA。很多人觉得CUDA就是写核函数,其实没那么玄乎。我刚开始接触时也犯过迷糊——以为把数据扔到GPU上就完事了。结果呢?性能还不如CPU。

CUDA的核心思想是啥?异构计算。CPU负责控制流,GPU负责并行计算。你想想看,CPU就像项目经理,GPU就像一群搬砖工。项目经理分派任务,搬砖工们一起干。

关键概念:

  • Host:CPU及其内存
  • Device:GPU及其显存
  • Kernel:在GPU上执行的函数
  • Grid & Block:线程的组织结构

写CUDA代码时,我习惯先画个图。线程是怎么组织的?Grid里有Block,Block里有Thread。每个Thread执行同样的Kernel,但处理不同的数据。这就是SIMT(单指令多线程)模型。

// 一个简单的CUDA向量加法
__global__ void vecAdd(float* A, float* B, float* C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

int main() {
    // 分配显存
    float *d_A, *d_B, *d_C;
    cudaMalloc(&d_A, N * sizeof(float));
    cudaMalloc(&d_B, N * sizeof(float));
    cudaMalloc(&d_C, N * sizeof(float));
    
    // 拷贝数据到GPU
    cudaMemcpy(d_A, h_A, N * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, N * sizeof(float), cudaMemcpyHostToDevice);
    
    // 启动Kernel
    vecAdd<<<(N+255)/256, 256>>>(d_A, d_B, d_C, N);
    
    // 拷贝结果回CPU
    cudaMemcpy(h_C, d_C, N * sizeof(float), cudaMemcpyDeviceToHost);
}

这段代码看着简单,但坑不少。我曾经犯过一个错——忘了检查cudaMalloc的返回值。结果显存不够时,程序直接崩了。后来我养成了习惯:每次CUDA调用后都检查错误。

避坑指南:

我曾经在生产环境遇到过一个问题:多卡训练时,某张卡的显存突然爆了。排查了半天,发现是某个Kernel的block size设得太大,导致寄存器溢出。记住:block size不是越大越好,要根据GPU的SM数量和寄存器资源来调。

2.2 PyTorch分布式包简介

好了,CUDA基础有了,咱们看看PyTorch怎么玩分布式。PyTorch的分布式包叫torch.distributed。说实话,我第一次用的时候也觉得文档写得不够清楚。但用多了就发现,核心概念就那么几个。

先说说进程组。分布式训练时,每个GPU对应一个进程。这些进程组成一个组,组内可以互相通信。初始化进程组是第一步:

import torch.distributed as dist

# 初始化进程组
dist.init_process_group(
    backend='nccl',  # GPU通信用nccl
    init_method='env://',  # 通过环境变量获取rank和world_size
    world_size=4,  # 总共4个进程
    rank=rank  # 当前进程的编号
)

这里有几个关键参数:

  • backend:通信后端。GPU用nccl,CPU用gloo。我建议你直接用nccl,性能最好。
  • init_method:初始化方式。env://最常用,还有tcp://和file://。
  • world_size:总进程数,通常等于GPU数量。
  • rank:当前进程的编号,从0到world_size-1。

小技巧:

我个人习惯用环境变量来传rank和world_size。启动脚本里设好RANK、WORLD_SIZE、MASTER_ADDR和MASTER_PORT,代码里直接用env://,省事。

2.3 点对点通信

点对点通信,就是两个进程之间互相发数据。最常用的是send和recv。你想想看,这就像两个人打电话——一个人说,一个人听。

# 进程0发送数据给进程1
if rank == 0:
    tensor = torch.tensor([1, 2, 3]).cuda()
    dist.send(tensor, dst=1)
    
if rank == 1:
    tensor = torch.zeros(3).cuda()
    dist.recv(tensor, src=0)
    print(tensor)  # 输出: tensor([1, 2, 3])

点对点通信看着简单,但有个坑——死锁。我曾经写过这样的代码:

# 错误示例:可能死锁
if rank == 0:
    dist.send(tensor, dst=1)
    dist.recv(tensor, src=1)
    
if rank == 1:
    dist.send(tensor, dst=0)
    dist.recv(tensor, src=0)

为什么?因为send是阻塞的。进程0发完数据后,等着进程1发数据。进程1也一样。结果两个都在等,谁也不发。解决办法是用isend和irecv(非阻塞版本),或者调整发送顺序。

2.4 集合通信

集合通信就厉害了。它让所有进程一起干活,而不是两两通信。常用的有:

操作 说明 类比
Broadcast 一个进程把数据发给所有人 老师发试卷
All-Reduce 所有进程的数据求和/求平均,结果分给所有人 全班统计成绩
All-Gather 收集所有进程的数据,分给所有人 每人交作业,大家互相看
Reduce-Scatter 先求和,再把结果分散到各个进程 分组统计,每人拿一部分

分布式训练中最常用的是All-Reduce。每个GPU算完梯度后,用All-Reduce把梯度平均一下,然后各自更新参数。PyTorch的DistributedDataParallel底层就是这么干的。

# All-Reduce示例
tensor = torch.tensor([rank]).cuda()
dist.all_reduce(tensor, op=dist.ReduceOp.SUM)
print(f"Rank {rank}: {tensor}")  
# 假设4个进程,输出都是 tensor([6]),因为 0+1+2+3=6

核心要点:

集合通信的性能瓶颈通常在带宽和延迟。我遇到过一个问题:8卡训练时,All-Reduce占了总时间的30%。后来改用NCCL的ncclAllReduce,并调整了通信拓扑,时间降到了10%。

2.5 知识体系总览

说了这么多,咱们用一张图来总结本章的核心内容:

单机多卡编程基础 CUDA编程模型 • Host/Device模型 • Grid-Block-Thread • Kernel启动 • 显存管理 • 错误检查 torch.distributed • 进程组初始化 • backend选择 • rank/world_size • init_method • 环境变量配置 通信方式 • 点对点通信 - send/recv - isend/irecv • 集合通信 - All-Reduce/Broadcast 实践要点 1. 先理解CUDA内存模型,再写分布式代码 2. 初始化进程组时,确保所有进程的world_size一致 3. 点对点通信注意死锁问题,优先用非阻塞版本 4. 集合通信选NCCL后端,性能最优 图2-1:单机多卡编程知识体系

这张图把本章的三个核心模块串起来了。CUDA是基础,PyTorch分布式包是工具,通信方式是核心机制。三者缺一不可。

我的建议:

刚开始学分布式时,别急着上大模型。先写个简单的All-Reduce demo,跑通4卡。然后试试点对点通信,感受一下进程间怎么协作。我当年就是这么过来的——从单卡到多卡,一步步来,稳得很。

好了,这一章就到这里。记住:分布式编程的核心不是写代码,而是理解通信。把通信搞明白了,后面的大规模训练就是水到渠成的事。

可可精品资料库