1. CUDA内存模型全景:全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存的层次结构与带宽特性
做CUDA压测这些年,我见过太多人一上来就写cudaMemcpy,然后抱怨程序跑不快。其实说白了,你不了解GPU的内存体系,就像开赛车却不看仪表盘——能跑,但绝对跑不远。
今天咱们就把CUDA的内存模型彻底捋一遍。我个人习惯把这六个内存区域分成两类:程序员可控的和硬件自动管理的。前者是共享内存、常量内存、纹理内存;后者是全局内存、寄存器、本地内存。嗯,这个分类方式帮我避过不少坑。
1.1 全局内存:最大的池子,最慢的路
全局内存是GPU里容量最大的存储区域,通常有4GB到80GB不等。但大归大,它的延迟也是最高的——大约400到800个时钟周期。
我曾经在压测一个图像处理管线时,发现90%的时间都花在全局内存访问上。后来一查,原来是没做合并访问(coalesced access)。
关键特性:
- 容量:最大(显存大小)
- 延迟:400-800 cycles
- 带宽:取决于显存类型(GDDR6/HBM2e)
- 可见性:所有线程可见
- 缓存:L1/L2缓存(取决于架构)
这里有个血泪教训:全局内存的带宽利用率取决于访问模式。如果相邻线程访问相邻地址,硬件会把多次访问合并成一次大事务。反之,如果线程访问的是分散的地址,带宽利用率会暴跌到个位数百分比。
// 好的访问模式:合并访问
__global__ void coalesced_kernel(float* data, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
data[idx] = data[idx] * 2.0f; // 相邻线程访问相邻地址
}
}
// 坏的访问模式:非合并访问
__global__ void strided_kernel(float* data, int N, int stride) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
data[idx * stride] = data[idx * stride] * 2.0f; // 线程间有间隔
}
}
1.2 共享内存:程序员的手动缓存
共享内存是我个人最喜欢的特性。它位于芯片内部,延迟只有全局内存的十分之一左右(约20-30 cycles)。但容量很小,每个SM上通常只有48KB到164KB。
你想想看,如果能把频繁访问的数据从全局内存搬到共享内存里,性能提升是立竿见影的。我在做矩阵乘法压测时,用共享内存做分块(tiling),吞吐量直接翻了6倍。
使用技巧:
- 共享内存按block分配,同一个block内的线程可以共享
- 需要手动用
__syncthreads()做同步 - 小心bank conflict——多个线程同时访问同一个bank会导致串行化
// 共享内存分块示例
__global__ void tiled_matmul(float* A, float* B, float* C, int N) {
__shared__ float As[TILE_SIZE][TILE_SIZE];
__shared__ float Bs[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < N / TILE_SIZE; t++) {
As[threadIdx.y][threadIdx.x] = A[row * N + t * TILE_SIZE + threadIdx.x];
Bs[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * N + col];
__syncthreads();
for (int k = 0; k < TILE_SIZE; k++) {
sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
}
__syncthreads();
}
C[row * N + col] = sum;
}
1.3 寄存器:最快但最稀缺
寄存器是GPU里最快的存储,延迟为0(一个周期内完成)。但每个线程能用的寄存器数量有限——通常是32到255个,取决于架构和编译器设置。
我记得有一次压测,kernel的寄存器使用量超过了限制,结果编译器把多余的变量溢出(spill)到了本地内存。性能直接腰斩。嗯,这就是为什么我总说:别在kernel里声明太多局部变量。
避坑指南:
- 用
--maxrregcount编译选项控制寄存器数量 - 寄存器溢出到本地内存,性能损失巨大
- 每个SM的寄存器总数固定(如65536个),线程越多,每个线程分到的越少
1.4 本地内存:寄存器的备胎
本地内存听起来像"本地",但其实它物理上位于全局内存里。当寄存器不够用时,编译器会自动把变量放到本地内存。它的延迟和全局内存一样高。
我曾经在分析一个kernel的性能瓶颈时,发现local memory traffic占了总内存访问的30%。一看代码,里面有个大数组float temp[256]——这玩意儿根本放不进寄存器,全被溢出到本地内存了。
1.5 常量内存:只读且带缓存
常量内存是全局内存的一个特殊区域,大小为64KB。它有两个特点:一是只读,二是硬件会为它提供常量缓存(constant cache)。
为什么要有常量缓存?因为当同一个warp内的所有线程访问同一个常量地址时,硬件只需要一次读取就能广播给所有线程。这在做查找表(LUT)或者参数传递时特别有用。
// 常量内存声明
__constant__ float params[256];
__global__ void kernel_with_const(float* data, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
data[idx] = data[idx] * params[0] + params[1]; // 所有线程访问相同地址
}
}
1.6 纹理内存:为空间局部性而生
纹理内存最初是为图形学设计的,但它对某些计算场景也很有用。它利用空间局部性——如果线程访问了地址A,那么附近的地址很可能也会被访问。硬件会预取这些数据到纹理缓存中。
我在做图像处理压测时,用纹理内存代替全局内存做双线性插值,带宽利用率提升了40%。不过要注意,纹理内存的写入操作很慢,它更适合只读且访问模式不规则的场景。
1.7 带宽特性对比
下面这张表是我压测时常用的参考数据。注意,具体数值因GPU架构而异,但相对关系是固定的。
| 内存类型 | 典型容量 | 延迟 | 带宽(相对) | 缓存 | 作用域 |
|---|---|---|---|---|---|
| 寄存器 | 256KB/SM | 1 cycle | 最高 | 无 | 线程私有 |
| 共享内存 | 48-164KB/SM | 20-30 cycles | 高 | 无 | Block内共享 |
| 常量内存 | 64KB | ~200 cycles(缓存命中) | 中 | 常量缓存 | 全局只读 |
| 纹理内存 | 取决于显存 | ~200 cycles(缓存命中) | 中 | 纹理缓存 | 全局只读 |
| 本地内存 | 取决于显存 | 400-800 cycles | 低 | L1/L2 | 线程私有 |
| 全局内存 | 4-80GB | 400-800 cycles | 低 | L1/L2 | 全局可见 |
1.8 内存层次结构全景图
下面这张SVG图是我自己画的,把整个内存层次结构串起来了。你看,从芯片内部的寄存器到片外的全局内存,延迟和容量呈反比——这就是经典的存储层次。
从这张图你能看到,越靠近芯片核心(顶部),速度越快但容量越小;越往外(底部),容量越大但速度越慢。做压测优化的核心思路就是:把热点数据尽量往顶部搬。
我的经验总结:
- 全局内存访问一定要做合并,否则带宽利用率惨不忍睹
- 共享内存是性价比最高的优化手段,但要注意bank conflict
- 别让寄存器溢出到本地内存——用
--ptxas-options=-v检查 - 常量内存适合warp内所有线程访问同一地址的场景
- 纹理内存适合二维空间局部性强的只读访问
好了,这一章的内容就到这里。记住,理解内存模型是CUDA优化的第一步,也是最关键的一步。下一章咱们会深入聊全局内存的合并访问——那才是真正拉开性能差距的地方。