GPU计算基础:CUDA编程与GPU架构

要玩转分子生成训练,GPU你不是用着,而是得“懂”它。我刚开始接触CUDA时,以为把for循环扔到GPU上就完事了,结果跑了三天三夜还没收敛……后来才明白,不懂架构的调优就是瞎蒙。

1. GPU架构概览——为什么它那么快

先看一张我经常画给新同事的手绘图。它解释了一个核心问题:为什么GPU能同时处理上千个线程?

GPU计算架构核心组件 CPU GPU SM 0 32 CUDA Cores SM 1 32 CUDA Cores SM 2 32 CUDA Cores SM 3 32 CUDA Cores 每 SM 共享内存 (L1 缓存 + Shared Memory) 全局内存 (GDDR6) 线程块 (Block) 线程块 (Block) 线程块 (Block) 说明 • GPU = 多个 SM • 每 SM 含 CUDA Core • 共享内存可编程

这张图藏着几个关键点:

  • SM(Streaming Multiprocessor):GPU 的最小计算单元,每 SM 有多个 CUDA Core。我用的 A100 每 SM 有 64 个 FP32 Core。
  • 共享内存(Shared Memory):每 SM 独享,延迟远低于全局内存。但只有几十 KB,得精打细算。
  • 线程块(Block):分配到某个 SM,块内线程能共享共享内存并进行同步。

一个小经验:我调分子生成网络时,刚开始把所有参数都放全局内存里,结果带宽瓶颈严重。后来把一些频繁访问的权重塞到共享内存里,迭代速度提升了 30%。

2. CUDA 线程模型与内存层次

写 CUDA 你得理解三个层次:Grid(网格)→ Block(线程块)→ Thread(线程)。每个线程都有自己独一无二的 ID,靠它来区分处理哪部分数据。

看一个最简单的向量加法 kernel,我猜你没写过也见过:

__global__ void vecAdd(float *a, float *b, float *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

这里 blockIdx.x 是块索引,threadIdx.x 是块内线程索引,blockDim.x 是每块线程数。组合起来就得到全局索引。

为什么这么设计?说白了就是让成千上万个线程能并行跑,每个线程只管自己那一小块数据。

内存类型作用域速度典型大小
全局内存所有线程~200 GB/s 带宽几 GB ~ 80 GB
共享内存单个 Block~1 TB/s 带宽48~164 KB / SM
寄存器单个 Thread零延迟256 / SM(每个 SM)
局部内存单个 Thread同全局内存由编译器决定

你想想看,如果你把线程内频繁用到的变量不加限制地声明,可能就被编译器推到局部内存里了。局部内存其实在全局内存空间,速度慢很多。我曾经遇到过核函数里声明了一个大数组,结果性能直接腰斩。解决方案?改成动态分配,或者尽量用寄存器变量(__shared__ 不行,那就用普通局部变量但长度小一点)。

注意:共享内存虽然快,但太小。而且同一 warp 内的线程访问共享内存时如果发生 bank conflict(比如多个线程访问同一个 bank 的不同地址),性能会下降。我调一个分子力场计算时,就因为共享内存 bank conflict 导致 occupancy 高但实际性能低,后来把数据排成 float2 结构对齐才解决。

3. 性能考量与调优方向

调优 GPU 程序,我习惯从三个维度找瓶颈:

  1. Occupancy – 每个 SM 活跃的 Warp 数量。占有率越高,隐藏延迟能力越强。
  2. 内存带宽利用率 – 全局内存访问是否合并(coalesced)?比如 float4 一次读 16 字节比四个单独读快得多。
  3. 计算与访存比 – 计算密集还是访存密集?分子生成里能量损失计算通常是访存瓶颈,而反向传播可能是计算瓶颈。

举个例子,如果你想计算 occupancy,可以用 NVIDIA 的 cuda-occupancy-calculator 工具,或者直接在代码里调用 cudaOccupancyMaxActiveBlocksPerMultiprocessor:

int minGridSize, blockSize;
cudaOccupancyMaxPotentialBlockSize(&minGridSize, &blockSize,
    vecAdd, 0, n);
// blockSize 就是建议的线程块大小
// 然后在 kernel 启动时用这个 blockSize

我个人习惯在训练循环里动态调整 blockSize,因为不同层次的网络层,计算密度不一样。拿分子生成模型来说,注意力层和 MLP 层的线程需求就不同。

一条调优经验:把 blockSize 设成 32 的倍数(比如 128、256),而且最好不超过 1024。记不清了?就设 256。大部分情况它都不是最差的。

好了,GPU 的基础架构和 CUDA 编程要点就聊这么多。你大概心里有数了:CPU 负责控制流,GPU 负责数据并行。SM 是计算元,共享内存是加速利器,线程块是调度单位。后面我们会把这一套用在实际分子生成训练里,比如怎么把分子图数据塞进 GPU,怎么用 CUDA 加速消息传递层。嗯,先消化这些吧。