第一章 CUDA编程模型回顾:线程与内存的底层博弈

各位同学,咱们今天聊点实在的。CUDA编程模型,说白了就是你怎么指挥GPU干活。很多初学者觉得这玩意儿就是写写kernel、调调线程块大小,但真正到了TensorCore适配和性能优化的时候,你会发现——嗯,不懂底层调度,你连瓶颈在哪都找不到。

我个人习惯把CUDA模型拆成两大部分来看:线程怎么组织,数据怎么流动。这两件事搞明白了,后面TensorCore的适配就是水到渠成的事。

1.1 线程层次结构:Grid、Block、Warp

先看线程的组织方式。GPU上有成千上万个线程,总得有个管理方法吧?CUDA用了三层结构:Grid → Block → Thread。

  • Grid:一个kernel启动时,你启动的就是一个Grid。它包含若干个Block。
  • Block:一组线程的集合,它们可以协作,共享一块内存(Shared Memory)。
  • Thread:最基础的执行单元,每个线程执行相同的指令(SIMT模型)。

这里有个关键点:Block内的线程可以同步,但Block之间是独立的。我在项目中遇到过有人试图用全局变量在Block之间做同步,结果性能直接崩了——因为硬件根本不保证Block的执行顺序。

核心原则:Block是独立调度的最小单元。不同Block可以运行在任何SM上,顺序完全随机。

再往下,还有一层更重要的结构——Warp。Warp是SM调度时的基本单位,一个Warp包含32个线程。这32个线程执行相同的指令,但处理不同的数据。

你想想看,如果你写了个if-else分支,Warp里的线程有的走if,有的走else,会发生什么?分支发散。硬件会串行执行两个分支,性能直接腰斩。我曾经调试过一个项目,性能死活上不去,最后发现是一个简单的条件判断导致了严重的Warp发散——改完之后,性能提升了40%。

我的建议:写kernel时,尽量让同一个Warp内的线程走相同的执行路径。如果必须分支,让分支粒度对齐到Warp边界(即32的倍数)。

1.2 内存层次结构:Global、Shared、Register

聊完线程,咱们看看数据怎么存。GPU的内存层次,说白了就是速度与容量的博弈。

内存类型 位置 延迟 容量 作用域
Register SM内部 ~1 cycle 每个线程最多255个 单个线程
Shared Memory SM内部 ~30 cycles 每个SM 48KB~164KB 单个Block
Global Memory 显存 ~400 cycles GB级别 所有线程

看到这个延迟差距了吗?Global Memory访问一次要400个周期,而Register只要1个周期。所以优化的核心思路就一个:把数据尽量往快了搬。

我个人习惯把数据流动画成一条流水线:
Global → Shared → Register → 计算单元

每一步都有代价,但每一步也都有优化空间。

注意:Shared Memory虽然快,但容量有限。我见过有人把整个数据集都塞进Shared Memory,结果编译都过不了——因为每个SM就那么几十KB。合理规划,别贪心。

1.3 SM调度原理:硬件怎么分配活儿

SM(Streaming Multiprocessor)是GPU的核心计算单元。一个GPU有几十个SM,每个SM可以同时运行多个Block。

SM的调度逻辑其实挺简单的:
1. 它有一个Block调度器,负责把Grid中的Block分配给空闲的SM。
2. 每个SM内部,Block再被拆成Warp,由Warp调度器负责执行。
3. Warp调度器每个周期选一个就绪的Warp,发射指令。

这里有个关键概念——Occupancy(占用率)。它指的是SM上活跃的Warp数量与最大Warp数量的比值。占用率越高,SM越不容易因为等待内存而空闲。

为什么会这样?因为GPU用Warp切换来隐藏内存延迟。当一个Warp在等数据时,调度器立刻切换到另一个就绪的Warp。如果占用率低,切换的选择就少,SM就会空转。

经验之谈:我一般会先用NVIDIA的Occupancy Calculator算一下,看看我的kernel能达到多少占用率。低于50%的,基本都有优化空间。

1.4 Warp执行模型:SIMT的精髓

Warp执行模型,说白了就是32个线程一起走。但这里有个容易忽略的点:虽然它们执行相同的指令,但每个线程有自己的Program Counter和Register状态。

这意味着:
- 每个线程可以独立访问不同的内存地址。
- 每个线程可以有自己的局部变量。
- 但指令流是共享的。

这种模型叫SIMT(Single Instruction, Multiple Threads)。它比传统的SIMD更灵活,但也带来了分支发散的问题。

我记得有一次优化一个矩阵乘法的kernel,发现性能始终上不去。用nvprof一分析,发现Warp发散率高达30%。后来我把数据重新排列,让每个Warp处理连续的内存区域,发散率降到了5%以下,性能直接翻倍。

避坑指南:我曾经在代码里写了一个switch-case,每个case处理不同的数据长度。结果Warp内的线程走了不同的case,性能惨不忍睹。后来改成用预计算+查表的方式,把分支变成了数据依赖——嗯,效果立竿见影。

1.5 知识体系总览

下面这张图,是我自己整理的CUDA编程模型核心逻辑。你可以把它当作一张地图,后面讲TensorCore适配时,我们会反复回到这张图上来。

CUDA编程模型核心逻辑 Grid(网格) 一个kernel启动 = 一个Grid 包含多个Block,独立调度 Block(线程块) 共享Shared Memory,可同步 被SM调度,一个SM可运行多个Block Warp(线程束) 32个线程一组,SIMT执行 SM调度的基本单位,Warp切换隐藏延迟 Thread(线程) 拥有独立Register和PC 执行相同指令,处理不同数据 内存层次 Global Memory Shared Memory Register 延迟: Global ~400 cycles Shared ~30 cycles Register ~1 cycle

这张图把线程层次和内存层次放在了一起。你看,从Grid到Thread,每一层都有对应的内存作用域。优化的本质,就是让数据尽量靠近计算单元——也就是往右下角走。

好了,第一章的内容就到这里。记住:线程组织决定并行度,内存层次决定带宽利用率。后面讲TensorCore适配时,我们会反复用到这些概念。

一句话总结:CUDA编程模型不是死记硬背的概念,而是你手里的一把尺——用它去量你的kernel,哪里慢了,哪里该改,一目了然。

可可精品资料库