第一章:CUDA编程模型回顾
各位同学好,我是你们的老朋友。今天咱们聊聊CUDA编程模型的基础。说实话,这部分内容看起来有点枯燥,但它是后面所有算子融合技巧的根基。我见过太多人一上来就搞融合优化,结果连线程怎么调度都没搞明白——嗯,那基本就是在碰运气。
GPU架构概览:从硬件角度看CUDA
先说说GPU的硬件架构。你想想看,CPU和GPU最大的区别是什么?CPU是「少而精」,GPU是「多而糙」。CPU有十几个大核,每个核都配了大缓存、复杂控制单元;GPU呢?成百上千个小核,每个核都很简单,但堆在一起就是一股洪流。
以NVIDIA的Ampere架构为例,一个GA102芯片包含7个GPC(图形处理簇),每个GPC里有12个SM(流式多处理器)。每个SM又包含128个CUDA核心。算下来,一个芯片就有超过1万个CUDA核心。我刚开始接触这个数字时也吓了一跳——1万个核心同时干活,那是什么概念?
关键概念:SM(Streaming Multiprocessor)是GPU执行计算的基本单元。每个SM有自己的共享内存、寄存器文件、调度器和执行单元。说白了,SM就是GPU里的「小CPU」。
这里有个我踩过的坑:很多人以为CUDA核心越多,程序就一定越快。其实不然。我曾经优化过一个矩阵乘法,在V100上跑得飞快,换到T4上反而慢了——因为T4的SM数量少,但每个SM的线程调度能力更强。所以,了解你的目标GPU架构,比盲目堆核心数重要得多。
线程层次结构:Grid、Block、Thread
CUDA的线程模型,说白了就是三级结构:Grid(网格)→ Block(线程块)→ Thread(线程)。
- Thread:最基础的执行单元,每个线程执行相同的kernel函数
- Block:一组线程,可以协作(通过共享内存和同步操作)
- Grid:一组Block,构成整个计算任务
为什么要有这种层次结构?我个人的理解是:这是为了适配GPU的硬件架构。Block对应SM,一个Block里的线程在同一个SM上执行,可以高效共享数据。Grid对应整个GPU,不同Block可以在不同SM上并行执行。
实战技巧:Block的大小通常设为32的倍数(warp大小)。我习惯用128或256,太小了SM利用率低,太大了寄存器压力大。具体选多少,得看你的kernel用了多少寄存器。
我记得有一次优化一个图像处理kernel,把Block大小从64改成128,性能直接翻倍。为什么?因为SM里同时运行的warp更多了,内存延迟被更好地隐藏了。你想想看,当某个warp在等数据时,SM可以切换到另一个warp执行——这就是所谓的「零开销上下文切换」。
内存层次结构:从全局到局部
GPU的内存层次,是性能优化的核心战场。我常说一句话:「优化CUDA程序,本质上就是在优化数据流动。」
| 内存类型 | 位置 | 大小 | 延迟 | 作用域 |
|---|---|---|---|---|
| 全局内存 | 显存 | GB级别 | ~400周期 | 所有线程 |
| 共享内存 | SM内部 | ~48KB/SM | ~5周期 | Block内 |
| 寄存器 | SM内部 | ~256KB/SM | ~1周期 | 单个线程 |
| 常量内存 | 显存(带缓存) | 64KB | ~10周期 | 所有线程 |
| 纹理内存 | 显存(带缓存) | 显存大小 | ~100周期 | 所有线程 |
看到这个表格,你可能会问:为什么共享内存只有几十KB,却这么重要?我举个例子:假设你要做矩阵乘法,每个线程需要读取A矩阵的一行和B矩阵的一列。如果直接从全局内存读,每个线程都要访问全局内存——带宽很快就用完了。但如果把数据先加载到共享内存,Block内的线程共享这些数据,全局内存的访问次数就大大减少了。
避坑指南:我曾经在一个项目里,把共享内存用到了极致——每个Block用了48KB。结果发现性能反而下降了。为什么?因为SM里能同时运行的Block数量减少了。共享内存是有限的,用多了会降低并行度。这个平衡点,需要反复调参才能找到。
CUDA程序编译流程
最后聊聊编译流程。很多人写CUDA代码,写完直接nvcc一敲就完事了。但了解编译过程,能帮你更好地理解性能问题。
CUDA的编译流程大致是这样的:
- 预处理:处理宏定义、头文件包含等
- 设备代码编译:nvcc把kernel代码编译成PTX(并行线程执行)中间表示
- PTX优化:编译器对PTX进行优化(指令重排、寄存器分配等)
- PTX到机器码:根据目标GPU架构,把PTX编译成cubin二进制
- 主机代码编译:主机端的C++代码由系统编译器(gcc/clang)编译
- 链接:把设备代码和主机代码链接成最终的可执行文件
这里有个关键点:PTX是中间表示,它不依赖具体的GPU架构。所以你可以用同一个PTX文件,在不同代际的GPU上运行——只要驱动能把它翻译成对应的机器码。我个人的习惯是,在编译时指定多个架构:
nvcc -arch=sm_70 -code=sm_70,sm_75,sm_80 mykernel.cu -o mykernel
这样生成的二进制包含多个架构的代码,运行时驱动会自动选择最合适的版本。嗯,虽然编译出来的文件会大一些,但兼容性更好。
小技巧:如果你想知道kernel到底用了多少寄存器,可以在编译时加 --ptxas-options=-v 参数。它会输出寄存器使用量、共享内存使用量等信息。我每次调优都会看这个输出,非常有用。
好了,这一章的内容就到这里。CUDA编程模型虽然基础,但它是后面所有优化技巧的基石。你想想看,如果连线程怎么组织、数据怎么流动都不清楚,谈何算子融合?下一章我们会深入讨论「融合策略」——那才是真正有意思的部分。
想要更多资料,联系可可