全局内存(Global Memory):显存的基础,容量最大但延迟最高

全局内存,说白了就是咱们常说的显存。它是GPU里容量最大的存储区域,所有线程都能访问。但代价也很明显——延迟最高。

我刚开始接触CUDA时,总觉得全局内存随便用就行。直到有一次,一个简单的矩阵乘法,我直接拿全局内存硬怼,结果性能惨不忍睹。嗯,从那以后我才真正重视起这个“大块头”的脾气。

全局内存的本质

全局内存位于GPU芯片外部,通过高速总线与SM连接。你可以把它想象成一个巨大的仓库,所有线程都能从里面取货、放货。但每次取货都要走一段很长的路——这就是延迟的来源。

关键特性:

  • 容量最大:从几GB到几十GB不等,取决于显卡型号
  • 延迟最高:典型延迟在400-800个时钟周期
  • 所有线程可访问:不分SM、不分block,全局共享
  • 持久性:数据在kernel执行期间一直存在

为什么全局内存这么慢?

你想想看,GPU芯片内部跑在1-2GHz的频率上,而全局内存的DRAM颗粒跑在几GHz。但问题不在于频率,而在于物理距离和协议开销。

从SM发出一个内存请求,需要经过:

  1. SM内部的load/store单元
  2. L1缓存(如果miss)
  3. L2缓存(如果miss)
  4. 内存控制器
  5. DRAM颗粒
  6. 数据原路返回

这一来一回,几百个周期就没了。我在项目中遇到过,一个kernel如果频繁访问全局内存,性能瓶颈几乎100%在这里。

全局内存的访问模式

全局内存虽然慢,但有个重要的优化手段——合并访问。说白了,就是让相邻的线程访问相邻的地址。

为什么合并访问这么重要?因为GPU的内存控制器一次读取一个128字节的cache line。如果32个线程(一个warp)访问的地址是连续的,那一次内存事务就能搞定。否则,就要多次事务,性能直接打折扣。

我的经验:写kernel时,我习惯让线程索引和内存地址一一对应。比如thread 0访问地址0,thread 1访问地址1,以此类推。这样基本能保证合并访问。

代码示例:合并访问 vs 非合并访问

// 合并访问(推荐)
__global__ void coalesced_access(float *A, float *B, float *C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];  // 相邻线程访问相邻地址
    }
}

// 非合并访问(避免)
__global__ void strided_access(float *A, float *B, float *C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        C[idx] = A[idx * 2] + B[idx * 2];  // 跨步访问,破坏合并
    }
}

你看,第一个例子中,thread 0访问A[0],thread 1访问A[1],完全连续。第二个例子中,thread 0访问A[0],thread 1访问A[2],中间跳过了A[1]。这会导致内存控制器每次只用到一半的带宽。

全局内存的带宽

全局内存的带宽其实不低。以NVIDIA A100为例,HBM2e显存带宽超过2TB/s。但问题是,这个带宽是所有SM共享的。如果多个SM同时发起大量内存请求,带宽就成了瓶颈。

GPU型号 显存类型 带宽 容量
A100 HBM2e 2.0 TB/s 40/80 GB
V100 HBM2 900 GB/s 16/32 GB
RTX 4090 GDDR6X 1.0 TB/s 24 GB

我曾经优化过一个图像处理kernel,一开始带宽利用率只有30%。后来通过调整数据布局和访问模式,把利用率提到了80%以上。性能直接翻了2倍多。

全局内存的避坑指南

我曾经踩过的坑:

  • 频繁的原子操作:全局内存的原子操作非常慢,因为要保证所有线程的可见性。能用共享内存的原子操作就别用全局的。
  • 未对齐的访问:如果地址不是32字节对齐,内存控制器会多读一次。我习惯用cudaMalloc分配的内存,它默认对齐到256字节。
  • 过度使用全局内存:能放共享内存或寄存器的数据,就别放全局内存。我见过有人把循环变量都放全局内存,那性能简直灾难。

全局内存的生命周期

全局内存的生命周期由程序员控制。用cudaMalloc分配,用cudaFree释放。数据在kernel执行期间一直存在,直到你显式释放或程序结束。

这里有个小技巧:我习惯在程序开始时一次性分配好所有需要的全局内存,而不是在循环里反复分配释放。因为cudaMalloc本身也有开销,频繁调用会影响性能。

全局内存与缓存的配合

全局内存不是孤立存在的。它和L1、L2缓存配合工作。如果一个数据被频繁访问,它会被缓存到L1或L2中,后续访问就快多了。

但要注意,缓存是透明的。你不能强制数据留在缓存里,也不能强制它被踢出。NVIDIA提供了__ldg()指令,可以绕过L1直接读全局内存,适合那些只读一次的大数组。

我的建议:对于只读且访问模式规律的数据,用const __restrict__修饰指针。这能告诉编译器数据不会别名,有助于生成更高效的代码。

全局内存的典型应用场景

  • 输入数据:从CPU传过来的数据,先放全局内存
  • 输出结果:kernel计算完的结果,写回全局内存
  • 大数组:超过共享内存容量的数据,只能放全局内存
  • 持久化数据:需要在多个kernel之间共享的数据

说白了,全局内存就是GPU的“主存”。它容量大、延迟高,但所有线程都能访问。用好它的关键是——减少访问次数,优化访问模式

全局内存访问路径 SM L1缓存 L2缓存 内存控制器 全局内存 ~30周期 ~200周期 ~400周期 访问路径:SM → L1 → L2 → 内存控制器 → 全局内存 每次miss都会走更长的路径,延迟逐级增加

这张图展示了全局内存的访问路径。每次内存请求都要经过多层缓存和控制器。如果L1命中,延迟只有几十个周期。如果一路miss到全局内存,那就是几百个周期。所以,尽量让数据留在缓存里,这是性能优化的核心思路。

好了,全局内存就讲这么多。记住它的特点:容量大、延迟高、所有线程可访问。用好合并访问,避开原子操作,配合缓存策略,你就能把这块“大块头”用得得心应手。

专注资料整理