3、GPU内存层次结构:全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存的特点与访问延迟对比

说到GPU编程,内存模型绝对是绕不开的核心。我见过太多新手,写出来的kernel跑得比CPU还慢,问题十有八九出在内存访问上。

GPU的内存不是一块铁板,它分了好几个层次。每个层次的速度、容量、用途都不一样。你想想看,如果所有数据都放在同一个地方,那高带宽的硬件资源就浪费了。

下面这张图,是我自己总结的GPU内存层次结构。你看一眼,心里就有谱了。

GPU内存层次结构 全局内存 (Global Memory) 容量: GB级 | 延迟: 400-800 cycles | 所有线程可访问 常量内存 (Constant Memory) 容量: 64KB | 延迟: ~5-10 cycles (缓存命中) 只读 | 广播机制 | 适合查找表 纹理内存 (Texture Memory) 容量: 取决于纹理对象 | 延迟: ~100-200 cycles 空间局部性优化 | 硬件插值 L2 缓存 (L2 Cache) 容量: 几百KB ~ 几十MB | 延迟: ~200-400 cycles 共享内存 / L1 缓存 (Shared Memory / L1 Cache) 容量: 每个SM 48KB ~ 128KB (可配置) | 延迟: ~20-30 cycles 同一Block内线程共享 | 手动管理 寄存器 (Registers) 每个线程私有 | 延迟: 1 cycle | 容量: 每个SM 64K~256K 延迟: 从下往上递减 容量: 从上往下递减

这张图里,越靠近底部的内存,速度越快,但容量越小。寄存器最快,全局内存最慢。这个道理很简单,但实际用起来,坑特别多。

3.1 全局内存:最慢但最大

全局内存是GPU里最大的存储空间,通常有GB级别。所有线程都能读写它。但代价就是慢——延迟高达400到800个时钟周期。

我刚开始做CUDA优化时,就犯过一个低级错误。我把一个频繁访问的变量放在全局内存里,结果kernel跑得比蜗牛还慢。后来才发现,这玩意儿每次访问都要等几百个周期。

关键点:全局内存访问要尽量合并(coalesced)。相邻线程访问相邻地址,硬件会把多次访问合并成一次。这是性能优化的第一课。

举个例子,如果你让线程0访问地址0,线程1访问地址1,线程2访问地址2……这样就是合并访问。反过来,线程0访问地址0,线程1访问地址100,线程2访问地址200……那就完蛋了,每次访问都是独立的,带宽直接打骨折。

3.2 共享内存:手动控制的高速缓存

共享内存是GPU里最灵活、也最考验程序员功底的内存。它位于芯片内部,延迟只有20到30个周期,比全局内存快了一个数量级。

但共享内存有两个限制:

  • 容量小:每个SM通常只有48KB到128KB,具体看GPU架构
  • 作用域有限:只有同一个Block内的线程才能访问

共享内存需要程序员手动管理。说白了,你得自己把数据从全局内存搬到共享内存里,用完了再写回去。这听起来麻烦,但性能收益巨大。

我的习惯:在做矩阵乘法、卷积这类计算密集型任务时,我通常会先用共享内存做数据分块(tiling)。把一块数据加载到共享内存里,然后让所有线程反复使用它。这样全局内存的访问次数能减少几十倍。

3.3 寄存器:最快的存储

寄存器是每个线程私有的,延迟只有1个时钟周期。说白了,这就是GPU里最快的存储,没有之一。

但寄存器数量有限。每个SM的寄存器总数是固定的,比如64K或128K。如果你每个线程用的寄存器太多,能同时运行的线程数就会减少。这就是所谓的「寄存器压力」。

我曾经遇到过一个情况:一个kernel用了太多局部变量,结果每个线程占了40多个寄存器。SM里能同时跑的线程数直接减半,occupancy掉到了50%以下。性能反而比优化前更差了。

注意:编译器有时候会自作聪明地把变量「溢出」(spill)到本地内存里。本地内存虽然名字带「本地」,但实际上走的还是全局内存的路径,延迟很高。如果你发现kernel性能异常,可以用 --ptxas-options=-v 编译选项看看寄存器使用情况。

3.4 本地内存:寄存器的「备胎」

本地内存听起来像是片上的存储,但实际上它是在全局内存里划出来的一块空间。每个线程私有的,但访问延迟和全局内存一样高。

什么时候会用本地内存?当寄存器不够用的时候。比如你声明了一个大数组,或者编译器觉得某个变量不适合放寄存器里,它就会把数据放到本地内存里。

嗯,这里要注意:本地内存的访问虽然也是私有的,但它没有缓存机制。频繁访问本地内存,性能会急剧下降。

3.5 常量内存:只读且带广播

常量内存只有64KB,但它的访问模式很特别。当同一个warp里的所有线程访问同一个常量地址时,硬件只需要一次读取,然后广播给所有线程。延迟只有5到10个周期(如果缓存命中)。

我一般在两种场景下用常量内存:

  • 查找表(LUT),比如数学函数的系数表
  • 所有线程都用到的固定参数,比如滤波器的权重
避坑指南:如果warp里的线程访问不同的常量地址,那广播机制就失效了。每个访问都会串行化,性能反而比全局内存还差。所以常量内存只适合「所有人读同一个值」的场景。

3.6 纹理内存:为空间局部性而生

纹理内存最初是为图形渲染设计的,但它也能用在通用计算里。它的特点是:

  • 有专门的缓存,优化空间局部性
  • 支持硬件插值(比如双线性插值)
  • 对二维/三维访问模式特别友好

延迟大概在100到200个周期,比全局内存快,但比共享内存慢。如果你处理的是图像数据,或者访问模式有很强的空间局部性,纹理内存是个不错的选择。

我记得有一次做图像处理,用全局内存读像素数据,性能一直上不去。换成纹理内存后,缓存命中率大幅提升,整体速度提升了将近一倍。

3.7 延迟对比一览

下面这张表,是我根据实际测试和官方文档整理出来的。你可以把它当作参考手册。

内存类型 容量 延迟(时钟周期) 作用域 缓存
寄存器 每个SM 64K~256K 1 线程私有
共享内存/L1 每个SM 48KB~128KB 20~30 Block内共享 手动管理
常量内存 64KB 5~10(缓存命中) 所有线程只读 专用常量缓存
纹理内存 取决于纹理对象 100~200 所有线程只读 专用纹理缓存
L2缓存 几百KB~几十MB 200~400 所有线程 硬件管理
全局内存 GB级 400~800 所有线程 L2缓存
本地内存 同全局内存 400~800 线程私有 无(走全局内存路径)

看到这个表,你应该明白了:写CUDA程序,本质上就是在做「数据放置」的决策。把频繁访问的数据放到快的内存里,把不常用的数据放到慢的内存里。就这么简单,但做起来需要经验。

我的建议:刚开始做优化时,先用profiler看看kernel的瓶颈在哪里。如果是内存访问,再具体分析是哪一层内存出了问题。别一上来就想着用共享内存,有时候调整一下访问模式,让全局内存访问合并,效果就很明显了。

好了,这一章的内容就到这里。内存层次结构是GPU编程的基石,理解透了,后面的优化技巧才能用得上。

专注资料整理