3、CUDA编程模型:Kernel启动、Grid-Block-Thread映射、线程束执行
好,咱们今天聊聊CUDA编程模型里最核心的三个概念:Kernel怎么启动、线程怎么映射、以及线程束怎么执行。这三个东西搞明白了,你写CUDA代码就有底了。
我个人习惯把CUDA编程模型想象成一个「三层指挥体系」。你想想看,CPU是总司令,GPU是前线部队。总司令(CPU)下命令,前线部队(GPU)去执行。这个命令,就是Kernel。
3.1 Kernel启动:CPU给GPU下的一道命令
Kernel说白了就是一个在GPU上执行的函数。写法上跟C函数差不多,但前面要加个 __global__ 修饰符。调用的时候,用三括号语法 <<<grid, block>>> 指定启动配置。
// 定义一个Kernel
__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];
}
}
// CPU端调用
int main() {
// ... 数据准备 ...
vecAdd<<<256, 256>>>(d_A, d_B, d_C, N);
// ... 同步、结果回传 ...
}
这里有个坑,我曾经踩过。三括号里的参数顺序是 <<<gridDim, blockDim, sharedMem, stream>>>。很多人只写前两个,第三个共享内存大小默认是0,第四个流默认是0。但如果你要用动态共享内存,第三个参数必须显式指定。我刚开始写的时候忘了这茬,结果数据对不上,排查了半天。
cudaDeviceSynchronize() 同步一下。
3.2 Grid-Block-Thread映射:谁在哪儿干活
好,现在命令发出去了。GPU怎么分配人手?这就涉及到Grid、Block、Thread三层结构。
你可以这样理解:
- Grid:整个任务,相当于一个工程队
- Block:工程队里的班组,每个班组有自己的一亩三分地
- Thread:班组里的工人,每个人干具体的活
每个线程都有一个唯一的ID,通过 threadIdx 和 blockIdx 组合计算出来。公式很简单:
int globalThreadId = threadIdx.x + blockIdx.x * blockDim.x;
如果是二维或三维的,稍微复杂一点:
// 二维Grid,二维Block
int blockId = blockIdx.x + blockIdx.y * gridDim.x;
int threadId = blockId * (blockDim.x * blockDim.y)
+ threadIdx.y * blockDim.x + threadIdx.x;
嗯,这里要注意。映射方式直接影响访存效率。我个人习惯尽量用一维映射,除非处理图像或矩阵这种天然二维的数据。为什么?因为一维映射的地址计算简单,不容易出错,而且对编译器优化更友好。
3.3 线程束执行:GPU真正的执行单元
说到线程束,这才是GPU执行的最小单位。你写代码时感觉是每个线程独立执行,但实际上,GPU是以32个线程为一组(一个Warp)来调度和执行的。
为什么会这样?因为GPU的设计哲学是「大量线程,简单控制」。它把32个线程打包成一个Warp,共享同一个指令单元。也就是说,一个Warp里的32个线程,在同一时刻执行同一条指令。
这就引出了一个经典问题:线程束发散(Warp Divergence)。
// 不好的写法:线程束发散
if (threadIdx.x % 2 == 0) {
// 偶数线程走这里
C[idx] = A[idx] + B[idx];
} else {
// 奇数线程走这里
C[idx] = A[idx] * B[idx];
}
// 好的写法:避免发散
C[idx] = (threadIdx.x % 2 == 0) ? (A[idx] + B[idx]) : (A[idx] * B[idx]);
第一个例子,同一个Warp里的线程,有的走if分支,有的走else分支。GPU没办法同时执行两条不同的指令,只能先执行if分支(偶数线程干活,奇数线程闲着),再执行else分支(奇数线程干活,偶数线程闲着)。性能直接砍半。
第二个例子用了三元运算符,虽然逻辑一样,但编译器有可能优化成无分支的代码。当然,最理想的情况是让同一个Warp里的线程走同一个分支。
我记得有一次优化一个归约(Reduction)算子,性能死活上不去。后来用nvprof一分析,发现线程束发散率高达40%。改成分块归约,每个Warp独立处理一块数据,发散率降到5%以下,性能提升了3倍多。
3.4 一张图看懂CUDA执行模型
下面这张SVG图,把Kernel启动、线程映射、线程束执行串起来了。你可以对照着看,理解会更直观。
3.5 几个实战建议
讲完理论,给几个我实际项目中总结出来的建议:
- Block大小怎么选? 我个人习惯用128或256。太小了(比如32)会导致SM利用率低;太大了(比如1024)又受限于寄存器资源。256是个比较平衡的值。
- Grid大小怎么算? 保证总线程数大于等于数据量。比如处理100万个元素,block用256,那grid就是
ceil(1e6 / 256) = 3907。 - 别忘了边界检查。 总线程数可能比数据量大,多出来的线程要直接return,别让他们访问越界内存。
- 用Occupancy API辅助决策。
cudaOccupancyMaxPotentialBlockSize这个函数能帮你算出当前硬件下最优的block大小,省得自己瞎猜。
好了,这一章的内容就这些。Kernel启动、线程映射、线程束执行,这三个概念是CUDA编程的基石。搞懂了它们,后面的优化技巧才能用得上。
想要更多资料,联系可可