【CUDA编程入门②】CUDA性能优化基础:内存访问、Warp Shuffle与Occupancy调优
本文是「CUDA编程与HPC高性能计算」系列的第二篇。会写CUDA kernel只是第一步,写得快才是真本事。CUDA性能优化说复杂也复杂,说简单也简单------核心就是两件事:内存和计算。本文把最基础、最常用的优化技术一网打尽,掌握这些,你的kernel性能就能上一个大台阶。
一、写在前面:优化之前先判断瓶颈
在讲优化技术之前,先强调一个最重要的原则:
先判断瓶颈,再做优化。不知道瓶颈在哪就瞎优化,90%都是无用功。
CUDA kernel的性能瓶颈通常分两类:
| 瓶颈类型 | 含义 | 优化方向 |
|---|---|---|
| Memory-Bound(访存瓶颈) | 显存带宽不够,喂不饱计算单元 | 减少访存、优化访存模式、提高数据复用 |
| Compute-Bound(计算瓶颈) | 计算单元不够用,带宽还有富余 | 提高算力利用率、用Tensor Core、减少计算量 |
怎么判断? 用Nsight Compute(ncu)看:
- 显存带宽利用率接近峰值 → memory-bound
- 计算单元(SM/Tensor Core)利用率接近峰值 → compute-bound
或者用算术强度(Arithmetic Intensity)估算:
算术强度 = FLOPs / 访存字节数
算术强度低 → memory-bound;算术强度高 → compute-bound。
先搞清楚"卡在哪",再对症下药。
先上一张优化技术全景图:

二、内存访问优化:CUDA优化的第一课
大部分kernel(尤其是初学者写的)都是memory-bound的------所以内存访问优化是重中之重。
2.1 合并访问(Coalesced Access)
这是最基础、也是性价比最高的优化。
原理:一个Warp(32个线程)如果访问一段连续的全局内存,硬件可以把32个请求合并成少数几个cache line传输------带宽利用率最高。
text
✅ 合并访问(理想):
Thread 0 → addr + 0
Thread 1 → addr + 4
Thread 2 → addr + 8
...
Thread 31 → addr + 124
→ 32个请求合并为1-2个cache line传输
❌ 跨步访问(糟糕):
Thread 0 → addr + 0
Thread 1 → addr + 4096
Thread 2 → addr + 8192
...
→ 每个线程一个cache line,带宽利用率极低
怎么做到合并访问?
- 让thread i访问第i个连续的元素
- 也就是:
data[blockIdx.x * blockDim.x + threadIdx.x] - 这是最自然的写法,但很多人写着写着就歪了------比如按列访问矩阵、跨步访问数组
💡 经验法则:90%的情况下,你的kernel应该是合并访问的。如果不是,先想想能不能改写成合并的。
2.2 共享内存预取(Shared Memory Tiling)
如果数据需要多次复用,那每次都从全局内存读就太亏了。这时候可以:
text
全局内存 → 共享内存 → 计算
一次高延迟访问 多次低延迟访问
先把数据从全局内存加载到共享内存(慢,但只做一次),然后线程从共享内存读取(快,可以读很多次)。
典型应用场景:
- 矩阵乘法分块(GEMM Tiling)
- 卷积im2col
- Stencil计算(每个点需要周围点的数据)
关键前提:数据复用率要高。如果每个数据只用一次,那搬到共享内存反而多此一举------直接合并读全局内存更快。
2.3 向量化加载
使用float4、int4等向量类型,一次加载多个元素:
c
float4 vec = reinterpret_cast<float4*>(gmem)[idx];
// 一次加载4个float:vec.x, vec.y, vec.z, vec.w
好处:
- 减少指令数量(1条指令顶4条)
- 减少访存事务数
- 有助于编译器做更好的优化
什么情况下不能用?
- 数据不是对齐的(比如数组起始地址不是16字节对齐)
- 数据量不是4的倍数(边界处理麻烦)
- 访问模式不连续(向量化也救不了)
三、Warp Shuffle:Warp内通信的"神器"
3.1 什么是Warp Shuffle?
Warp Shuffle是一种特殊指令,允许同一个Warp内的线程直接交换寄存器中的数据------不需要经过共享内存,也不需要同步。
你想想,之前Warp内线程要交换数据怎么办?
- 线程A把数据写到共享内存
__syncthreads()同步一下- 线程B从共享内存读出来
又要共享内存,又要同步,麻烦还慢。
有了Warp Shuffle,直接在寄存器层面交换------又快又省事。
3.2 常用指令
c
__shfl_sync(mask, var, srcLane); // 从指定lane读取
__shfl_up_sync(mask, var, delta); // 从 lane-delta 读取(往上)
__shfl_down_sync(mask, var, delta); // 从 lane+delta 读取(往下)
__shfl_xor_sync(mask, var, laneMask); // 蝴蝶交换(按位异或)
举个例子,__shfl_down_sync(0xFFFFFFFF, val, 16):
- lane 0 从 lane 16 读数据
- lane 1 从 lane 17 读数据
- ...
- lane 15 从 lane 31 读数据
- lane 16-31 从哪里读?超出范围了,读不到(返回原数据或未定义)
3.3 优势与局限
| 优势 | 局限 |
|---|---|
| 不需要共享内存 | 只能在一个Warp内通信(32个线程) |
不需要__syncthreads() |
跨Warp通信还是得用共享内存 |
| 速度快(寄存器级) | 只能交换32-bit或64-bit的数据 |
典型应用:
- Reduce归约(Warp内求和)
- Scan(前缀和)
- 矩阵乘法累加(Warp内数据交换)
后面讲Reduce算子的时候,我们会看到Warp Shuffle的具体用法。
四、Occupancy与资源分配
4.1 什么是Occupancy?
复习一下:
Occupancy = 每个SM上实际驻留的Warp数 / 最大可驻留Warp数
每个SM的资源是有限的(以A100为例):
| 资源 | 典型值 |
|---|---|
| 最大驻留Warp数 | 64 |
| 最大驻留线程数 | 2048 |
| 最大共享内存 | 164 KB |
| 寄存器文件 | 256 KB |
如果每个线程用64个寄存器,每个Block 256线程:
- 一个Block用的寄存器 = 256 × 64 = 16384 个 = 64KB
- 一个SM最多同时驻留 256KB / 64KB = 4个Block = 1024线程 = 32个Warp
- Occupancy = 32 / 64 = 50%
4.2 怎么提高Occupancy?
-
减少寄存器使用
- 编译选项:
--maxrregcount限制每个线程最多用多少寄存器 - 代码层面:减少局部变量、复用变量
- 或者用
__launch_bounds__提示编译器
- 编译选项:
-
减少共享内存使用
- 优化Tile大小
- 用寄存器代替共享内存(如果数据量小)
-
调整Block大小
- 128或256通常是比较好的选择
- 太小:调度开销大;太大:资源不够用
4.3 常见误区:Occupancy越高越好?
错!
Occupancy高有助于隐藏内存延迟(一些Warp等内存的时候,其他Warp可以干活),但:
- 如果kernel已经是compute-bound,提高Occupancy收益很小
- 为了提高Occupancy而强制减少寄存器,可能导致寄存器溢出(反而更慢)
- 有些kernel本身计算密集,低Occupancy也能跑满算力
结论:Occupancy是参考指标,不是越高越好。要结合具体kernel分析,以实际性能为准。
五、同步与原子操作
5.1 同步原语
c
__syncthreads(); // Block内所有线程同步(Barrier)
__syncwarp(mask); // Warp内同步
__threadfence(); // 全局内存顺序保证
__threadfence_block(); // Block内顺序保证
__syncthreads()是最常用的------同Block内所有线程都走到这里才继续往下走。
典型场景:
- 所有线程把数据写到共享内存后,同步一下,然后再读(确保大家都写完了)
- 分阶段的计算,确保前一阶段都完成了再进入下一阶段
注意 :__syncthreads()必须所有线程都能到达------如果放在if分支里,而且不是所有线程都走这个分支,会死锁!
5.2 原子操作
c
atomicAdd(addr, val); // 原子加
atomicMax(addr, val); // 原子取最大值
atomicCAS(addr, compare, val); // 原子比较并交换
原子操作保证"读-改-写"是原子的------不会被其他线程打断。
但是!原子操作是全局序列化的------很多线程同时做原子操作,会排队,性能很差。
怎么减少原子操作?
- 先局部规约,再全局写:先在共享内存/Warp内做归约,得到一个部分结果,再用一次原子操作写回全局内存
- 用Warp Shuffle:Warp内先规约,减少原子次数
- 多级归约:先在Block内归约,再做第二级归约,最后只有一个结果
💡 经验法则:原子操作能不用就不用,能用局部归约代替就代替。尤其是大量线程同时做atomicAdd,性能会掉得很惨。
六、优化决策路线图
最后给大家一个优化的"路线图",遇到慢的kernel按这个顺序来:
text
kernel慢?
│
├─ 第一步:profile定位
│ ├─ 用nsys看是不是GPU在等CPU/数据
│ └─ 用ncu看哪个部分最慢
│
├─ 第二步:看访存
│ ├─ 是合并访问吗?→ 不是就改成合并的
│ ├─ 数据有复用吗?→ 有就用共享内存预取
│ └─ 能向量化吗?→ 能就用float4等
│
├─ 第三步:看计算
│ ├─ 能用Tensor Core吗?→ 能用就用(后面讲)
│ ├─ 有多余的计算吗?→ 能省就省
│ └─ 分支发散严重吗?→ 优化分支
│
├─ 第四步:看资源
│ ├─ Occupancy太低?→ 减少寄存器/共享内存
│ ├─ 寄存器溢出?→ 减少局部变量
│ └─ Bank Conflict?→ 调整共享内存访问模式
│
└─ 第五步:看同步/原子
├─ 同步太多?→ 减少同步次数
└─ 原子操作多?→ 先局部规约
七、总结一下
今天这篇文章,我们讲了CUDA性能优化的基础技术:
- 内存访问优化:合并访问(最重要)、共享内存预取(数据复用)、向量化加载
- Warp Shuffle:Warp内高效通信,无需共享内存和同步
- Occupancy:受寄存器、共享内存、Block大小限制,不是越高越好
- 同步与原子操作:慎用,过度同步和原子竞争会严重影响性能
- 优化路线图:先profile定位,再从访存、计算、资源、同步逐步优化
下一篇预告:我们来实战第一个经典算子------Reduce(归约)。从朴素的全局原子加,到共享内存树形归约,再到Warp Shuffle优化,一步步把Reduce做到高性能。
📚 本系列导航:
- 第1篇:CUDA编程体系全景:线程模型、内存层次与Warp执行机制
- 第2篇:CUDA性能优化基础:内存访问、Warp Shuffle与Occupancy调优(本文)
- 第3篇:经典算子实战:Reduce归约------从朴素到高性能
- 第4篇:经典算子实战:GEMM矩阵乘法------从Tiling到Tensor Core
- 更多内容持续更新中......