第三章:GPU架构基础——CUDA核心与Tensor Core、内存层次结构、Warp调度与SIMT模型、计算吞吐量与带宽
各位同学,欢迎来到第三章。这一章我们聊聊GPU的“骨架”和“血管”。
很多做AI加速的朋友,上来就调库、写算子,但一旦遇到性能瓶颈,就抓瞎了。为什么?因为不懂硬件在想什么。我当年刚入行时也是这样,调一个卷积调了三天,速度纹丝不动。后来才发现,是内存访问模式没对齐,数据在Global Memory里“散步”呢。
好,咱们今天就把GPU的底裤扒开看看。
3.1 CUDA核心与Tensor Core:算力的两个引擎
GPU里干活的主力,是CUDA Core和Tensor Core。你可以把它们想象成两个不同工种的工人。
3.1.1 CUDA Core:通用计算的“万金油”
CUDA Core是GPU里最基础的执行单元。每个CUDA Core都能做整数、浮点数的加减乘除,也能做逻辑判断。说白了,它就是一个小而全的CPU核心,只不过数量极多——一块A100上有6912个。
但要注意,CUDA Core是“单精度”的。它做一次FP32乘加(FMA)需要一个时钟周期。如果你需要双精度(FP64),那性能会骤降。我在项目中遇到过有人用FP64做训练,结果速度比FP32慢了32倍,嗯,这其实是个常见的坑。
关键点:CUDA Core擅长处理“不规则”的计算任务,比如分支多、数据依赖强的逻辑。但它的算力密度不如Tensor Core。
3.1.2 Tensor Core:AI加速的“特种兵”
Tensor Core是NVIDIA从Volta架构开始引入的专用硬件。它专门为矩阵乘法而生。你想想看,深度学习里90%的计算都是矩阵乘(GEMM)和卷积,Tensor Core就是为这个场景量身定做的。
一个Tensor Core可以在一个时钟周期内完成一个4x4矩阵的乘加运算。这是什么概念?如果用CUDA Core做,需要16个FMA指令,也就是16个周期。Tensor Core直接干到1个周期,吞吐量提升了16倍。
我个人习惯在训练和推理时,只要精度允许,一律用Tensor Core。比如FP16、BF16,甚至INT8。但要注意,Tensor Core对数据布局有要求——矩阵必须是“平铺”的,不能是乱序的。我曾经因为没对齐数据维度,导致Tensor Core根本没被启用,性能直接掉回CUDA Core水平。这个坑,你们一定要记住。
实战技巧:在PyTorch中,只要输入是FP16或BF16,并且矩阵维度是8的倍数,Tensor Core就会自动启用。你可以用 torch.cuda.amp 自动混合精度来触发。
3.2 内存层次结构:数据流动的“高速公路”
GPU的性能瓶颈,99%在内存,不在计算。为什么?因为计算单元跑得太快,数据根本喂不饱。这就好比一个顶级大厨,但配菜员走路太慢,菜送不过来。
GPU的内存分三层:Global Memory、Shared Memory、Local Memory(寄存器)。咱们一层层说。
3.2.1 Global Memory:大而慢的“仓库”
Global Memory就是显存。容量大(几十GB),但延迟高(几百个时钟周期)。所有线程都能读写它,但速度很慢。
这里有个关键概念:合并访问(Coalesced Access)。当同一个Warp里的32个线程访问连续的Global Memory地址时,硬件会把32次访问合并成一次大的突发传输。如果访问是随机的,那就得32次单独传输,性能直接掉到1/32。
我记得有一次优化一个算子,把数据从“行优先”改成“列优先”存储,速度就快了5倍。原因就是访问模式从随机变成了连续。
避坑指南:我曾经因为没注意Global Memory的对齐要求,导致每次读取都跨了两个缓存行,性能损失了30%。记住:尽量让每个线程访问的地址是128字节对齐的。
3.2.2 Shared Memory:小而快的“厨房操作台”
Shared Memory是每个线程块(Block)私有的。容量很小(通常几十KB),但延迟极低(几个时钟周期)。它相当于CPU的L1缓存,但由程序员手动管理。
Shared Memory最大的用处是数据复用。比如做矩阵乘法时,一个数据会被多个线程用到。如果每次都从Global Memory读,那就太慢了。正确的做法是:先把数据从Global Memory搬到Shared Memory,然后所有线程从Shared Memory读。
我建议你们把Shared Memory当成“临时工作区”。但要注意Bank Conflict——当多个线程同时访问Shared Memory的同一个Bank时,访问会串行化。解决办法是让访问地址错开,比如加一个padding。
关键公式:Shared Memory的带宽 = 32个Bank × 每个Bank的带宽。如果发生Bank Conflict,带宽会除以冲突数。
3.2.3 Local Memory(寄存器):最快但最稀缺
寄存器是每个线程私有的,速度最快(0延迟),但数量有限。每个SM(Streaming Multiprocessor)只有几十KB的寄存器文件,分给所有线程。
如果每个线程用的寄存器太多,就会发生寄存器溢出(Register Spill)——多余的变量被挤到Local Memory里。Local Memory其实是在Global Memory里划了一块区域,速度慢得离谱。
嗯,这里要注意:编译器有时候会自作聪明地分配很多寄存器。我建议你们用 --maxrregcount 选项限制每个线程的寄存器数量,避免溢出。
| 内存类型 | 容量 | 延迟 | 作用域 | 典型用途 |
|---|---|---|---|---|
| Global Memory | GB级 | ~400 cycles | 所有线程 | 存储输入/输出数据 |
| Shared Memory | 几十KB | ~5 cycles | 线程块内 | 数据复用、线程间通信 |
| 寄存器 | 每个SM几十KB | 0 cycles | 单个线程 | 局部变量、中间结果 |
3.3 Warp调度与SIMT模型:32个线程一起走
GPU的执行单位是Warp——32个线程组成一个Warp。这32个线程执行同一条指令,但处理不同的数据。这就是SIMT(Single Instruction, Multiple Threads)模型。
你想想看,这就像32个人排成一排,教官喊“向左转”,所有人必须同时向左转。如果有人想向右转,那他就得等其他人转完,再单独转。这就是Warp Divergence。
我在项目中遇到过最典型的例子:一个if-else分支,一半线程走if,一半线程走else。结果Warp先执行if分支(另一半线程被屏蔽),再执行else分支(另一半线程被屏蔽)。总执行时间翻倍。
优化建议:尽量让同一个Warp里的线程走相同的分支。如果分支不可避免,可以用“谓词化”技术——把分支转换成条件赋值,避免Warp Divergence。
Warp调度器是SM里的一个硬件单元。它负责从多个Warp中选一个,发射指令。为什么需要调度?因为当某个Warp在等数据(比如从Global Memory读数据)时,调度器会立刻切换到另一个Warp,让计算单元不闲着。这就是延迟隐藏。
我个人习惯把Warp数量设置得足够多,比如每个SM至少64个Warp。这样当一部分Warp在等数据时,另一部分Warp可以继续计算,把内存延迟“藏”起来。
3.4 计算吞吐量与带宽:算力的两个瓶颈
最后咱们聊聊两个关键指标:计算吞吐量和内存带宽。
计算吞吐量:GPU每秒能做多少次运算。单位是TFLOPS(每秒万亿次浮点运算)。比如A100的FP16 Tensor Core吞吐量是312 TFLOPS。
内存带宽:GPU每秒能从内存读多少数据。单位是GB/s。比如A100的带宽是2 TB/s。
这两个指标决定了你的程序是计算密集型还是内存密集型。怎么判断?算一下计算强度(Arithmetic Intensity):
计算强度 = 总运算量(FLOPs) / 总数据访问量(Bytes)
如果计算强度大于GPU的“计算强度阈值”(计算吞吐量 / 内存带宽),那就是计算密集型,瓶颈在计算。反之,就是内存密集型,瓶颈在带宽。
我记得有一次优化一个LayerNorm算子,算下来计算强度只有0.5 FLOPs/Byte,明显是内存密集型。我花了很多精力优化计算,结果速度纹丝不动。后来改成减少Global Memory访问、多用Shared Memory,速度直接翻倍。嗯,这个教训让我明白了:先分析瓶颈,再动手优化。
核心公式:实际性能 = min(计算吞吐量, 内存带宽 × 计算强度)
如果你的计算强度很低,就算计算吞吐量再高,性能也上不去。
好了,这一章的内容就到这里。总结一下:
- CUDA Core是通用计算单元,Tensor Core是矩阵乘法专用单元。
- 内存层次:Global Memory大而慢,Shared Memory小而快,寄存器最快但最少。
- Warp调度:32个线程一起走,避免分支发散,用足够多的Warp隐藏延迟。
- 吞吐量与带宽:先算计算强度,再决定优化方向。
下一章,咱们会把这些知识用到实战中,手写一个高效的矩阵乘法算子。到时候你们会发现,理解了这些底层原理,写出来的代码性能就是不一样。