第二章:CUDA基础回顾——编程模型、线程层次、内存层次与Warp调度
各位同学,欢迎来到《TensorRT与CUDA协同优化实战》的第二讲。
在正式开始TensorRT优化之前,我强烈建议大家先花点时间,把CUDA的基础再过一遍。为什么?因为TensorRT说到底,是在帮你生成更高效的CUDA代码。如果你连CUDA的线程怎么跑、内存怎么分都不清楚,那后面做优化的时候,很容易踩坑。
这一章,我们就来快速回顾一下CUDA的核心概念。嗯,我尽量讲得接地气一点,毕竟咱们的目标是实战,不是考试。
2.1 CUDA编程模型:从CPU到GPU的“搬运工”
CUDA编程模型,说白了就是一套规则,告诉你怎么把CPU上的活儿,分给GPU去干。
我个人习惯把CPU叫做“主机”(Host),GPU叫做“设备”(Device)。整个流程其实就三步:
- 把数据从主机搬到设备(cudaMemcpy Host→Device)
- 在设备上执行核函数(Kernel Launch)
- 把结果从设备搬回主机(cudaMemcpy Device→Host)
你想想看,是不是很简单?但就是这三步,很多人第一步就出问题。我在项目中遇到过一位同事,他写了一个很大的矩阵乘法,结果发现每次运行都比CPU还慢。排查了半天,发现他每次循环都做了一次内存拷贝。嗯,这其实是个很常见的错误——尽量减少Host与Device之间的数据传输,这是优化的第一原则。
核心要点:CUDA编程模型的核心是“异构计算”。CPU负责控制流和串行任务,GPU负责大规模并行计算。两者通过PCIe总线通信,带宽有限,所以数据传输要精打细算。
2.2 线程层次结构:Grid、Block、Thread
CUDA的线程组织方式,有点像军队的编制。一个Grid(网格)包含多个Block(线程块),每个Block包含多个Thread(线程)。
我刚开始学的时候,总觉得这玩意儿很抽象。后来我换了个角度想:Thread就是最底层的工人,Block是一个小组,Grid是整个工厂。这样就好理解了。
| 层次 | 说明 | 典型大小 |
|---|---|---|
| Grid | 一个核函数启动的所有线程 | 根据数据量决定 |
| Block | 一组可以协作的线程(共享内存) | 128~512个线程 |
| Thread | 最小的执行单元 | 1个 |
这里有个关键点:同一个Block内的线程可以通过共享内存通信,不同Block的线程不能直接通信。我建议你在设计核函数时,尽量把需要协作的数据放在同一个Block内。我曾经在一个图像处理项目中,因为没注意这个,导致不同Block之间用全局内存做同步,性能直接腰斩。
小技巧:启动核函数时,Block的大小最好是32的倍数。为什么?因为Warp的大小就是32。后面会讲。
2.3 内存层次结构:速度与容量的博弈
CUDA的内存模型,说白了就是一场“速度与容量”的博弈。离计算单元越近,速度越快,但容量越小;离得越远,容量越大,但速度越慢。
- 全局内存(Global Memory):容量最大(GB级别),但延迟最高(几百个周期)。所有线程都能访问。
- 共享内存(Shared Memory):容量小(几十KB),但延迟极低(几个周期)。同一个Block内的线程共享。
- 寄存器(Registers):每个线程私有,速度最快,但数量有限(每个线程最多255个)。
- 本地内存(Local Memory):寄存器不够用时,编译器会把变量 spill 到本地内存。其实它物理上在全局内存里,所以慢。
- 常量内存(Constant Memory):只读,有缓存,适合存放不会变的参数。
- 纹理内存(Texture Memory):专门为图形处理优化,有空间局部性缓存。
嗯,这里我要特别强调一下共享内存。我在做TensorRT优化时,经常通过手动分配共享内存来减少全局内存访问。比如矩阵分块乘法,把子矩阵先搬到共享内存里,再计算,速度能快好几倍。
避坑指南:我曾经因为共享内存使用不当,导致程序跑飞。原因是共享内存的大小是有限的,每个SM(流多处理器)的共享内存总量是固定的(比如48KB或96KB)。如果你一个Block用了太多共享内存,那SM上能同时运行的Block数量就会减少,反而降低并行度。所以,共享内存不是越多越好,要权衡。
2.4 SM与Warp调度:GPU的“心脏”
SM(Streaming Multiprocessor,流多处理器)是GPU的核心计算单元。一个GPU有多个SM,每个SM内部又包含多个CUDA Core。
但真正执行指令的单位,是Warp。Warp是32个线程组成的一个组。SM以Warp为单位进行调度。也就是说,你虽然写了成千上万个线程,但SM一次只调度一个Warp(或者多个Warp交替执行)。
为什么会这样?因为GPU的设计哲学是“以空间换时间”。它用大量的线程来隐藏内存访问延迟。当一个Warp在等待内存数据时,SM会立刻切换到另一个Warp去执行。只要Warp的数量足够多,SM就能一直保持忙碌。
我建议你在写核函数时,尽量保证同一个Warp内的线程执行相同的指令。如果出现分支(if-else),那Warp内的线程就会“分道扬镳”,导致一部分线程执行,另一部分等待,这就是所谓的Warp Divergence。我在一个粒子模拟项目中,就因为一个简单的条件判断,导致性能下降了30%。后来我把数据重新排列,让同一个Warp内的粒子状态尽量一致,才把性能拉回来。
关键数字:
- 一个Warp = 32个线程
- 一个SM可以同时管理多个Warp(比如64个)
- Warp调度是零开销的(硬件自动完成)
2.5 小结:这些基础为什么重要?
好了,这一章我们回顾了CUDA的四大基础:编程模型、线程层次、内存层次、Warp调度。你可能会问,这些跟TensorRT有什么关系?
关系大了去了。TensorRT在优化模型时,本质上就是在做这些事情:
- 它帮你自动选择最优的Block和Grid大小
- 它帮你管理共享内存,减少全局内存访问
- 它帮你避免Warp Divergence,提高指令吞吐
但如果你不理解这些底层原理,那TensorRT对你来说就是个黑盒。出了问题,你都不知道从哪里下手。所以,打好基础,后面才能飞得起来。
下一章,我们会正式进入TensorRT的世界,看看它到底是怎么把模型“榨干”的。到时候你会发现,今天讲的这些,全都能用上。
课后练习:写一个简单的向量加法核函数,分别尝试不同的Block大小(64、128、256、512),观察性能变化。然后思考一下,为什么不是越大越好?
公众号:蓝海资料掘金营,微信deep3321