第一章:CUDA编程模型回顾——从线程到内存,把GPU算力榨干

各位同学,欢迎来到《CUDA算子开发进阶》。

我是这门课的主讲。做了十年GPU计算,踩过的坑比写过的代码还多。今天咱们先不急着写算子,把地基打牢。

第一章,说白了就是一次“回锅”。把CUDA编程模型里最核心的三个东西——线程层次、内存层次、核函数写法——重新捋一遍。我保证,哪怕你觉得自己已经懂了,这一章也能挖出点新东西。

1.1 线程层次结构:Grid、Block、Thread

先问个问题:GPU为什么快?

因为它能同时跑成千上万个线程。但这么多线程,总得有个组织方式吧?

CUDA的组织方式很简单:Grid → Block → Thread,三层结构。

  • Thread:最基础的执行单元。每个线程执行相同的核函数,但处理不同的数据。
  • Block:一组线程的集合。同一个Block里的线程可以协作——共享内存、同步、通信。
  • Grid:所有Block的集合。一个Grid对应一次核函数调用。

我刚开始学的时候,总觉得这玩意儿就是个概念。直到有一次写一个矩阵乘法,Block大小设成32×32,结果性能死活上不去。后来发现,Block里的线程数太少,SM(流多处理器)的资源没喂饱。嗯,这里要注意:Block大小不是随便选的。

核心原则:

  • Block的线程数最好是32的倍数(warp大小)
  • 一般建议128~512个线程/Block
  • Grid的维度取决于数据量,但别让Block太少,否则SM空闲

举个例子,你要处理一个1024×1024的矩阵,每个线程处理一个元素:

dim3 blockDim(16, 16);   // 每个Block 256个线程
dim3 gridDim(64, 64);    // 总共4096个Block
kernel<<<gridDim, blockDim>>>(d_data);

为什么Block用16×16?因为256个线程刚好能喂饱一个SM上的资源,又不至于让寄存器压力太大。我个人的习惯是,先试32×32(1024线程),如果寄存器溢出就降下来。

小技巧:用 cudaOccupancyMaxPotentialBlockSize 这个API,它能帮你算出当前硬件上最优的Block大小。省得你一个个试。

1.2 内存层次结构:Global、Shared、Local、Register

GPU的内存,说白了就是“越近越快,越远越慢”。但很多人只记住了这句话,没搞懂怎么用。

内存类型 位置 速度 作用域 生命周期
Register SM内部 最快(~1 cycle) 单个线程 核函数执行期间
Shared Memory SM内部 快(~5 cycle) 单个Block Block生命周期
Local Memory 显存中 慢(~100 cycle) 单个线程 核函数执行期间
Global Memory 显存中 最慢(~400 cycle) 所有线程 程序生命周期

你看这张表,Register最快,但数量有限。每个线程大概能用255个(具体看架构)。Shared Memory也快,但一个Block通常只有48KB~96KB。Global Memory最慢,但容量最大。

我曾经犯过一个低级错误:把一个频繁访问的变量忘了加 __shared__,结果每次循环都去读Global Memory。性能直接掉了3倍。你想想看,一个循环里读100次Global Memory,跟读1次Shared Memory再反复用,差距有多大?

避坑指南:

  • 别把大数组声明成局部变量——它会自动落到Local Memory里,速度跟Global一样慢
  • Shared Memory用多了会减少每个SM能同时运行的Block数,这叫“占用率下降”
  • Register溢出到Local Memory时,编译器不会报错,但性能会暴跌。用 --ptxas-options=-v 编译选项可以查看寄存器使用量

1.3 核函数编写基础

核函数,就是跑在GPU上的函数。写法跟C函数差不多,但有几个硬规矩:

  • 用 __global__ 修饰
  • 返回类型必须是 void
  • 不能有可变参数(比如 ...)
  • 不能是类的成员函数(静态成员函数可以)

来,看个最简单的例子——向量加法:

__global__ void vecAdd(float* A, float* B, float* C, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

// 调用
int N = 1 << 20;
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

这里有个细节:if (idx < N) 这个边界检查不能省。因为数据量不一定能被Block大小整除,最后一个Block可能有多余的线程。我见过有人偷懒不写,结果数组越界,数据写到了别人的内存里——调试了一整天。

核函数编写要点:

  • 用 threadIdx、blockIdx、blockDim 计算全局索引
  • 总是做边界检查
  • 尽量减少Global Memory访问,多用Shared Memory
  • 避免线程发散(同一个warp里的线程走不同分支)

说到线程发散,我再多嘴一句。warp是GPU调度的基本单位,32个线程一起执行。如果这32个线程里,有的走if分支,有的走else分支,那它们就得串行执行。性能直接砍半。所以写核函数时,尽量让同一个warp里的线程走相同的路径。

个人经验:我写核函数时,习惯先把数据从Global Memory搬到Shared Memory里,然后在Shared Memory里做计算,最后再写回Global Memory。这样能减少90%的Global Memory访问。代价是多写几行代码,但性能提升是实打实的。

本章知识体系

下面这张图,把本章的核心逻辑串起来了。你可以把它当成一张“地图”,以后写算子时对照着看。

CUDA编程模型核心结构 Grid(网格) 一次核函数调用 = 一个Grid 包含多个Block,每个Block处理一部分数据 维度:gridDim.x, gridDim.y, gridDim.z Block(线程块) 一组线程的集合,共享Shared Memory 可以同步(__syncthreads()) 维度:blockDim.x, blockDim.y, blockDim.z Thread(线程) 最小编程单元,每个线程有独立寄存器 索引:threadIdx.x, threadIdx.y, threadIdx.z 全局索引 = threadIdx + blockIdx * blockDim ← Global Memory ← Shared Memory ← Register / Local Memory

这张图从左到右,从上到下,就是CUDA编程模型的完整脉络。你写任何算子,都逃不出这个框架。

好了,第一章就到这里。内容不多,但都是硬通货。下一章我们会深入Shared Memory的优化技巧——那才是真正拉开性能差距的地方。

想要更多资料,联系可可