4166 字
21 分钟
CUDA C编程入门笔记(2)

更复杂的线程结构(2D、3D)#

前面我们是这样用线程的:

int idx = threadIdx.x + blockIdx.x * blockDim.x;
// ...
kernel_add<<<block_cnt, block_size>>>(d_a, d_b, d_c, n);

这里可以看见,每个线程,我们有这两个坐标变量:

  • blockIdx,线程块在线程格内的索引
  • threadIdx,块内的线程索引

当执行一个核函数时,CUDA运行时会自动为每个线程,分配这两个坐标变量blockIdxthreadIdx

不过,上面的使用是一维的,实际上CUDA也支持二维、三维,这样可以方便处理2D的图像、3D的体积数据之类的。

前面说的这两个坐标变量,都是基于uint3定义的CUDA内置的向量类型,包含3个无符号整数。可以通过x,y,z三个字段来指定。

blockIdx.x
blockIdx.y
blockIdx.z
threadIdx.x
threadIdx.y
threadIdx.z

上面是内置的坐标变量,但是每个块有多少个线程、整个GPU有多少块,是由blockDimgridDim来决定的。同理,也有

blockDim.x // 线程块的维度(每个块有多少线程)
blockDim.y
blockDim.z
gridDim.x // 线程格的维度(有多少个块)
gridDim.y
gridDim.z

怎么使用呢,大致如下:

// 1D的写法
int block_size = 256;
int grid_size = (n + block_size - 1) / block_size;
kernel<<<grid_size, block_size>>>(...);
// 2D的写法
dim3 block(16, 16); // 每个块 16×16 = 256 个线程
dim3 grid(32, 32); // 32×32 = 1024 个块
kernel<<<grid, block>>>(...);
// 3D的写法
dim3 block(8, 8, 4); // 8×8×4 = 256 个线程
dim3 grid(10, 10, 10); // 10×10×10 = 1000 个块
kernel<<<grid, block>>>(...);

未使用的维度默认为1。

线程格和线程块:

GPU
└─ Grid 线程格
├─ Block 0 线程块
│ ├─ Thread 0
│ ├─ Thread 1
│ └─ Thread 2
├─ Block 1 线程
│ ├─ Thread 0
│ ├─ Thread 1
│ └─ Thread 2
└─ Block 2 线程块
└─ ...

虽然都可以用3D标号,但是有硬件限制:

Terminal window
// Grid的最大维度(不同GPU可能不同)
gridDim.x: 最大 2^31 - 1
gridDim.y: 最大 65535
gridDim.z: 最大 65535
// Block的最大维度
blockDim.x: 最大 1024
blockDim.y: 最大 1024
blockDim.z: 最大 64
// blockDim.x × blockDim.y × blockDim.z 1024
TIP

一个线程格,不代表一个GPU。

应该说,一个线程格 = 一次kernel调用创建的所有线程。

GPU是硬件,是物理存在的显卡。Grid是软件概念,每次调用kernel,就创建一个grid。一个GPU可以同时运行多个Grid(如果资源够),Grid运行完就消失了。

数据内存布局#

虽然线程数是可以用2D 3D的(逻辑上),但是显存是1D的(物理意义上)。

在前面我们提到过这个cudaMalloc函数,用于GPU分配空间。

cudaError_t cudaMalloc (void** devPtr, size_t size)

这个是在GPU显存上,申请一个size字节的连续内存,返回的是是否分配成功,然后分配的GPU显存的指针,会被存在devPtr指向的地址中。

这里分配的显存,是线性的,也就是1D的。

进一步的核函数#

函数限定符#

前面我们用__global__来定义了和函数。CUDA C中还有更多的函数限定符。

限定符在哪里执行谁能调用它备注
__global__GPUCPU(或GPU,计算能力≥3.0)必须返回void,这就是”核函数”
__device__GPU只能GPU调用GPU内部的辅助函数
__host__CPU只能CPU调用可省略,普通C函数

__device____host__ 可以同时使用,让同一个函数在CPU和GPU上都能用。

CUDA执行模型#

SM:流式处理器#

前面说了,当CPU调用一次核函数,比如

train_kernel<<<grid_size, block_size>>>(...)

这时候,我们要调用非常非常多的线程。而在调用时,<<<grid, block>>> 是一个软件代码里划分的层次,这里我们可以用二维或者三维来组织这些线程。

不过,在扔进显卡后,这些线程实际上还都是一维的,那么这时候是怎么对应的呢?

实际上,我们用一个Grid(线程格)来组织这么多的线程。而其中,一个Block(线程块)会被分配到一个指定的SM。

注意:是一个Block会被分配给一个SM,但一个SM可以容纳不止一个Block。

一个SM有这样的一些东西:

  1. CUDA核心
  2. 共享内存、一级缓存(这里共享内存,是每个block占用一份,不是SM上所有Block共享的。所谓共享,是同一个Block内的线程共享。)
  3. 寄存器文件
  4. 加载/存储单元
  5. 特殊功能单元
  6. 线程束调度器

一个家用级别的显卡,比如RTX3060,大概有28个SM。而例如A100这种专业的显卡,也只有108个SM左右。

那么,一个任务,会分配给几个SM呢?

先来看硬件上限。一个Block最多占用一个SM,不会跨SM,所以Grid有多少个Block,就最多能用多少个SM(当然也不能超过GPU的SM总数)。

这是上限,那实际呢?每个SM资源有限,主要是三样东西:寄存器数量、共享内存大小、以及 SM 支持的最大线程数。一个 Block 进来会占用这些资源,如果一个 SM 的资源还够,就可以再塞第二个 Block 进来同时跑,这叫做占用率(Occupancy)

所以实际用几个 SM,是由“Block 数量”和“每个 SM 能同时容纳几个 Block” 共同决定的,硬件调度器自动分配,人是控制不了具体哪个 Block 去哪个 SM。

如果都分配完了呢?

假设一个SM有 48KB 共享内存,每个 Block 申请 20KB,那这个 SM 只能同时容纳 2 个 Block(40KB),第 3 个 Block 塞不进来,就要等前面的 Block 执行完释放资源,才能进来。

如果所有 SM 都满了,剩下的 Block 就排队等待。

而且共享内存只是卡住的原因之一,寄存器也会卡。(比如超级无敌多的局部变量!)

SIMT架构、warp#

CUDA采用的是SIMT,也就是单指令多线程架构。和这个相对的是SIMD,单指令多数据架构。

这看上去比较难懂,对比一下可能比较好。

SIMD(Single Instruction, Multiple Data) 是 CPU 上的概念。一条指令,同时对多个数据做同样的操作。比如一条 AVX 指令可以同时加 8 个 float。但这 8 个数据必须打包在一起,用专门的寄存器,程序员(或编译器)要显式地去安排这件事。

举个例子,

for (int i = 0; i < 8; i++) {
c[i] = a[i] + b[i];
}

这看上去是循环加了 8 次,但编译器开了 -O2 优化之后,可能直接生成一条 AVX 指令,把 8 个 float 一次加完。没写任何特殊代码,但 SIMD 已经在用了。

SIMT(Single Instruction, Multiple Threads) 是 GPU 的概念。同样是一条指令操作多份数据,但包装方式不同——它伪装成”多个独立线程”,每个线程有自己独立的寄存器、自己的 PC 指针,你写代码的时候感觉像在写普通的单线程逻辑,硬件偷偷把 32 个线程捆成一个 Warp 一起跑。

还是举个例子,要对32个数各自+1,核函数是:

__global__ void add_one(int *a) {
int i = threadIdx.x;
a[i] = a[i] + 1;
}

这里写的是 第 i 个线程处理第 i 个数,每个线程是独立的逻辑,也就是单线程视角的代码。

但硬件实际干的是,把 Thread 0 到 Thread 31 捆成一个 Warp,同时对 32 个数据执行同一条加法指令。本质上和 SIMD 做的事一模一样。

但是这样就很显然,有一个问题——如果这32个线程,我写的不是顺序执行呢?比如中间有if,那他们有可能同时无法执行同样的指令。

这就是线程束分化(Warp Divergence),这是 SIMT 的核心痛点。

例如:

__global__ void kernel(int *a) {
int i = threadIdx.x;
if (i % 2 == 0) {
a[i] = a[i] + 1; // 偶数线程走这里
} else {
a[i] = a[i] * 2; // 奇数线程走这里
}
}

同一个 Warp 里,Thread 0、2、4… 要执行加法,Thread 1、3、5… 要执行乘法。但硬件要求 32 个线程同时执行同一条指令,怎么办?

硬件的解决方式是串行化

第一步:让偶数线程执行加法,奇数线程强制闲置(屏蔽掉)

第二步:让奇数线程执行乘法,偶数线程强制闲置(屏蔽掉)

如果我们再极端一点,假设一共有 nn 个分叉,总共有 2n2^n 种可能的指令,就在最坏情况下,需要 O(2n)O(2^n) 个时间周期。不过这是理论,因为一个warp只有32个线程,不可能有超过 32 条不同的路径同时出现。所以最坏情况不是 O(2n)O(2^n) ,而是 O(min(2n,32))O(\min(2^n, 32)) ,也就是最坏情况是 32 步串行执行。

NOTE

nn 比较小的时候(比如 n=1n=1 就是 2 条路径,n=2n=2 就是 4 条),2n2^n 是瓶颈。但 n 一旦超过 5,2n2^n 就超过 32 了,后面再多分支也不会更坏,因为线程数就这么多。

所以现实中的最坏情况是:Warp 里 32 个线程每个人走的路径都不一样,硬件要串行执行 32 次,每次只有 1 个线程在干活,其余 31 个闲置。效率变成 1/32,退化成完全串行了。

这也是为什么 GPU 并不适合分支密集、逻辑复杂的任务——这类任务交给 CPU 反而更合适。

在并行线程中,可能多个线程都要访问同一个数据,或者写入同一个数据。假设这是共享内存,那怎么办?

比如:

__global__ void kernel(int *a) {
a[0] = threadIdx.x; // 32个线程都往a[0]写
}

32 个线程同时写同一个地址,最终 a[0] 是多少?

不确定,取决于谁最后写进去,结果是随机的。

如果确实需要这么做的话,可以用原子操作。比如求和,可以这样写:

atomicAdd(&result, value);

原子操作保证每次只有一个线程在操作这个地址,操作完了下一个线程才能进来。这样结果是正确的,但代价是这些操作被串行化了,性能会下降。

算法优化#

避免竞争(以求和问题为例)#

前面说了,如果大量的原子操作,会串行化,就不优。

一般这时候,可以算法上来调整。

比如求和这个问题,真的需要32个数的和的话,怎么办呢?

朴素的写法就是顺序一个个加,需要32个时钟周期。但是我们毕竟有32个线程。我们可以在第一个时钟周期,1和2、3和4、5和6……这样两两线程负责相加。

第一轮加16次,假设都把各自的和存在奇数线程的存储位,比如a[1],a[3],a[5]...,然后第二轮,再同理,再各自两两相加,1和3,5和7……这样,最后一轮就在a[1]得到结果。

总共需要 log32=5\log 32=5 个时钟周期就可以了。

当然,这里涉及到一个技巧:我们一般不是这样奇偶交错相加的,我们一般会让1去加9,2去加10……8去加16。

为什么?

假设我们不是32个数相加,是128个数相加,这样占据了4个warp。

如果是奇偶相加,第一轮,我们所有warp都要干活。

如果是我们说的第二种做法——前一半去加后一半——那第一轮,GPU调度器就会聪明地发现,后面2个warp,完全不干活了,可以提前下班了!释放资源。

除此之外,连续内存读取,是连续的东西,也涉及合并内存访问,比随机访问更快。

循环展开#

举个例子:

for (int offset = 4; offset > 0; offset /= 2) {
partial += offset;
}

这个代码写出来后,这是循环。

但是我们知道,循环是比顺序结构更慢的。我们会尽可能地少用循环,比如把两个for合并成一个,或者干脆展开。

上面的代码就可以展开,我们很容易注意到,循环次数不是固定的吗?

直接展开:

partial += 4;
partial += 2;
partial += 1;

我们除了手动展开,也可以用#pragma unroll这个指令,让nvcc编译器,自动在编译的时候就在底层汇编里面展开。

#pragma unroll
for (int offset = 4; offset > 0; offset /= 2) {
partial += offset;
}

但是有一个问题啊——参考前面的求和代码:

// 雇佣了 512 个线程
int tid = threadIdx.x;
// 第一轮循环:步长是 256 (512的一半)
for (int stride = 256; stride > 0; stride /= 2) {
if (tid < stride) { // 只有编号小于 256 的线程才准干活!
idata[tid] += idata[tid + stride];
}
__syncthreads(); // 所有人必须等干活的人做完
}

虽然我们知道,没用的线程是可以释放啊。但是我们有一个__syncthreads(),也就意味着强制同步,即使上面那个 if 导致后一半的线程没进括号干活,它们也必须在这里死等前一半的人加完,才能进入下一轮循环。

既然我们知道,第一轮后面一半都在摸鱼,那直接第一步就开除了。

// 我们现在只雇佣 256 个线程了!网格大小直接砍半。
int tid = threadIdx.x;
// 在进入循环前,让 0 号线程去把 0 号和 256 号加起来;
// 让 1 号线程去把 1 号和 257 号加起来...
g_idata[idx] += g_idata[idx + blockDim.x];
__syncthreads();
// 此时,512 个数字已经被折叠成 256 个了。
// 接下来再舒舒服服地进入原先的循环。
for (int stride = 128; stride > 0; stride /= 2) {
// ... 原来的逻辑 ...
}
隐式同步#

同样是上面的,假设,随着循环不断进行,我们数据 32\leq 32 了!

这时候,全场只有编号 0 到 31 的这 32 个线程在干活了。

这 32 个线程,刚好组成了一个 Warp(线程束)。 GPU 硬件设计里有一个铁律(SIMT 架构):同一个 Warp 里的 32 个线程,在物理电路上是绑死在一起的!它们就像一条龙舟上的 32 个桨手,必须在同一个微秒、听同一个鼓点、挥动同一次船桨。

这意味着,线程 0 和线程 8 在执行上一轮的加法和写入时,是绝对在同一个时钟周期内完成的。(也就是说,根本不需要__syncthreads了!)

甚至可以直接删掉for

// 只让前 32 个线程进来
if (tid < 32) {
volatile int *vmem = idata;
// 以下 6 行代码,没有 for 循环,没有 __syncthreads() 互相等待
// 32 个线程靠着天然的硬件同步,像机关枪一样一口气打完!
vmem[tid] += vmem[tid + 32]; // 这是应对如果刚好剩 64 个数据的情况
vmem[tid] += vmem[tid + 16];
vmem[tid] += vmem[tid + 8];
vmem[tid] += vmem[tid + 4];
vmem[tid] += vmem[tid + 2];
vmem[tid] += vmem[tid + 1];
}

很容易注意到,这里有一个volatile

虽然 32 个线程动作是一致的,但现代编译器(nvcc)会优化,发现连续对 vmem[tid] 做 6 次加法,它会偷偷把中间结果存在各自私有的寄存器,然后一次性写回。

volatile就是强制要求,必须进内存的。

关于前面的,“根本不需要 __syncthreads() 了!甚至可以直接删掉 for… 32 个线程靠着天然的硬件同步,像机关枪一样一口气打完!”

这里还有一个问题。在2017年前,只是对的,但是在后面的GPU上不一定对。

过去的架构(Pascal及更早): 同一个Warp的32个线程共享同一个程序计数器(PC)。它们确实是绝对同步的(隐式同步),很多老旧的CUDA教程会教大家省略同步函数,直接用 volatile 来榨干性能。

现代架构(Volta及之后,如RTX 20/30/40系列): NVIDIA引入了独立线程调度(Independent Thread Scheduling)。这意味着同一个Warp中的每个线程现在都有自己的程序计数器和调用栈。虽然硬件依然倾向于让它们一起执行,但系统不再保证它们绝对同步。

正确的做法: 从CUDA 9.0开始,如果需要Warp内的线程进行同步,必须显式调用 __syncwarp()。在现代CUDA编程中,处理这种Warp级别的数据归约(Reduction),官方推荐使用 Warp Shuffle 指令(如 __shfl_down_sync),它甚至不需要通过共享内存或全局内存就能在寄存器之间直接交换数据,速度极快且完全安全。

CUDA C编程入门笔记(2)
https://fuwari.vercel.app/posts/course/cuda-c/cuda-2/
作者
wegret
发布于
2026-01-03
许可协议
CC BY-NC-SA 4.0