🔥 本文专栏:CUDA
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
焦虑喜欢让你提前活在失败里,而行动只要求你把眼前这一小步走完。
思维导图
text
普通 C/C++ 程序
↓
程序主要运行在 CPU
↓
CPU 擅长复杂控制逻辑和通用任务
↓
出现大量重复、相似、彼此相对独立的数值计算
↓
GPU 更适合承担这类任务
↓
CUDA
↓
CPU 作为 Host 负责整体控制
GPU 作为 Device 负责并行计算
↓
CPU 申请 GPU 显存
↓
Host 数据复制到 Device
↓
CPU Launch Kernel
↓
Grid
↓
多个 Block
↓
每个 Block 包含多个 Thread
↓
Thread 根据自身编号处理不同数据
↓
Block 被调度到 SM
↓
Thread 进一步按照 32 个一组组成 Warp
↓
SM 以 Warp 为重要调度执行单位
↓
SIMT:相同 Kernel 逻辑 + 不同线程数据
↓
如果同一 Warp 出现不同分支
↓
Warp Divergence
↓
SM 资源有限
↓
一个 SM 只能同时驻留有限数量 Block / Warp
↓
Resident Warp
↓
Warp 可能 Ready,也可能因为访存 / 同步等原因等待
↓
调度其他 Ready Warp
↓
隐藏等待延迟
↓
Occupancy
= 实际驻留 Warp / SM 最大可驻留 Warp
↓
Occupancy 够用即可,并非越高越快
↓
进一步观察 GPU 数据存储
↓
Register:Thread 私有,片上
Shared Memory:Block 共享,片上
Local Memory:Thread 私有,通常落在设备内存侧
Global Memory:设备级访问,通常对应显存
↓
Global Memory 访问代价较高
↓
一个 Warp 尽量访问连续地址
↓
更少 Memory Transaction
↓
Coalesced Memory Access
↓
高频复用数据搬到 Shared Memory
↓
减少 Global Memory 访问
↓
Shared Memory 又由多个 Bank 交错组织
↓
同一 Warp 的不同地址撞到同一 Bank
↓
Bank Conflict
↓
调整数据布局 / Padding
一、为什么会出现 CUDA
在此前编写的大部分 C/C++ 程序中,我们几乎不会主动思考:
text
这段代码到底应该运行在 CPU 还是 GPU?
原因其实很简单:
我们以前编写的程序,默认主要就是运行在 CPU 一侧。
例如网络服务器中:
text
socket
epoll
业务逻辑
Redis / MySQL
协议解析
定时器
线程池
这些任务通常包含大量控制逻辑、分支判断、系统调用以及不规则的数据访问。
CPU 本身就非常适合处理这类任务。
1. CPU 和 GPU 的设计目标不同
CPU 通常只有相对较少但功能很强的核心。
它更强调:
text
复杂控制
低延迟
强大的单线程执行能力
灵活的分支处理
而 GPU 的设计思路不同。
GPU 把更多硬件资源用于提供大量计算执行资源,因此能够承载大量线程,特别适合处理:
text
大量
重复
规则
彼此相对独立
的数值计算。
例如:
cpp
for (int i = 0; i < 10000000; ++i) {
c[i] = a[i] + b[i];
}
CPU 当然可以完成。
但是观察每一个元素:
text
c[0] = a[0] + b[0]
c[1] = a[1] + b[1]
c[2] = a[2] + b[2]
...
这些计算之间非常相似,而且很多时候彼此不存在依赖。
那么就可以考虑:
把这些重复的数值计算交给更适合吞吐型并行计算的 GPU。
所以不要把 GPU 理解成什么神秘设备。
从整个程序角度来看,它仍然是一种由 CPU 驱动和协同使用的计算设备。
2. CUDA 的核心作用
当程序开始同时涉及:
text
CPU 代码
+
GPU 代码
就需要一套能够描述 GPU 计算、启动 GPU 任务并管理相关资源的编程体系。
这就是 CUDA。
可以暂时理解为:
CUDA 是 NVIDIA 提供的一套让程序能够使用 NVIDIA GPU 进行通用计算的编程平台和编程模型。
于是一个 CUDA 程序中会自然出现两个概念:
text
Host
→ CPU 一侧
Device
→ GPU 一侧
整个程序的控制入口仍然在 CPU。
GPU 并不是自己突然开始运行,而是 CPU 在程序执行过程中向 GPU 发起计算任务。
二、CUDA 程序是怎么跑起来的
理解 CUDA 最重要的第一步,不是背 API,而是先理解一轮 GPU 计算的完整生命周期。
一个最基本的过程可以概括为:
text
CPU 上 main() 开始执行
↓
在 GPU 设备内存上申请空间
↓
CPU 主存数据复制到 GPU
↓
CPU 启动一个 GPU Kernel
↓
GPU 并行完成计算
↓
计算结果复制回 CPU
↓
释放 GPU 资源
对应最常见的一组 CUDA Runtime API:
cpp
cudaMalloc(...);
cudaMemcpy(..., cudaMemcpyHostToDevice);
kernel<<<gridSize, blockSize>>>(...);
cudaMemcpy(..., cudaMemcpyDeviceToHost);
cudaFree(...);
所以这些 API 并不是彼此独立的。
它们实际上刚好对应一次 GPU 任务的完整生命周期。
1. Kernel 是什么
CPU 真正向 GPU 发起的计算任务,通常表现为一个 Kernel,也就是核函数。
例如:
cpp
__global__ void add(const float* a,
const float* b,
float* c,
int n)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
c[idx] = a[idx] + b[idx];
}
}
CPU 一侧通过:
cpp
add<<<gridSize, blockSize>>>(a, b, c, n);
发起这个 GPU 任务。
和普通 C++:
cpp
add(a, b, c, n);
不同的是:
text
普通函数调用
↓
由某个 CPU 执行流完成
Kernel Launch
↓
启动大量 GPU Thread
↓
大量线程执行同一份 Kernel 代码
三、Grid、Block 和 Thread:GPU 大量线程如何组织
GPU 能运行大量线程。
但是显然不能把几十万甚至更多线程完全无组织地堆在一起。
CUDA 在软件编程模型中将这些线程组织成:
text
Grid
↓
Block
↓
Thread
例如:
cpp
kernel<<<100, 256>>>();
可以理解成:
text
一个 Grid
│
├── Block 0
│ ├── Thread 0
│ ├── Thread 1
│ ├── ...
│ └── Thread 255
│
├── Block 1
│ └── 256 Threads
│
...
│
└── Block 99
因此一共启动:
text
100 × 256
=
25600 个 Thread
四、线程编号:同一份代码为什么能够处理不同数据
既然这么多线程执行同一个 Kernel,那么马上会出现一个问题:
所有线程代码都一样,它们怎么知道自己应该处理哪一份数据?
答案就是:
线程编号。
CUDA 为线程提供:
text
threadIdx.x
→ 当前线程在 Block 内部的局部编号
blockIdx.x
→ 当前 Block 在 Grid 中的编号
blockDim.x
→ 当前 Block 的线程数量
于是可以计算:
cpp
int idx = blockIdx.x * blockDim.x + threadIdx.x;
得到一个全局线程编号。
例如每个 Block 有 256 个线程:
text
Block 0
→ 全局 Thread 0 ~ 255
Block 1
→ 全局 Thread 256 ~ 511
Block 2
→ 全局 Thread 512 ~ 767
于是:
cpp
c[idx] = a[idx] + b[idx];
在不同线程中实际上变成:
text
Thread 0 → c[0] = a[0] + b[0]
Thread 1 → c[1] = a[1] + b[1]
Thread 2 → c[2] = a[2] + b[2]
...
这就是 GPU 最基础的一种并行模式:
同一份计算逻辑 + 不同线程编号 + 不同数据。
五、数据竞争与同步不是一回事
假设:
cpp
data[idx] = ...;
不同线程分别写:
text
Thread 0 → data[0]
Thread 1 → data[1]
Thread 2 → data[2]
只要每个线程访问自己的位置,就不会因为多个线程同时修改同一个位置而产生数据竞争。
但是:
没有数据竞争,并不意味着线程之间永远不需要同步。
假设后续:
cpp
data[idx] = ...;
// 后面需要读取其他线程产生的数据
float x = data[idx + 1];
这里就出现了另一个问题:
text
Thread 0 想读取 data[1]
但是:
Thread 1 到底有没有完成 data[1] 的写入?
这不是"两个线程同时写同一个位置"的问题。
而是:
线程之间存在数据依赖,因此需要保证执行时序。
1. __syncthreads():把计算拆成两个阶段
例如:
cpp
__shared__ float cache[256];
cache[threadIdx.x] = ...;
// 第一阶段写入完成以后进行同步
__syncthreads();
// 第二阶段再读取其他线程产生的数据
float x = cache[...];
可以理解成:
text
第一阶段
所有线程完成写入
↓
__syncthreads()
↓
先到的线程等待
↓
同一个 Block 的线程全部到达
↓
第二阶段
开始读取共享数据
所以:
各写各的解决的是数据竞争问题,而同步解决的是跨线程数据依赖的时序问题。
同时需要注意:
text
__syncthreads()
是 Block 级同步。
它保证的是:
同一个 Block 中的线程完成同步。
并不是整个 Grid 的所有线程一起同步。
六、软件层的 Block 最终由谁执行:SM
前面介绍的:
text
Grid
Block
Thread
主要是 CUDA 编程模型中的逻辑组织方式。
真正进入 GPU 硬件执行层以后,还会出现一个重要概念:
text
SM
Streaming Multiprocessor
可以暂时把 SM 理解成:
GPU 内真正承载并执行线程块的一个计算单元。
一个 GPU 内部通常有多个 SM:
text
GPU
│
├── SM 0
├── SM 1
├── SM 2
├── ...
└── SM N
SM 中包含:
text
计算执行资源
寄存器
Shared Memory
Warp 调度相关硬件
...
1. Block 与 SM 不是一对一
假设 Kernel 启动:
text
1000 个 Block
GPU 只有:
text
10 个 SM
显然不可能做到:
text
一个 Block 对应一个 SM
实际情况是:
text
多个 Block
↓
可以驻留在同一个 SM
并且 Block 是分批进入 SM 的。
前面的 Block 执行完成、释放资源以后,后续 Block 再继续进入。
七、Warp:真正分析 GPU 执行时最重要的视角
一个 Block 进入 SM 以后,其中的 Thread 还会进一步被组织成:
text
32 Threads = 1 Warp
例如:
text
一个 Block
=
256 Threads
=
8 Warps
于是:
text
Block
│
├── Warp 0 → Thread 0 ~ 31
├── Warp 1 → Thread 32 ~ 63
├── Warp 2 → Thread 64 ~ 95
...
所以这里一定要区分:
text
Grid / Block / Thread
→ 软件编程模型
Warp
→ GPU 硬件调度执行时非常重要的线程组织单位
八、SIMT:为什么 GPU 适合"同一份逻辑 + 不同数据"
在编程模型层面,一个 Warp 中的线程共同推进同一份 Kernel 代码。
但是每个线程有自己的:
text
threadIdx
寄存器状态
数据地址
计算数据
所以例如:
cpp
c[idx] = a[idx] + b[idx];
同一个 Warp 中可以表现为:
text
Thread 0 → c[0] = a[0] + b[0]
Thread 1 → c[1] = a[1] + b[1]
Thread 2 → c[2] = a[2] + b[2]
...
这就是 CUDA 的 SIMT:
text
Single Instruction, Multiple Threads
可以先理解成:
Warp 中大量线程执行相同的程序逻辑,但每个线程处理自己的数据。
这也是 GPU 高吞吐设计非常核心的一点。
GPU 并不是给每个 Thread 都设计一整套像 CPU 核心一样复杂的独立控制能力。
它更强调:
text
大量计算资源
+
大量线程
+
相似控制流
+
不同数据
九、Warp Divergence:为什么 if/else 可能让 GPU 变慢
假设 Kernel 中:
cpp
if (data[idx] > 0) {
// A
} else {
// B
}
不同线程处理的数据不同,于是同一个 Warp 中可能出现:
text
Thread 0 → A
Thread 1 → A
Thread 2 → B
Thread 3 → A
Thread 4 → B
...
这时候就出现:
text
Warp Divergence
也就是 Warp 分支发散。
同一个 Warp 无法简单地让一部分线程同时推进 A,另一部分线程完全独立推进 B。
硬件会根据当前执行路径,让对应线程保持 active,而其他线程暂时不参与这一条路径。
可以粗略理解成:
text
先执行 A
↓
需要 A 的线程 active
其他线程暂时 idle
再执行 B
↓
需要 B 的线程 active
其他线程暂时 idle
因此:
真正的问题不是代码中出现了 if/else,而是同一个 Warp 中的线程走向了不同控制路径。
如果整个 Warp 都走 A:
text
没有分支发散
如果整个 Warp 都走 B:
text
同样没有分支发散
所以分析 Warp Divergence 的基本单位仍然是:
Warp。
十、一个 SM 为什么不能无限驻留 Block
前面已经知道:
text
一个 SM
↓
可以同时驻留多个 Block
但是 SM 本身的资源显然不是无限的。
一个 SM 存在:
text
最大驻留 Block 数
最大驻留 Thread 数
最大驻留 Warp 数
寄存器总量
Shared Memory 总量
而每一个 Block 进入 SM 都会消耗这些资源。
例如一个 Block:
text
256 Threads
=
8 Warps
这些线程还需要:
text
Register
如果 Kernel 中使用:
cpp
__shared__ float cache[...];
这个 Block 还需要消耗一定数量:
text
Shared Memory
所以最终一个 SM 能够驻留多少 Block,取决于:
text
架构最大 Block 数限制
Thread 数限制
Warp 数限制
Register 限制
Shared Memory 限制
哪个条件最先达到上限,哪个就会成为限制因素。
十一、Resident Warp:驻留不等于正在执行
假设一个 SM 当前驻留了多个 Block:
text
SM
│
├── Block 0
│ ├── Warp 0
│ ├── Warp 1
│ └── ...
│
├── Block 1
│ ├── Warp 0
│ ├── Warp 1
│ └── ...
│
└── Block 2
这些已经进入 SM、获得对应资源并等待调度的 Warp,就可以称为:
text
Resident Warp
驻留 Warp
需要注意:
驻留并不代表这个 Warp 此刻正在执行。
它只是:
text
已经待在 SM 上
+
拥有自己的执行上下文
+
等待被 Warp Scheduler 选择
十二、Ready Warp:什么叫"当前可执行"
Warp Scheduler 并不是机械地:
text
先执行完 Block 0 的所有 Warp
↓
再执行 Block 1
它会在当前驻留的 Warp 中选择能够继续执行的 Warp。
所谓 Ready Warp,可以简单理解成:
这个 Warp 下一条指令当前需要的条件已经满足,可以继续执行。
例如:
cpp
float x = data[idx];
float y = x + 1.0f;
如果 data[idx] 的 Global Memory 数据还没有返回:
text
Warp 发起读取
↓
数据尚未返回
↓
下一条指令又依赖 x
↓
当前不能继续
这个 Warp 就会暂时等待。
类似地:
cpp
__syncthreads();
如果这个 Warp 已经到达同步点,但是同一个 Block 的其他 Warp 还没有到:
text
当前 Warp
↓
等待同步条件满足
它此时同样不能继续执行。
十三、延迟隐藏:GPU 为什么需要很多驻留 Warp
如果一个 SM 只有一个 Warp:
text
Warp 0
↓
访问 Global Memory
↓
等待
↓
SM 没有其他工作
那么等待时间就会直接表现为硬件空闲。
但是如果:
text
Warp 0 → 等待 Global Memory
Warp 1 → Ready
Warp 2 → Ready
Warp 3 → Ready
Scheduler 可以:
text
Warp 0 等
↓
执行 Warp 1
Warp 1 等
↓
执行 Warp 2
于是:
某一个 Warp 的等待时间仍然存在,但是 SM 可以在这段时间执行其他 Warp。
这就是:
text
Latency Hiding
延迟隐藏
注意:
text
降低延迟
≠
隐藏延迟
降低延迟是:
text
让一次访问本身更快
隐藏延迟则是:
text
这次访问还是要等
但是我等你的时候先去干别的
这正是 GPU 高吞吐设计的重要思路。
十四、Occupancy:SM 到底驻留了多少 Warp
到这里就可以自然引出:
text
Occupancy
Occupancy 可以理解成:
text
实际驻留 Warp 数
────────────────
SM 理论最大可驻留 Warp 数
例如:
text
SM 最大可以驻留 64 Warp
当前 Kernel 实际只能驻留 32 Warp
Occupancy
=
32 / 64
=
50%
这里一定要注意:
text
Occupancy
≠
Ready Warp / Resident Warp
Occupancy 关心的是:
SM 的 Warp 驻留容量实际使用了多少。
而 Ready Warp 关心:
这些已经驻留的 Warp 中,当前谁能继续执行。
这是两个完全不同的概念。
十五、Occupancy 为什么会影响性能
假设:
text
Resident Warp 很少
那么:
text
可供 Scheduler 选择的候选 Warp 也少
一旦这些 Warp 中有很多都因为:
text
Global Memory
同步
数据依赖
暂时无法执行,就更容易出现:
text
Scheduler 找不到 Ready Warp
↓
SM 部分计算资源空闲
所以:
text
Resident Warp 较多
↓
通常更容易找到 Ready Warp
↓
更容易隐藏等待延迟
这就是 Occupancy 和性能之间最基本的联系。
十六、为什么 Occupancy 不是越高越好
这里非常容易产生一个误区:
text
Occupancy = 100%
↓
性能一定最好
实际上并不是。
Occupancy 的意义主要在于:
给 Scheduler 提供足够多的 Warp,让它能够隐藏等待延迟。
假设:
text
50% Occupancy
已经足够让 Scheduler 几乎一直都能找到 Ready Warp。
那么继续提升到:
text
75%
100%
可能不会带来明显收益。
甚至为了强行提高 Occupancy,如果刻意压缩:
text
每个 Thread 的 Register 使用量
可能发生寄存器溢出,导致数据进入更慢的 Local Memory。
或者为了提高驻留 Block 数,强行减少:
text
Shared Memory
又可能导致 Global Memory 访问次数增加。
最终可能出现:
text
Occupancy 提高了
↓
Kernel 反而变慢
所以真正应该追求的是:
足够的 Occupancy,而不是最高的 Occupancy。
最终目标始终是:
text
让 SM 持续、高效地执行有效工作
而不是让某个指标达到 100%。
十七、Occupancy 如何影响代码层面的设计
Occupancy 并不是:
cpp
occupancy = 80%;
这样直接配置出来的。
它是:
text
GPU 硬件资源
+
Kernel 资源需求
+
Block 配置
共同作用的结果。
程序员最容易影响的主要有:
text
Block 中 Thread 数量
每个 Thread 的 Register 压力
每个 Block 的 Shared Memory 使用量
例如:
cpp
kernel<<<gridSize, 128>>>(...);
和:
cpp
kernel<<<gridSize, 256>>>(...);
对应的每个 Block Warp 数量不同。
Kernel 中局部变量过多,又可能提高:
text
Register Pressure
而大量:
cpp
__shared__
数据又会提高每个 Block 的 Shared Memory 占用。
这些最终都会影响:
text
一个 SM 可以同时驻留多少 Block
↓
Resident Warp 数量
↓
Occupancy
十八、GPU 硬件配置不是靠程序员猜
不同 GPU 的:
text
SM 数量
每个 Block 最大 Thread 数
每个 SM 最大驻留 Thread 数
Shared Memory 容量
寄存器资源
都可能不同。
所以程序并不需要把这些东西全靠人工写死。
CUDA 可以通过:
cpp
cudaGetDeviceProperties(...)
查询设备属性。
需要注意:
这些查询得到的是 GPU 硬件能力,而不是"当前 GPU 此刻忙不忙"的动态负载状态。
同时 CUDA 还提供 Occupancy 相关 API,例如:
cpp
cudaOccupancyMaxPotentialBlockSize(...)
可以结合:
text
当前 GPU 的硬件能力
+
当前 Kernel 的资源需求
估算一个比较合理的 Block Size。
例如:
cpp
int minGridSize;
int blockSize;
cudaOccupancyMaxPotentialBlockSize(
&minGridSize,
&blockSize,
kernel,
0,
0
);
然后再根据实际数据量:
cpp
int gridSize = (N + blockSize - 1) / blockSize;
kernel<<<gridSize, blockSize>>>(...);
这里同样要注意:
Occupancy API 给出的是基于 Occupancy 的合理启动配置参考,而不是绝对性能最优解。
最终仍然需要结合实际 benchmark 和 profiler。
十九、CUDA 内存体系:显存、Register、Shared、Local、Global 到底是什么关系
最开始接触 GPU 时,我们通常只知道一个词:
text
显存
VRAM
但是进入 CUDA 以后,又会接触:
text
Register
Shared Memory
Local Memory
Global Memory
这里最容易混乱的原因是:
"显存"偏物理硬件概念,而这些名字更多是 CUDA 编程模型中的内存空间概念。
可以先画成:
text
GPU
│
├── SM
│ │
│ ├── Register
│ │ └── Thread 私有
│ │
│ └── Shared Memory
│ └── Block 内共享
│
└── 设备内存一侧
│
├── Global Memory
│ └── 设备级可访问
│
└── Local Memory
└── 逻辑上 Thread 私有
其中:
text
Register / Shared Memory
位于 SM 片上,离计算执行资源近。
而:
text
Global Memory
Local Memory
通常依托设备内存体系,访问代价明显更高。
1. Register
普通 Kernel 局部标量:
cpp
int idx;
float sum;
通常会被编译器尽量放在寄存器中。
Register 的语义是:
text
Thread 私有
并且程序员通常不会显式指定:
text
idx 放 R3
sum 放 R7
具体物理寄存器分配由编译器完成。
2. Shared Memory
Shared Memory 可以由程序员明确声明:
cpp
__shared__ float cache[256];
它的语义是:
text
同一个 Block 内线程共享
不同 Block 即使驻留在同一个 SM,它们的 Shared Memory 数据也仍然是彼此隔离的。
也就是说:
text
共享的是 SM 的 Shared Memory 总容量
不是:
不同 Block 可以随便访问彼此的数据
3. Global Memory
例如:
cpp
float* d_data;
cudaMalloc(&d_data, N * sizeof(float));
这里的数据就是程序员显式放到设备 Global Memory 中。
它可以被不同:
text
Thread
Block
SM
通过相应地址访问。
我们平时所说的大容量 GPU 显存,和 Global Memory 的物理后端关系最直接。
4. Local Memory
Local Memory 最容易因为名字产生误解。
它叫:
text
Local
并不代表:
text
它离计算单元很近
Local 描述的是:
逻辑作用范围属于 Thread 私有。
例如:
cpp
__global__ void kernel() {
float arr[100];
}
如果这个局部数组无法很好地被寄存器化,就可能进入 Local Memory。
于是:
text
Thread 0 → 自己的 arr[]
Thread 1 → 自己的 arr[]
Thread 2 → 自己的 arr[]
逻辑上它仍然是 Thread 私有。
但是物理访问通常依托设备内存体系,因此代价远高于 Register。
二十、普通局部变量到底放 Register 还是 Local Memory
这里还有一个非常重要的边界:
编译器并不是在 Register / Local / Shared / Global 四个空间之间随便选。
原因是这些内存空间本身具有不同的程序语义。
例如:
cpp
float x;
如果这是 Kernel 内普通局部变量,那么它天然是:
text
Thread 私有
因此:
text
Register
→ Thread 私有
√
Local Memory
→ Thread 私有
√
而:
text
Shared Memory
→ Block 共享
Global Memory
→ 设备级可访问
它们的共享语义已经不同。
所以对于普通线程局部变量:
编译器主要是在 Register 和 Local Memory 之间决定具体实现。
而:
text
__shared__
或者:
text
cudaMalloc()
则是程序员显式表达 Shared / Global Memory 语义的重要方式。
二十一、为什么 Global Memory 的访问方式会影响性能
认识完内存体系以后,就可以重新回到性能问题。
Global Memory:
text
容量大
设备级可访问
但是相对于 SM 上的 Register 和 Shared Memory:
text
距离计算单元更远
访问代价更高
所以 GPU 性能优化的一个重要方向就是:
尽量提高每一次 Global Memory 访问的有效利用率。
这就引出了:
text
Coalesced Memory Access
合并内存访问
二十二、什么是 Memory Transaction
假设一个 Warp 中 32 个线程执行:
cpp
float x = data[idx];
从软件角度看,是:
text
32 个线程
↓
产生各自的数据读取需求
但是硬件不会机械地理解成:
text
32 个 Thread
=
必须独立访问显存 32 次
GPU 内存系统会观察这些线程产生的地址,并将访问组织成实际的:
text
Memory Transaction
内存事务
可以简单理解为:
硬件真正向存储系统发起的一次数据传输访问。
所以:
text
线程的访存需求
≠
底层一定一一对应一个 Memory Transaction
硬件会根据地址分布决定需要多少次内存事务。
二十三、合并访存:同一个 Warp 尽量访问连续地址
假设:
text
Thread 0 → data[0]
Thread 1 → data[1]
Thread 2 → data[2]
...
Thread 31 → data[31]
这些地址非常集中。
那么硬件就更容易:
text
将这些访问需求
↓
组织成较少的 Memory Transaction
↓
一次搬运一片连续数据
于是显存带宽利用率更高。
反过来:
text
Thread 0 → data[0]
Thread 1 → data[100]
Thread 2 → data[200]
Thread 3 → data[300]
...
地址非常分散。
于是:
text
一个 Warp 的访问
↓
落在很多不同内存区域
↓
需要更多 Memory Transaction
↓
Global Memory 访问效率降低
所以:
Global Memory 合并访存,本质上就是尽量让一个 Warp 中线程的地址集中、连续,从而使用较少的内存事务完成访问。
1. 为什么全局线程下标访问数组这么常见
CUDA 中经常看到:
cpp
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float x = data[idx];
这不仅仅是因为:
text
一个 Thread 处理一个元素
同时还因为一个 Warp 中线程编号连续。
所以通常形成:
text
Thread 0 → data[0]
Thread 1 → data[1]
Thread 2 → data[2]
...
于是:
text
连续 Thread
↓
连续数组下标
↓
连续内存地址
↓
容易形成合并访存
但是需要注意:
cpp
data[idx * 100]
虽然:
text
idx = 0, 1, 2, 3...
仍然连续。
但真正访问的是:
text
data[0]
data[100]
data[200]
data[300]
地址仍然分散。
所以:
线程编号连续不等于访存一定连续,最终仍然要看地址映射。
二十四、Shared Memory:把高频数据搬近一点
如果某些 Global Memory 数据会被同一个 Block 内反复使用,那么一直访问 Global Memory 显然不划算。
这时候可以:
text
Global Memory
↓
先读取一次
↓
Shared Memory
↓
Block 内线程反复使用
例如:
cpp
__global__ void kernel(const float* input)
{
__shared__ float cache[256];
int tid = threadIdx.x;
int idx = blockIdx.x * blockDim.x + tid;
cache[tid] = input[idx];
__syncthreads();
// 后面反复使用 cache 中的数据
}
这里:
cpp
cache[tid] = input[idx];
就完成了:
text
Global Memory
↓
线程读取
↓
Shared Memory
它并不需要一个专门的:
text
cudaGlobalToSharedMemcpy()
1. 为什么搬完以后经常需要 __syncthreads()
假设:
text
Thread 0 → 搬 cache[0]
Thread 1 → 搬 cache[1]
Thread 2 → 搬 cache[2]
...
每个 Thread 写自己的位置:
text
不存在多个线程同时写同一个位置
所以这个阶段通常不需要加锁。
但是后面如果:
text
Thread 0
需要读取 Thread 1 刚刚搬进来的 cache[1]
就需要保证:
text
Thread 1 已经完成写入
于是:
cpp
__syncthreads();
再次出现。
所以这里又把前面的知识串回来了:
text
每个线程各写各的
↓
没有数据竞争
但是后续需要读取其他线程产生的数据
↓
存在执行时序依赖
↓
__syncthreads()
二十五、Shared Memory 也不能一股脑全部使用
Shared Memory 比 Global Memory 快。
但是这并不意味着:
把所有数据全部搬到 Shared Memory 就一定最好。
因为 Shared Memory 本身就是一个 SM 上有限的硬件资源。
如果:
text
每个 Block 使用大量 Shared Memory
那么:
text
一个 SM 能同时驻留的 Block 数减少
↓
Resident Warp 数可能减少
↓
Scheduler 可选择的 Warp 候选减少
↓
隐藏延迟能力可能下降
所以这里又出现一个 CUDA 中非常典型的权衡:
text
多用 Shared Memory
↓
减少 Global Memory 访问
↓
单个 Block 访存效率提高
但是:
text
Shared Memory 占用太高
↓
Resident Block / Warp 减少
↓
Occupancy 可能下降
最终需要在:
text
单个 Block 的执行效率
与:
text
SM 可以同时驻留多少 Block / Warp
之间找到合理平衡。
二十六、Shared Memory 为什么还要划分 Bank
到这里又会产生一个问题。
既然 Shared Memory 已经位于 SM 片上,速度很快,那是不是:
text
随便访问都一样快?
也不是。
一个 Warp 有:
text
32 个 Thread
如果 Shared Memory 只有一条访问通道:
text
Thread 0 ─┐
Thread 1 ─┤
Thread 2 ─┼→ Shared Memory
Thread 3 ─┘
...
那么大量线程仍然要排队。
所以 Shared Memory 在硬件上被组织成多个:
text
Bank
当前 NVIDIA CUDA 编程模型中,Shared Memory 有:
text
32 个 Bank
并且:
连续的 32-bit word 会依次映射到连续 Bank。
所以 Bank 可以理解成:
Shared Memory 为了支持并行访问而划分出来的多个独立存储通道。
二十七、Bank 不是连续分区,而是交错组织
Bank 很容易被误解成:
text
Bank 0 → 负责一整段连续 Shared Memory
Bank 1 → 负责下一整段连续 Shared Memory
实际上更接近:
text
shared[0] → Bank 0
shared[1] → Bank 1
shared[2] → Bank 2
...
shared[31] → Bank 31
shared[32] → Bank 0
shared[33] → Bank 1
...
对于连续的 float 数据,因为:
text
sizeof(float) = 4 Byte = 32 bit
可以在这个场景下粗略理解为:
text
Bank
=
float 元素序号 % 32
所以:
text
Bank 0
→ shared[0], shared[32], shared[64] ...
Bank 1
→ shared[1], shared[33], shared[65] ...
Bank 2
→ shared[2], shared[34], shared[66] ...
这是一种:
text
交错映射
而不是按大片连续地址切割。
1. 地址和 Bank 的关系
程序访问:
cpp
shared[35]
仍然是一个精确地址访问。
Bank 并不会把地址变成"粗粒度访问"。
更准确的理解是:
text
Shared Memory 地址
↓
硬件地址映射
↓
确定属于哪个 Bank
+
确定该 Bank 中的具体位置
所以:
text
地址
→ 决定访问哪个具体数据
Bank
→ 决定通过哪一路硬件存储通道访问
而且这种映射不是随机的。
规则确定以后,程序员就可以根据:
text
数据布局
+
访问下标
预测同一个 Warp 中线程会落在哪些 Bank。
二十八、Bank Conflict 到底是什么
如果一个 Warp:
text
Thread 0 → Bank 0
Thread 1 → Bank 1
Thread 2 → Bank 2
...
每个线程访问不同 Bank,那么多个 Bank 可以并行工作。
但是如果:
text
Thread 0 → Bank 0 中地址 A
Thread 1 → Bank 0 中地址 B
Thread 2 → Bank 0 中地址 C
...
也就是:
同一个 Warp 的同一条 Shared Memory 访问指令中,多个线程访问不同地址,但是这些地址映射到了同一个 Bank。
这就发生:
text
Bank Conflict
由于同一个 Bank 对多个不同地址的并发服务能力有限,这些访问需要被拆开处理。
于是:
text
原本希望并行完成
↓
被迫分多轮完成
↓
Shared Memory 访问效率下降
二十九、Bank Conflict 不是概率事件
这里很容易再次用 CPU 多线程的思路去理解:
text
只有两个线程恰好同一时刻访问同一个 Bank
才会冲突?
不是。
需要重新回到:
text
Warp
这个基本单位。
同一个 Warp 的线程在编程模型层面共同推进同一条指令。
例如:
cpp
float x = tile[threadIdx.x][col];
这个 Warp 执行到这一条 Shared Memory load 时:
text
Thread 0 产生自己的地址
Thread 1 产生自己的地址
Thread 2 产生自己的地址
...
Thread 31 产生自己的地址
然后硬件观察:
text
这些地址分别映射到哪个 Bank
所以 Bank Conflict 是:
由一个 Warp 的访问模式结构性决定的,而不是线程运行时随机碰巧撞在一起。
这也是为什么它可以被稳定分析和优化。
三十、二维数组为什么特别容易看到 Bank Conflict
考虑:
cpp
__shared__ float tile[32][32];
C/C++ 二维数组按照行优先连续存储。
所以:
text
tile[0][0]
tile[0][1]
tile[0][2]
...
tile[0][31]
是一整行连续的 32 个 float。
它们刚好映射:
text
Bank 0 ~ Bank 31
所以按行访问:
text
tile[0][0]
tile[0][1]
tile[0][2]
...
通常非常舒服。
1. 问题出现在按列访问
观察:
text
tile[0][0]
tile[1][0]
tile[2][0]
tile[3][0]
...
由于每一行有:
text
32 个 float
所以这些元素在线性地址中的距离分别是:
text
0
32
64
96
...
根据 Bank 的周期映射:
text
0 % 32 → Bank 0
32 % 32 → Bank 0
64 % 32 → Bank 0
96 % 32 → Bank 0
于是如果一个 Warp:
text
Thread 0 → tile[0][0]
Thread 1 → tile[1][0]
Thread 2 → tile[2][0]
...
就会出现大量线程访问:
text
同一个 Bank 中的不同地址
形成严重 Bank Conflict。
所以这里不是:
不能访问"同一列"。
真正的问题是:
当前二维布局的行跨度恰好让 Warp 的访问地址周期性映射到了相同 Bank。
三十一、Padding 为什么能够解决 Bank Conflict
Bank 由硬件按照地址规则映射,程序员不能直接写:
text
把这个变量放到 Bank 3
但是:
程序员能够控制数据布局,而数据布局决定地址,地址又决定 Bank。
所以可以间接改变 Bank 映射。
经典方式就是:
text
Padding
原来:
cpp
__shared__ float tile[32][32];
改为:
cpp
__shared__ float tile[32][33];
此时一行跨度不再是:
text
32 个 float
而是:
text
33 个 float
于是同一列:
text
tile[0][0] → 0
tile[1][0] → 33
tile[2][0] → 66
tile[3][0] → 99
映射:
text
0 % 32 → Bank 0
33 % 32 → Bank 1
66 % 32 → Bank 2
99 % 32 → Bank 3
原本:
text
全部撞到 Bank 0
现在被错开成:
text
Bank 0
Bank 1
Bank 2
Bank 3
...
所以:
Bank Conflict 优化并不是程序员直接指定 Bank,而是利用已知的地址映射规则,通过改变数据布局来改变最终 Bank 分布。
三十二、到这里,CUDA 性能优化其实已经出现了一个共同规律
我们前面学了两个看起来完全不同的问题:
text
Global Memory 合并访问
以及:
text
Shared Memory Bank Conflict
但本质上非常像。
对于 Global Memory:
text
程序员不能直接控制显存硬件事务
↓
但是可以控制线程访问地址
↓
让同一个 Warp 地址连续
↓
减少 Memory Transaction
对于 Shared Memory:
text
程序员不能直接指定 Bank
↓
但是可以控制数组布局和线程访问地址
↓
让同一个 Warp 尽量访问不同 Bank
↓
减少 Bank Conflict
所以 CUDA 性能优化中有一个非常重要的思维方式:
不要只看某个 Thread 在做什么,而是经常要站在整个 Warp 的角度观察 32 个线程的访问模式。
这也是为什么:
text
Warp Divergence
Global Memory Coalescing
Shared Memory Bank Conflict
这些概念最终都会回到 Warp。
三十三、把目前所有知识串成一张完整运行图
到这里,可以把整个 CUDA 心智模型压缩成:
text
CPU / Host
│
├── 运行 main()
│
├── cudaMalloc()
│ ↓
│ 申请 GPU Global Memory
│
├── cudaMemcpy()
│ ↓
│ Host → Device
│
├── Kernel<<<grid, block>>>()
│
↓
GPU / Device
│
├── Grid
│ │
│ ├── Block
│ │ ├── Thread
│ │ ├── Thread
│ │ └── ...
│ │
│ └── ...
│
├── Block 分配到 SM
│
├── Block 中 Thread
│ ↓
│ 每 32 个组成 Warp
│
├── Warp Scheduler
│ ↓
│ 从 Resident Warp 中选择 Ready Warp
│
├── Warp 等待
│ ↓
│ 切换其他 Ready Warp
│ ↓
│ Latency Hiding
│
├── SM 资源有限
│ ├── Register
│ ├── Shared Memory
│ ├── Thread / Warp 槽位
│ └── Resident Block 上限
│
├── 实际 Resident Warp
│ ÷
│ 理论最大 Resident Warp
│ ↓
│ Occupancy
│
├── 数据访问
│ │
│ ├── Register
│ │ → Thread 私有
│ │
│ ├── Shared Memory
│ │ → Block 内共享
│ │ → 注意 Bank Conflict
│ │
│ ├── Local Memory
│ │ → Thread 私有
│ │ → 通常访问代价较高
│ │
│ └── Global Memory
│ → 设备级访问
│ → 注意 Coalesced Access
│
↓
Kernel 完成
│
├── cudaMemcpy()
│ ↓
│ Device → Host
│
└── cudaFree()
三十四、一个最基础的 CUDA 代码现在应该怎么看
到这里再回来看:
cpp
__global__ void add(const float* a,
const float* b,
float* c,
int n)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
c[idx] = a[idx] + b[idx];
}
}
就不应该只看到:
text
这是一个数组加法
而应该能映射出:
text
__global__
↓
这是一个由 Host Launch 的 Kernel
blockIdx.x
blockDim.x
threadIdx.x
↓
计算 Thread 的全局数据编号
c[idx] = a[idx] + b[idx]
↓
不同 Thread 执行相同逻辑
但是处理不同数据
同一个 Warp 的 idx 通常连续
↓
a[idx] / b[idx] / c[idx]
通常形成连续 Global Memory 访问
↓
有利于合并访存
而如果未来代码变成:
cpp
__shared__ float cache[256];
cache[threadIdx.x] = a[idx];
__syncthreads();
...
又应该继续映射:
text
Global Memory
↓
协作搬到 Shared Memory
↓
Block 内共享
↓
__syncthreads()
保证跨线程数据依赖
↓
后续反复复用
如果再看到:
cpp
__shared__ float tile[32][33];
也不会再觉得:
text
为什么莫名其妙是 33?
而应该首先想到:
text
可能是在通过 Padding
改变 Shared Memory Bank 映射
避免 Bank Conflict
三十五、总结
学习 CUDA 最容易出现的问题,就是一上来记:
text
Grid
Block
Thread
Warp
SM
Register
Shared Memory
Occupancy
Bank
结果每个概念单独都认识,但是彼此之间完全没有关系。
真正更容易建立心智模型的方式,是沿着程序执行过程理解:
text
为什么要 GPU
↓
CPU 如何发起 GPU 任务
↓
GPU 如何组织大量线程
↓
Block 最终在哪里执行
↓
为什么出现 Warp
↓
为什么分支会影响 Warp
↓
为什么 SM 不能无限放 Block
↓
为什么需要 Resident Warp
↓
为什么 Warp 要区分 Ready / Waiting
↓
为什么需要延迟隐藏
↓
为什么出现 Occupancy
↓
为什么 Occupancy 不是越高越好
↓
数据到底存在哪里
↓
Global Memory 为什么慢
↓
为什么需要合并访存
↓
为什么又要 Shared Memory
↓
为什么 Shared Memory 也不是随便访问
↓
为什么最终出现 Bank Conflict
到这里,我们目前建立起来的 CUDA 模型可以浓缩成一句话:
CPU 负责整体程序控制并 Launch Kernel;GPU 将大量 Thread 组织成 Block,再由 SM 承载并以 Warp 为重要单位推进执行;为了保持高吞吐,既要准备足够的 Resident Warp 隐藏等待延迟,又要控制 Register、Shared Memory 等资源占用,同时让 Global Memory 和 Shared Memory 的访问模式尽可能符合 GPU 的硬件并行结构。
后续再学习 CUDA 算子时,就可以继续在这套模型上向下增加:
text
矩阵乘法 Tiling
Reduction
Atomic
Stream
异步拷贝
CUDA Event
Tensor Core
算子融合
而不需要重新从一堆孤立名词开始记。
