2. 硬件架构基础
各位同学,欢迎来到第二章。这一章我们聊聊硬件。我知道很多人一听到“硬件架构”就头大,觉得这是搞芯片设计的人该关心的事。但说实话,你写AI训练算子,如果不懂GPU怎么干活,那优化基本靠蒙。
我刚开始做算子优化那会儿,就犯过这种错。写了个看起来很漂亮的kernel,结果跑起来比PyTorch原生还慢。后来一分析,发现是全局内存访问模式太乱,把带宽全浪费了。嗯,从那以后,我老老实实把GPU架构手册翻了三遍。
2.1 GPU/加速器架构概览
先看一张整体架构图,帮你建立直观印象。
这张图展示的是现代GPU的典型层次结构。最下面是全局内存,也就是我们常说的显存。往上走是L2缓存,再往上就是一个个GPC(图形处理簇)。每个GPC里包含多个TPC,每个TPC里又有两个SM(流式多处理器)。
SM才是真正干活的地方。每个SM内部有Warp调度器、寄存器文件、共享内存,还有一堆计算单元。你写的CUDA kernel,最终就是被拆成一个个warp,在这些SM上执行的。
核心要点:GPU之所以能并行处理成千上万个线程,靠的就是这种多级并行架构。GPC之间可以并行,TPC之间可以并行,SM之间也可以并行。理解了这个层次,你才能知道数据该放哪里、线程该怎么组织。
2.2 内存层次结构
内存层次是算子优化的核心。我个人习惯把GPU内存分成三个层级:全局内存、共享内存、寄存器。每个层级的速度和容量天差地别。
| 内存类型 | 容量 | 延迟 | 作用域 | 典型用途 |
|---|---|---|---|---|
| 全局内存 | GB级别 | ~400-800周期 | 所有线程 | 存储输入数据、权重、输出 |
| 共享内存 | 几十KB/SM | ~20-30周期 | 单个Block内 | 数据复用、线程间通信 |
| 寄存器 | 几百KB/SM | ~1周期 | 单个线程 | 局部变量、中间结果 |
2.2.1 全局内存
全局内存是GPU里最大的存储区域,所有线程都能访问。但代价是延迟极高——一次全局内存访问可能要等几百个时钟周期。
我在项目中遇到过最典型的坑:写了一个矩阵乘法,每次计算都从全局内存读数据。结果计算只用了几个周期,等数据却等了上百个周期。性能惨不忍睹。
避坑指南:我曾经以为只要把数据放到全局内存就完事了。后来发现,全局内存的访问模式比容量更重要。连续访问(合并访问)能充分利用带宽,随机访问则会让性能暴跌。记住:合并访问是全局内存优化的第一原则。
2.2.2 共享内存
共享内存是每个SM内部的一块高速缓存,同一个Block内的线程可以共享。它的延迟比全局内存低一个数量级。
你想想看,如果多个线程需要反复读取同一块数据,把它从全局内存搬到共享内存,是不是比每次都去全局内存读要快得多?这就是共享内存的核心用法——数据复用。
// 典型用法:将数据从全局内存加载到共享内存
__global__ void kernel(float* input, float* output, int N) {
__shared__ float s_data[256]; // 共享内存声明
int tid = threadIdx.x;
int idx = blockIdx.x * blockDim.x + tid;
// 从全局内存加载到共享内存
if (idx < N) {
s_data[tid] = input[idx];
}
__syncthreads(); // 同步,确保所有线程加载完成
// 在共享内存上做计算
// ... 计算逻辑 ...
// 写回全局内存
if (idx < N) {
output[idx] = s_data[tid];
}
}
个人经验:共享内存的大小是有限的。以A100为例,每个SM只有192KB共享内存。如果你的Block需要更多,就得考虑分块处理。我一般建议:共享内存能省则省,但该用的时候别犹豫。
2.2.3 寄存器
寄存器是速度最快的存储,延迟只有1个周期。每个线程都有自己的寄存器,其他线程无法访问。
但寄存器也是稀缺资源。每个SM的寄存器总数是固定的,比如A100每个SM有65536个32位寄存器。如果每个线程用太多寄存器,SM能同时容纳的线程数就会减少,这会降低并行度。
为什么会这样?因为寄存器是分给线程的。假设一个SM有65536个寄存器,每个线程用32个,那这个SM最多同时容纳2048个线程。如果每个线程用64个,就只能容纳1024个了。
关键权衡:寄存器用多了,并行度下降;用少了,可能频繁访问全局内存。我个人的经验是:先保证计算正确,再逐步减少寄存器使用量,找到性能和并行度的平衡点。
2.3 计算单元与线程调度
说完了存储,我们聊聊计算。GPU的计算核心是SM里的CUDA Core和Tensor Core。CUDA Core负责通用计算,Tensor Core专门加速矩阵运算。
2.3.1 Warp与线程调度
GPU执行线程的最小单位是warp。一个warp包含32个线程,它们在同一时刻执行同一条指令。这就是所谓的SIMT(单指令多线程)模型。
你想想看,如果warp里的32个线程走了不同的分支(if-else),会发生什么?嗯,GPU会串行执行所有分支,导致性能下降。这就是warp divergence问题。
// 避免warp divergence的写法
// 不好的写法:warp内线程走不同分支
if (threadIdx.x % 2 == 0) {
// 偶数线程执行
} else {
// 奇数线程执行
}
// 好的写法:将数据重新组织,让同一warp走相同分支
// 或者用三元运算符替代分支
float result = (threadIdx.x % 2 == 0) ? a : b;
我曾经踩过的坑:写了一个softmax kernel,里面有个if-else判断边界条件。结果发现warp divergence导致性能只有理论值的1/3。后来我把边界判断单独拎出来,用mask的方式处理,性能直接翻倍。
2.3.2 线程块与网格
线程的组织方式也很重要。一个Block内的线程可以共享共享内存,也可以同步。不同Block之间则完全独立。
Block的大小会影响SM的利用率。太小了,SM吃不饱;太大了,可能超过SM的资源限制。我一般建议Block大小设为256或512,这是经过大量实践验证的黄金值。
| 参数 | 建议值 | 说明 |
|---|---|---|
| Block大小 | 128-512 | 太小则SM利用率低,太大则资源受限 |
| Grid大小 | SM数量的2-4倍 | 保证SM有足够的任务可调度 |
| 每个SM的Block数 | 4-8个 | 隐藏内存访问延迟 |
2.3.3 Tensor Core
从Volta架构开始,NVIDIA引入了Tensor Core。这东西专门为矩阵乘法设计,一次可以执行4×4矩阵的乘加运算。对于AI训练来说,Tensor Core是性能的关键。
我建议你在写算子时,尽量让计算满足Tensor Core的要求。比如矩阵维度最好是8的倍数,数据类型用half或bfloat16。这样编译器会自动生成Tensor Core指令。
一句话总结:GPU优化的本质,就是让数据在合适的时间出现在合适的位置,让计算单元尽可能满负荷运转。全局内存管容量,共享内存管复用,寄存器管速度,Tensor Core管计算。把这四者的关系理清了,你的算子优化就成功了一半。
好了,这一章的内容就到这里。硬件架构的知识点比较多,但都是后续优化的基础。下一章我们开始讲具体的算子实现,到时候你会感谢今天花时间搞懂了这些底层细节。
课后思考:如果你现在要写一个矩阵乘法kernel,你会怎么安排数据在全局内存、共享内存和寄存器之间的流动?试着画一张数据流图,看看能不能找到优化的突破口。