第四章:内存管理与优化——全局内存、共享内存、寄存器、常量内存、纹理内存的使用与优化策略

说到GPU性能优化,内存管理绝对是绕不开的核心话题。我见过太多同学,核函数写得花里胡哨,结果一跑性能惨不忍睹。为什么?说白了,大部分时间都浪费在了数据搬运上。GPU的计算单元快得飞起,但内存访问如果跟不上,那就是典型的「高射炮打蚊子」。

这一章,咱们就把GPU的五种内存掰开揉碎了讲清楚。你想想看,搞懂了这些,你的算子性能至少能翻个倍。

4.1 全局内存:最常用,也最慢

全局内存是GPU里容量最大的存储空间,所有线程都能访问。但它的延迟也是最高的——大概400-800个时钟周期。嗯,这个数字你得记住。

访问模式是关键

全局内存有个脾气:它喜欢「合并访问」。什么意思?就是相邻的线程最好访问相邻的地址。我在项目中遇到过,一个同事写了个矩阵转置,性能死活上不去。我一看代码,好家伙,线程访问的步长是矩阵的行宽,完全没对齐。改成列访问后,带宽利用率从20%直接飙到了80%。

核心原则:让线程束(warp)内的32个线程访问连续的内存地址,且地址对齐到128字节。

// ❌ 糟糕的访问模式:步长访问
float value = data[threadIdx.x * 1024];  // 每个线程跳着读

// ✅ 良好的访问模式:连续访问
float value = data[threadIdx.x];  // 相邻线程读相邻地址

我的小技巧:如果数据访问模式实在没法连续,可以考虑用共享内存做中转。先把数据从全局内存搬到共享内存(这一步可以合并访问),然后再从共享内存里乱序读取。虽然多了一步搬运,但往往比直接乱序访问全局内存快得多。

4.2 共享内存:片上高速缓存

共享内存是GPU上的「小仓库」,位于芯片内部,延迟只有全局内存的十分之一左右。每个线程块独享一份共享内存,块内的线程可以互相通信。

容量有限,精打细算

每个SM(流多处理器)的共享内存通常只有几十到一百多KB。你想想看,这点空间得省着用。我建议你养成习惯:每次写代码前先算算共享内存的用量。

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

// 使用共享内存做归约
int tid = threadIdx.x;
cache[tid] = input[tid];
__syncthreads();  // 同步!确保所有线程都写完了

// 树形归约
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
    if (tid < stride) {
        cache[tid] += cache[tid + stride];
    }
    __syncthreads();
}

我曾经踩过的坑:共享内存用多了会导致每个SM上能同时运行的线程块数量减少,这叫「占用率下降」。有一次我为了省事,给每个线程块分配了48KB共享内存,结果SM上只能跑一个块,性能反而比用全局内存还差。后来我压缩到16KB,占用率上来了,性能直接翻倍。

4.3 寄存器:最快但最稀缺

寄存器是GPU里最快的存储,没有之一。每个线程独享,延迟几乎为零。但数量极其有限——每个线程通常只有32-255个寄存器(取决于GPU架构)。

寄存器溢出是大忌

如果每个线程使用的寄存器超过了硬件限制,编译器会把多余的数据「溢出」到全局内存。这一溢出,性能就崩了。我见过一个极端案例:一个核函数用了128个寄存器,结果编译器溢出后,性能下降了10倍。

优化建议:用 --maxrregcount=N 编译选项限制寄存器数量,迫使编译器更高效地复用寄存器。但别设得太低,否则溢出更严重。我个人习惯从32开始试,逐步调整。

// 查看寄存器使用量
nvcc --ptxas-options=-v kernel.cu
// 输出类似:registers=32, smem=0, cmem[0]=16

4.4 常量内存:只读且广播

常量内存是全局内存的一种特殊形式,但有两个特点:一是只读,二是当线程束内所有线程访问同一个地址时,硬件会把这个值广播给所有线程,只发一次请求。

适用场景

常量内存最适合存放那些不会改变、且所有线程都需要的参数。比如卷积核的权重、矩阵乘法的系数等。

// 声明常量内存
__constant__ float filter[1024];

// 主机端复制数据
cudaMemcpyToSymbol(filter, host_filter, sizeof(float) * 1024);

// 核函数中直接使用
__global__ void convolution(float* output, float* input) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    float sum = 0.0f;
    for (int i = 0; i < 1024; i++) {
        sum += input[idx + i] * filter[i];  // 所有线程读同一个filter[i]
    }
    output[idx] = sum;
}

注意:常量内存只有64KB,别想着往里塞大数据。而且如果线程束内每个线程访问不同的地址,常量内存反而比全局内存还慢——因为它会串行化访问。

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

纹理内存最初是为图形学设计的,但用在通用计算里也有奇效。它利用空间局部性,对二维或三维数据的随机访问做了优化。而且它自带硬件插值功能,做图像处理时特别方便。

什么时候用纹理内存?

  • 数据访问模式不规则,比如光线追踪、随机采样
  • 需要硬件插值,比如图像缩放、特征提取
  • 数据是只读的,且访问模式有空间局部性
// 绑定纹理
texture<float, 2, cudaReadModeElementType> tex;
cudaBindTexture2D(0, tex, dev_data, &tex.channelDesc, width, height, pitch);

// 核函数中采样
__global__ void kernel(float* output) {
    int x = threadIdx.x + blockIdx.x * blockDim.x;
    int y = threadIdx.y + blockIdx.y * blockDim.y;
    output[y * width + x] = tex2D(tex, x + 0.5f, y + 0.5f);  // 硬件插值
}

我曾经犯过的错:纹理内存的读取延迟虽然低,但写入需要通过全局内存中转。有一次我试图用纹理内存做数据更新,结果发现根本写不进去——纹理内存是只读的。后来改用表面内存(surface memory)才解决了问题。

4.6 五种内存对比与选择策略

为了方便你快速决策,我整理了一张表。你写代码前可以对照着看看:

内存类型 位置 延迟 容量 作用域 典型用途
全局内存 片外DRAM 高(~400周期) 大(GB级) 所有线程 主数据存储
共享内存 片上SRAM 低(~30周期) 小(KB级) 线程块内 数据复用、线程通信
寄存器 片上 极低(~1周期) 极小(每个线程几十个) 单个线程 局部变量、中间结果
常量内存 片外(带缓存) 低(广播时) 64KB 所有线程 只读参数、系数
纹理内存 片外(带缓存) 中(空间局部性好) 大(GB级) 所有线程 不规则访问、插值

4.7 知识体系总览

下面这张图是我自己画的,把五种内存的关系和优化策略串在了一起。你保存下来,写代码时对照着看:

GPU内存层次与优化策略 全局内存 (Global Memory) 容量大、延迟高、需合并访问 常量内存 (Constant) 64KB,广播访问 纹理内存 (Texture) 空间局部性优化 共享内存 (Shared Memory) 片上SRAM,线程块内共享,需同步 寄存器 (Registers) 最快、线程私有、避免溢出 数据复用 批量搬运 合并访问 → 全局内存 避免溢出 → 寄存器 广播访问 → 常量内存 空间局部性 → 纹理内存

4.8 实战建议:如何选择内存类型

写代码时,我一般按这个顺序思考:

  1. 数据是否只读? → 是的话,考虑常量内存或纹理内存
  2. 数据是否被反复使用? → 是的话,用共享内存缓存
  3. 访问模式是否连续? → 是的话,直接用全局内存合并访问
  4. 数据量是否很小? → 是的话,尽量塞进寄存器

说白了,GPU优化的本质就是「让数据尽量靠近计算单元」。寄存器最快但最小,全局内存最慢但最大。你要做的,就是在两者之间找到平衡点。

最后送你一句话:别迷信任何一种内存。没有银弹,只有权衡。我见过有人为了用共享内存,把代码写得极其复杂,结果性能还不如简单的全局内存合并访问。优化之前,先profile,用数据说话。

可可精品资料库