一、GPU架构与CUDA编程模型:从硬件到实战

各位同学,欢迎来到《CUDA编程与NCCL联合调优实战》的第一章。

说实话,我见过太多人一上来就写CUDA代码,结果性能惨不忍睹。为什么?因为没搞懂GPU到底是怎么工作的。今天我们就从硬件架构讲起,把GPU的底裤扒干净。

1.1 GPU硬件架构:SM、Warp与Memory Hierarchy

先看一张图,这是我个人习惯——学任何东西之前,先看整体框架。

GPU硬件架构与CUDA编程模型 GPU 设备 GPC 0 SM 0 (128 CUDA Cores) SM 1 (128 CUDA Cores) L1 / Shared Memory (128KB) GPC 1 SM 2 (128 CUDA Cores) SM 3 (128 CUDA Cores) L1 / Shared Memory (128KB) GPC 2 SM 4 (128 CUDA Cores) SM 5 (128 CUDA Cores) L1 / Shared Memory (128KB) L2 Cache (6MB - 40MB 取决于架构) Memory Controller 0 Memory Controller 1 Memory Controller 2 Memory Controller 3 HBM2 / GDDR6 显存 (16GB - 80GB) GPC (图形处理簇) SM (流式多处理器) L1/Shared Memory L2 Cache

1.2 SM(流式多处理器)—— GPU的"心脏"

SM是GPU最核心的计算单元。你可以把它想象成一个"迷你CPU",但它跟CPU完全不同。

SM内部长什么样?

  • CUDA Cores:每个SM里有几十到上百个。比如A100有128个,H100有256个。这些才是真正干活的"工人"。
  • Warp Scheduler:负责调度线程束。每个SM有4个,可以同时调度4个warp。
  • Register File:寄存器文件,容量巨大。A100每个SM有65536个32位寄存器。
  • Shared Memory:共享内存,可编程的L1缓存。A100是192KB,可以配置成L1或Shared。
  • Tensor Cores:专门做矩阵乘法的硬件单元。搞AI训练的同学,这个你们最熟。

我踩过的坑: 有一次我写了一个kernel,每个线程用了太多寄存器(超过128个),结果occupancy从100%掉到了25%。性能直接腰斩。后来我强制编译器用local memory,虽然慢了,但occupancy上来了,整体反而更快。嗯,这就是个trade-off。

1.3 Warp——GPU调度的基本单位

Warp是32个线程组成的集合。这是GPU执行指令的最小单位。

关键点:

  • 一个warp里的32个线程执行同一条指令(SIMT模型)
  • 如果线程走不同的分支(if-else),就会发生warp divergence
  • warp divergence会导致部分线程被"屏蔽",性能下降

实战建议: 我写代码时,会刻意让同一个warp里的线程处理相邻的数据。这样不仅能避免分支发散,还能利用内存合并访问。你想想看,32个线程读连续的内存地址,一次就能搞定,多爽。

1.4 Memory Hierarchy——GPU的"存储金字塔"

GPU的内存层次,说白了就是"越快的越贵,越贵的越小"。

存储层次 大小 带宽 延迟 作用域
寄存器 256KB/SM ~20TB/s 1 cycle 线程私有
共享内存 48-164KB/SM ~10TB/s ~20 cycles Block内共享
L1缓存 128KB/SM ~5TB/s ~30 cycles SM内
L2缓存 6-40MB ~2TB/s ~200 cycles 芯片全局
全局内存 16-80GB ~1.5TB/s ~400 cycles 所有线程

注意: 全局内存的延迟是寄存器的400倍!所以,尽量减少全局内存访问。我见过有人把循环里的变量声明在全局内存里,结果性能惨不忍睹。记住:能用寄存器就别用共享内存,能用共享内存就别用全局内存。

1.5 CUDA线程层次结构:Grid、Block、Thread

CUDA的线程模型是分层的。说白了就是:

  • Thread:最底层的工作单元,每个线程执行一个任务
  • Block:一组线程,共享共享内存,可以同步
  • Grid:一组Block,构成一个完整的kernel

为什么会这样设计?因为GPU的硬件就是这样映射的:

  • 一个Block映射到一个SM
  • Block里的线程被分成warp执行
  • 多个Block可以同时运行在同一个SM上(取决于资源)
// 典型的CUDA kernel启动
dim3 gridDim(16, 16);    // 16x16 = 256个Block
dim3 blockDim(32, 32);   // 32x32 = 1024个线程/Block

myKernel<<<gridDim, blockDim>>>(d_data, N);

// 在kernel内部获取索引
__global__ void myKernel(float* data, int N) {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    // 或者用2D索引
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
}

我的经验: Block大小选256或512通常比较稳妥。太小了SM利用率低,太大了寄存器不够用。我一般从256开始试,然后看occupancy,再调整。

1.6 Kernel Launch机制——GPU到底怎么执行你的代码?

Kernel Launch看起来就是一行代码,但背后发生的事情可不少:

  1. CPU端准备:把数据从CPU拷贝到GPU(cudaMemcpy)
  2. Kernel提交:CPU把kernel函数和参数提交到GPU的命令队列
  3. GPU调度:GPU的GigaThread引擎把Block分配到SM上
  4. Warp调度:SM上的warp scheduler开始调度warp执行
  5. 指令发射:每个cycle,warp scheduler选择一个ready的warp发射指令
  6. 结果写回:执行完成后,数据写回全局内存
  7. 同步:CPU等待GPU完成(cudaDeviceSynchronize)

避坑指南: 我曾经犯过一个错误——在循环里反复launch小kernel。每次launch都有几百微秒的开销,结果性能还不如在CPU上跑。后来我把多个小kernel合并成一个,性能提升了10倍。记住:kernel launch不是免费的!

1.7 实战:如何用occupancy API优化配置

CUDA提供了occupancy API,可以帮你计算最优的block大小和shared memory用量。

// 计算最优block大小
int minGridSize, blockSize;
cudaOccupancyMaxPotentialBlockSize(
    &minGridSize, 
    &blockSize, 
    myKernel, 
    0,  // dynamic shared memory size
    0   // block size limit (0 = no limit)
);

// 或者指定shared memory用量
size_t dynamicSmem = 48 * 1024;  // 48KB
cudaOccupancyMaxPotentialBlockSize(
    &minGridSize, 
    &blockSize, 
    myKernel, 
    dynamicSmem, 
    0
);

printf("Recommended block size: %d\n", blockSize);
printf("Min grid size: %d\n", minGridSize);

核心要点: occupancy不是越高越好。有时候occupancy 50%但每个线程用更多寄存器,反而比occupancy 100%但寄存器溢出到local memory要快。我一般会跑几个配置对比一下,选最快的那个。

1.8 本章小结

好了,这一章我们讲了GPU的硬件架构和CUDA编程模型。说白了就是三件事:

  • 硬件:SM是核心,warp是调度单位,内存有层次
  • 线程模型:Grid→Block→Thread,映射到硬件上
  • Kernel Launch:不是免费的,要合理配置

下一章我们会深入讲CUDA内存管理,包括各种内存的用法和优化技巧。到时候我会分享一些实际项目中的调优案例,保证让你收获满满。

想要更多资料,联系可可