【CUDA入门系列】CUDA 执行模型与性能优化:SM 调度、Occupancy、内存访问与 Bank Conflict

🔥 本文专栏: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
算子融合

而不需要重新从一堆孤立名词开始记。


相关推荐
银河技术1 小时前
TB级文本去重实战:从单机 OOM 到 Spark / Ray 分布式架构的工程演进
分布式·微服务·重构·架构·spark·llm·rag
施棠海1 小时前
多 Agent 团队编排系统的设计与实测:agent-workflow
python·ai·架构
xcl09259 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
萧瑟余晖12 小时前
Dubbo SPI扩展机制详解
架构·dubbo
Shulex12 小时前
面向跨境电商多渠道消息系统的技术架构:亚马逊站内信合规对接与自动化执行链路设计
运维·架构·自动化
吴建旭 智宅焕14 小时前
AI搜索时代的智能家居交付知识架构:官网作为可信一手信息源与全国交付基础设施
人工智能·架构·智能家居
孟健14 小时前
出海开发者资金合规:从港卡结汇到完税申报实操
后端·架构
梦帮科技14 小时前
领域认知知识库图谱注入:从双式记账图网络到高质量问答对自动化合成流水线
运维·网络·数据库·人工智能·矩阵·架构·自动化
海宇服务14 小时前
零信任架构实战:基于海宇车型识别精准构建自动化定损网关
运维·人工智能·架构·自动化