第三章:内存模型——全局内存、共享内存、寄存器、本地内存、常量内存、纹理内存

聊到CUDA编程,绕不开的就是内存模型。说实话,很多新手甚至老手,性能瓶颈都卡在内存访问上。我个人习惯把GPU的内存体系比作一个“分层仓库”,每一层速度不同、容量不同、用法也不同。搞懂了这些,你的算子优化才算真正入门。

3.1 全局内存(Global Memory)

全局内存是GPU里容量最大、但延迟也最高的存储空间。所有线程都能读写它,CPU也能通过PCIe把数据搬进来。说白了,它就是显存本身。

特点:

  • 容量大(几GB到几十GB)
  • 延迟高(几百个时钟周期)
  • 所有线程可见
  • 支持动态分配(cudaMalloc)

避坑指南:我曾经在项目里犯过一个低级错误——频繁在kernel里用全局内存做原子操作。结果性能惨不忍睹。后来改成先用共享内存做局部归约,再写回全局内存,速度直接翻了5倍。

访问全局内存时,有个关键概念叫“合并访问”(coalesced access)。什么意思呢?就是相邻线程最好访问相邻地址。你想想看,如果线程0读地址0,线程1读地址1,线程2读地址2……这样硬件可以把多次访问合并成一次,效率极高。反之,如果线程0读地址0,线程1读地址100,线程2读地址200……那就得三次独立访问,性能直接崩了。

// 合并访问示例(推荐)
__global__ void coalesced_kernel(float *data, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        data[idx] = data[idx] * 2.0f;  // 相邻线程访问相邻地址
    }
}

// 非合并访问示例(不推荐)
__global__ void non_coalesced_kernel(float *data, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        data[threadIdx.x * N + blockIdx.x] = data[threadIdx.x * N + blockIdx.x] * 2.0f;  // 跨步访问
    }
}

3.2 共享内存(Shared Memory)

共享内存是每个block私有的高速缓存,容量很小(通常几十KB),但延迟极低(几个时钟周期)。它最大的用处是让同一个block内的线程协作。

特点:

  • 容量小(通常16KB-48KB可配置)
  • 延迟低(约5-10个时钟周期)
  • 同一block内线程共享
  • 需要手动管理数据搬入搬出

我的经验:共享内存最经典的用法是做矩阵分块。比如矩阵乘法,把子块先加载到共享内存里,然后反复使用,避免反复访问全局内存。我做过一个测试,用共享内存优化后的矩阵乘法,比直接读全局内存快了将近10倍。

嗯,这里要注意一个坑:共享内存有bank conflict问题。共享内存被分成32个bank,如果多个线程同时访问同一个bank的不同地址,就会发生冲突,导致串行化。解决办法?让线程访问的地址错开,或者用padding技巧。

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

// 典型用法:把全局内存数据加载到共享内存
__global__ void shared_memory_kernel(float *input, float *output, int N) {
    __shared__ float cache[256];
    int tid = threadIdx.x;
    int idx = blockIdx.x * blockDim.x + tid;
    
    if (idx < N) {
        cache[tid] = input[idx];  // 加载到共享内存
    }
    __syncthreads();  // 同步,确保所有线程都加载完毕
    
    // 在共享内存上做计算
    // ...
    
    if (idx < N) {
        output[idx] = cache[tid];  // 写回全局内存
    }
}

3.3 寄存器(Register)

寄存器是GPU里最快、最私有的存储空间。每个线程独享,延迟几乎为0。但数量极其有限——每个线程通常只有几十到几百个寄存器。

特点:

  • 速度最快(1个时钟周期)
  • 每个线程私有
  • 数量有限(通常每个线程最多255个)
  • 编译器自动管理

注意:寄存器用多了会 spill 到本地内存,性能会急剧下降。我曾经调试过一个kernel,寄存器用了300多个,结果编译器偷偷把一部分变量塞到了本地内存里,性能直接腰斩。后来我手动优化了代码,减少了中间变量,才把寄存器压到合理范围。

怎么控制寄存器使用量?可以用编译选项 --maxrregcount=32 限制每个线程最多用32个寄存器。但别设得太死,否则编译器会疯狂spill,反而更慢。

3.4 本地内存(Local Memory)

本地内存听起来像“本地”的,其实它物理上就在全局内存里。只是每个线程私有的全局内存而已。当寄存器不够用,或者访问数组索引不是编译期常量时,编译器会把变量放到本地内存里。

特点:

  • 物理上位于全局内存
  • 每个线程私有
  • 延迟高(和全局内存一样)
  • 编译器自动决定使用

为什么会用到本地内存?举个例子:你声明了一个大数组 int arr[100],寄存器放不下,编译器就会把它放到本地内存。或者你用了 arr[threadIdx.x] 这种动态索引,也可能触发本地内存。

避坑指南:我曾经写过一个kernel,里面有个大结构体,结果编译器把它全塞进了本地内存。性能惨不忍睹。后来我把结构体拆开,用多个小数组代替,寄存器使用量降下来了,性能也恢复了。

3.5 常量内存(Constant Memory)

常量内存是只读的,容量很小(64KB),但有个神奇的特性——如果所有线程访问同一个地址,它比全局内存快得多。因为它有专门的缓存机制。

特点:

  • 只读
  • 容量小(64KB)
  • 有缓存
  • 适合存储常量参数
// 声明常量内存
__constant__ float const_params[256];

// 主机端赋值
cudaMemcpyToSymbol(const_params, host_params, sizeof(float) * 256);

// 设备端使用
__global__ void constant_kernel(float *output, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        output[idx] = output[idx] * const_params[0];  // 所有线程读同一个地址
    }
}

我个人习惯把卷积核的权重、归一化参数这类所有线程都要用的常量放到常量内存里。注意,如果每个线程访问不同的地址,常量内存反而比全局内存慢,因为缓存会失效。

3.6 纹理内存(Texture Memory)

纹理内存最初是为图形渲染设计的,但用在通用计算里也有奇效。它支持硬件插值、边界处理、缓存优化,特别适合图像处理、查找表这类场景。

特点:

  • 只读
  • 有专门的纹理缓存
  • 支持硬件插值(线性、双线性等)
  • 支持边界处理(clamp、wrap等)
  • 对二维/三维空间局部性友好

我的经验:有一次做图像滤波,用全局内存读像素值,性能一般。换成纹理内存后,因为纹理缓存对二维空间局部性优化得很好,性能提升了30%。而且边界处理都不用自己写代码了,直接设置纹理的寻址模式就行。

// 纹理内存使用示例
texture<float, 2, cudaReadModeElementType> tex;

// 绑定纹理
cudaArray *cuArray;
cudaMallocArray(&cuArray, &channelDesc, width, height);
cudaMemcpyToArray(cuArray, 0, 0, hostData, size, cudaMemcpyHostToDevice);
cudaBindTextureToArray(tex, cuArray);

// kernel中使用
__global__ void texture_kernel(float *output, int width, int height) {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    if (x < width && y < height) {
        output[y * width + x] = tex2D(tex, x + 0.5f, y + 0.5f);  // 硬件插值
    }
}

3.7 内存模型总结

好了,我把这六种内存整理成一个表格,方便你对比:

内存类型 位置 速度 容量 作用域 生命周期
寄存器 芯片内 最快 极少 单个线程 kernel执行期间
共享内存 芯片内 快 小(KB级) 单个block block执行期间
本地内存 显存 慢 有限 单个线程 kernel执行期间
全局内存 显存 最慢 大(GB级) 所有线程+CPU 程序运行期间
常量内存 显存(有缓存) 较快(缓存命中时) 小(64KB) 所有线程(只读) 程序运行期间
纹理内存 显存(有缓存) 较快(空间局部性好) 大 所有线程(只读) 程序运行期间

核心原则:尽量把频繁访问的数据放到离计算单元近的地方。寄存器 > 共享内存 > 全局内存。别让数据在慢速内存里反复横跳,那是性能杀手。

CUDA内存层次结构 寄存器(Register) 每个线程私有 · 最快 · 容量极少 共享内存(Shared Memory) 每个Block私有 · 快 · 容量小(KB级) 本地内存(Local Memory) 每个线程私有 · 物理上在全局内存 · 编译器自动管理 常量内存(Constant Memory) 只读 · 64KB · 有缓存 纹理内存(Texture Memory) 只读 · 支持插值 · 空间局部性好 全局内存(Global Memory) 所有线程可见 · 最慢 · 容量大(GB级) 快 慢 小 大

这张图把内存层次结构画得很清楚了。从上到下,速度越来越慢,容量越来越大。写kernel的时候,脑子里要有这张图,时刻问自己:我的数据现在在哪?能不能搬到更快的地方去?

好了,这一章的内容就到这里。记住,内存模型是CUDA优化的基石。搞懂了它,后面的算子优化才能事半功倍。

可可精品资料库