CUDA内存模型:全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存

说到CUDA编程,内存模型这块儿,我敢说是最容易被忽视、却又最影响性能的环节。很多人一上来就写kernel,跑得慢就怪显卡不行。其实呢,很多时候是内存访问没搞对。今天咱们就把CUDA的内存体系彻底捋一遍。

内存层次结构:一张图看懂全局

先看这张图,我亲手画的。它把CUDA里所有内存类型和它们的关系讲得明明白白。

CUDA内存层次结构图 全局内存 (Global Memory) 容量大(GB级) · 延迟高(400-800 cycles) · 所有线程可访问 常量内存 (Constant Memory) 容量64KB · 只读 · 有缓存 所有线程访问同一地址时极快 适合存储查找表、系数等 纹理内存 (Texture Memory) 只读 · 有缓存 · 支持插值 适合空间局部性强的访问 图像处理、矩阵转置常用 共享内存 (Shared Memory) 容量小(48KB/block可配置) · 延迟低(~30 cycles) · 同一block内线程共享 程序员手动管理 · 避免bank conflict是关键 寄存器 (Register) + 本地内存 (Local Memory) 寄存器最快(0 cycle) · 本地内存本质是全局内存(慢!) 说明:越靠近内层速度越快、容量越小、作用域越小

这张图我每次培训都会拿出来。你看,从外到内,速度越来越快,容量越来越小。全局内存在最外层,是所有线程都能访问的「大仓库」。但访问一次要几百个时钟周期,慢得让人心疼。

全局内存:最常用也最容易被坑

全局内存就是显存。你在CPU端用cudaMalloc分配的那块空间,就是它。所有线程都能读写,但延迟很高。

合并访问是全局内存性能的关键。什么意思呢?就是让同一个warp里的32个线程,访问连续的内存地址。硬件会把多个访问合并成一次或几次事务。

合并访问示例:

// ✅ 合并访问:线程0访问data[0],线程1访问data[1]...
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float val = data[idx];  // 连续访问,效率高

// ❌ 非合并访问:线程0访问data[0],线程1访问data[stride]...
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float val = data[idx * stride];  // 跨步访问,效率低

我有个项目做矩阵乘法,一开始没注意合并访问,性能只有理论峰值的10%。后来把数据布局从行主序改成列主序,配合正确的索引方式,性能直接翻了6倍。嗯,合并访问就是这么重要。

共享内存:程序员手中的利器

共享内存是片上的,速度比全局内存快一个数量级。但它需要你手动管理——说白了就是你自己负责把数据从全局内存搬进来,算完再搬回去。

共享内存按bank组织。每个bank每周期只能服务一个地址。如果多个线程访问同一个bank的不同地址,就发生bank conflict,访问会串行化。

避坑指南:我曾经在写卷积算子时,因为共享内存的stride设置不当,导致16路bank conflict。性能从预期的80%峰值掉到了30%。后来把数据填充(padding)了一下,让每个线程访问的地址错开,问题就解决了。

// 共享内存声明
__shared__ float s_data[256];

// 避免bank conflict的技巧
// 如果每个线程访问s_data[threadIdx.x],没有冲突
// 但如果访问s_data[threadIdx.x * 2],就可能产生冲突
// 可以加padding:__shared__ float s_data[256 + 1];

寄存器与本地内存

寄存器是CUDA里最快的存储,没有之一。每个线程的私有变量,只要放得下,编译器都会优先分配到寄存器。

但寄存器数量有限。每个SM的寄存器总数是固定的(比如Kepler架构是65536个)。如果每个线程用的寄存器太多,就会降低occupancy(驻留的warp数量)。

本地内存是个坑。它名义上是「本地」的,实际上存储在全局内存里。当寄存器不够用,或者访问数组时索引不是编译期常量,编译器就会把变量「溢出」到本地内存。你想想看,本来以为很快的变量,突然变成了几百个周期的访问,性能能不崩吗?

注意:用nvcc --ptxas-options=-v编译时,会输出每个kernel的寄存器使用量和本地内存使用量。如果看到lmem数值很大,就要警惕了。我一般会控制每个线程的寄存器在32个以内,保证occupancy不低于50%。

常量内存与纹理内存

常量内存容量只有64KB,但有专门的缓存。当所有线程访问同一个常量地址时,速度极快——只需要一个周期。但如果每个线程访问不同的地址,那就惨了,会串行化。

纹理内存呢,我主要用在图像处理上。它支持硬件插值,而且对二维空间局部性有优化。比如做图像缩放、矩阵转置,用纹理内存往往比全局内存快不少。

内存类型 容量 延迟 作用域 缓存 典型用途
全局内存 GB级 400-800 cycles 所有线程 L1/L2 主要数据存储
共享内存 48KB/block ~30 cycles 同一block 无(手动管理) 数据重用、规约
寄存器 255个/线程 0 cycle 单个线程 无 局部变量
本地内存 同全局内存 400-800 cycles 单个线程 L1/L2 寄存器溢出
常量内存 64KB 1 cycle(同地址) 所有线程 专用常量缓存 查找表、系数
纹理内存 同全局内存 取决于缓存命中 所有线程 纹理缓存 图像、空间局部性

内存访问模式:别让硬件白干活

最后聊一下访问模式。GPU是SIMT架构,一个warp里的32个线程执行同一条指令。如果它们访问的内存地址是连续的、对齐的,硬件就能用最少的事务搞定。

我个人习惯在写kernel之前,先画个内存访问图。把线程ID和地址的对应关系画出来,一眼就能看出是不是合并访问。这招虽然土,但特别管用。

总结一下我的经验:

  • 全局内存:保证warp内线程访问连续地址
  • 共享内存:注意bank conflict,必要时加padding
  • 寄存器:别用太多,影响occupancy
  • 常量内存:所有线程读同一个地址时用
  • 纹理内存:二维空间局部性强时用

内存模型这块儿,说白了就是「数据在哪、怎么搬、怎么访问」三个问题。搞明白了,你的CUDA程序性能至少能翻一倍。我见过太多人在这上面栽跟头了,希望你能少走弯路。

想要更多资料,联系可可