GPU内存模型:全局内存、共享内存、寄存器与缓存层次

GPU 的高吞吐量不仅来自数千个计算核心,更依赖一套层次分明、带宽差异巨大的内存体系。理解这一模型,是编写高性能 CUDA/HIP 内核的前提。本章从硬件视角拆解各级内存的物理位置、容量、延迟与用途,并给出可落地的优化策略。

4.1 内存层次全景:从寄存器到全局内存

下图展示了 GPU 内存体系的完整结构。从最靠近计算单元的寄存器,到最远的全局内存(显存),每一级都有明确的角色与性能边界。

GPU 内存层次与带宽/延迟特征 Streaming Multiprocessor (SM / CUDA 核心阵列) 寄存器文件 ~256KB/SM, 延迟~1周期 共享内存 + L1 缓存 SM 内建,可编程共享内存 ~48-228KB L2 缓存(芯片级) ~40-50MB,所有 SM 共享 全局内存(显存 DRAM) 容量最大(GB级),延迟 400-800 周期 带宽(近似) 寄存器: >20 TB/s 共享内存: ~10-15 TB/s L2: ~2-4 TB/s 全局: ~0.3-1 TB/s 延迟(近似) 寄存器: 1 周期 共享内存: ~20-30 周期 L2: ~200 周期 全局: ~400-800 周期 关键认知:离 SM 越近,容量越小但速度越快;全局内存是唯一支持 跨线程块访问的大容量存储,但必须通过合并访问与缓存优化来弥补延迟。

上图中,寄存器是每个线程私有的存储,速度最快但容量极小(每线程通常最多 255 个 32 位寄存器)。共享内存(Shared Memory)位于 SM 内部,同一线程块内的线程可以互相访问,延迟远低于全局内存。L1/L2 缓存对程序员部分透明,但理解其行为对优化至关重要。全局内存则是所有线程共享的大容量存储,也是 CPU 与 GPU 数据交换的主通道。

4.2 核心原理:为什么内存模型决定性能上限

GPU 性能瓶颈往往不在计算,而在数据搬运。一个典型的算术密集型内核,若每个浮点运算需要从全局内存读取操作数,其实际吞吐量可能仅为峰值算力的 5%-10%。因此,优化内存访问模式比优化指令序列更重要。

三个核心原理需要牢记:

  • 合并访问(Coalesced Access):同一 warp(32 线程)访问连续地址时,硬件将其合并为少数几次内存事务。若访问跳跃或分散,事务数增加,有效带宽骤降。
  • 局部性(Locality):时间局部性(同一数据多次使用)和空间局部性(相邻数据同时使用)决定了缓存命中率。利用共享内存或寄存器缓存复用数据,可显著减少全局内存访问。
  • 占用率(Occupancy):每个 SM 能同时驻留的线程数受寄存器、共享内存等资源限制。资源占用过高会降低占用率,导致无法用线程切换隐藏内存延迟。

经验法则: 全局内存每次访问的代价约为几百个周期。若一个线程块需要反复读取同一份数据,应先将数据加载到共享内存,再通过 __syncthreads() 同步后使用。这能将数百次全局访问降为一次。

4.3 优化方法流程:从分析到落地

内存优化不是盲目套用技巧,而是系统性的分析过程。以下流程适用于大多数内核:

  1. Profiler 定位瓶颈:使用 ncu(NVIDIA Nsight Compute)或 rocprof(AMD)分析内核,查看 Memory Throughput 和 Compute Throughput。若前者接近 100% 而后者较低,则属于内存受限。
  2. 检查访问模式:审查全局内存访问是否合并。例如,以下代码实现了合并访问:
// 合并访问:线程 i 访问 data[i]
__global__ void copy_kernel(float *in, float *out, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) out[i] = in[i];  // 相邻线程访问相邻地址
}

// 非合并访问:线程 i 访问 data[i * 32](跨步访问)
__global__ void stride_kernel(float *in, float *out, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) out[i] = in[i * 32];  // 每次事务仅利用 1/32 数据
}
  1. 数据复用分析:若每个数据被同一线程块内多个线程使用,考虑用共享内存做块内缓存。典型场景是矩阵乘法中的分块(tiling)。
  2. 调整资源分配:通过 cudaFuncSetAttribute() 或启动配置调整共享内存/寄存器分配,平衡占用率与单线程性能。
  3. 迭代验证:每次修改后重新 profiling,对比带宽与耗时变化。

4.4 案例:矩阵乘法分块优化

以 1024×1024 的矩阵乘法 C = A × B 为例。朴素实现中,每个线程直接读取全局内存中的 A 和 B,每个输出元素需要 2N 次全局访问。分块后,每个线程块计算一个 16×16 的输出子块,并将对应的 A 子块(16×1024)和 B 子块(1024×16)加载到共享内存。

#define TILE_SIZE 16

__global__ void matmul_tiled(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 tile = 0; tile < N / TILE_SIZE; ++tile) {
        // 协作加载到共享内存
        As[threadIdx.y][threadIdx.x] = A[row * N + tile * TILE_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(tile * 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;
}

该优化将全局内存访问量从 O(N³) 降至 O(N³ / TILE_SIZE),实际加速比可达 5-10 倍(取决于矩阵规模与硬件)。关键点在于:每个线程块只加载自己需要的子块,且通过 __syncthreads() 保证数据就绪后再计算。

4.5 误区与复盘:常见性能陷阱

误区 表现 正确做法
忽略合并访问 带宽利用率低于 20% 确保 threadIdx.x 映射到连续内存维度
共享内存使用过度 占用率下降,延迟无法隐藏 平衡共享内存大小与 block 数量
未使用只读缓存 纹理/常量数据反复访问全局 使用 __ldg() 或 const __restrict__ 提示
同步过多 线程等待时间 > 计算时间 减少同步点,或使用异步拷贝(cp.async)

典型复盘:某图像处理内核将每个像素的 3×3 邻域数据重复从全局内存读取,导致 9 倍冗余访问。改为共享内存后,每个线程块只加载一次图像块(含 halo 边界),性能提升 4.2 倍。教训:先分析数据复用模式,再决定缓存策略,不要盲目增加共享内存大小。

4.6 关键参数速查

不同架构的 GPU 内存参数略有差异,以下以 NVIDIA H100(Hopper)和 A100(Ampere)为例:

  • 寄存器文件:每 SM 256KB,每线程最多 255 个寄存器。
  • 共享内存:每 SM 最大 228KB(H100),可配置为 L1 缓存拆分。
  • L2 缓存:H100 为 50MB,A100 为 40MB。
  • 全局内存带宽:H100 SXM 约 3.35 TB/s,A100 约 2.0 TB/s。
  • 缓存行大小:全局内存访问以 32 字节扇区为单位,合并访问应尽量覆盖完整扇区。

实际开发中,建议先用 cudaDeviceProp 查询当前设备的 sharedMemPerBlock、regsPerMultiprocessor 等参数,再据此设计内核配置。