CUDA内存管理与优化:全局内存、共享内存、常量内存、纹理内存、寄存器

大家好,我是你们的老朋友。今天咱们聊聊CUDA里最核心、也最让人头疼的话题——内存管理。说实话,我见过太多人GPU算力利用率上不去,十有八九是内存访问出了问题。你想想看,GPU计算单元跑得飞快,但数据如果卡在路上,那再强的算力也是白搭。

这一章,我会带大家把CUDA的几种内存类型挨个捋一遍。每种内存有什么特点、什么时候用、怎么用才能榨干性能,我都会结合自己的实战经验来讲。嗯,咱们开始吧。

全局内存:最常用,也最容易踩坑

全局内存是GPU里容量最大的存储空间,所有线程都能访问。但它的延迟也最高——大概400-800个时钟周期。我在项目中遇到过不少新手,上来就把所有数据丢进全局内存,结果性能惨不忍睹。

内存合并访问是全局内存优化的第一要务。什么意思呢?就是让同一个warp里的32个线程,访问连续的内存地址。GPU硬件会把多个小请求合并成一次大的内存事务,效率能提升好几倍。

核心原则:相邻线程访问相邻地址。

看个例子:

// 好的写法:合并访问
__global__ void good_kernel(float *data, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        float val = data[idx];  // 线程0访问data[0],线程1访问data[1]...
        // 处理数据...
    }
}

// 坏的写法:非合并访问
__global__ void bad_kernel(float *data, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        float val = data[idx * 32];  // 线程0访问data[0],线程1访问data[32]...
        // 性能会差很多
    }
}

我个人习惯在写kernel之前,先画一下线程和数据的映射关系。确保每个warp访问的地址是连续的,这能省下不少调试时间。

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

共享内存是GPU芯片上的SRAM,延迟只有几个时钟周期。但它有两个限制:容量小(通常48KB-96KB),而且只在同一个block内可见。

共享内存最大的价值在于数据复用。比如矩阵乘法,每个元素会被多个线程用到。如果每次都从全局内存读,那带宽根本不够用。把数据先搬到共享内存里,让block内的线程共享,性能能翻好几倍。

注意:共享内存用不好反而会拖慢性能。最常见的坑就是Bank Conflict。

Bank Conflict:共享内存的隐形杀手

共享内存被分成32个bank(对应32个线程)。如果同一个warp里的多个线程访问同一个bank的不同地址,就会发生bank conflict,导致访问串行化。

举个例子:

// 有bank conflict的写法
__shared__ float smem[32][32];
int tid = threadIdx.x;
float val = smem[tid][tid];  // 所有线程访问同一列,都在同一个bank

// 无bank conflict的写法
float val = smem[tid][0];    // 每个线程访问不同行,分布在不同的bank

我曾经在一个图像处理项目里,因为没注意bank conflict,共享内存的带宽利用率只有理论值的1/4。后来改成按列访问,性能直接提升了3倍。嗯,这个坑我踩过,你们就别再踩了。

常量内存:只读数据的福音

常量内存有专门的缓存,延迟低,而且支持广播——如果一个warp里的所有线程访问同一个常量地址,硬件只会发一次请求。适合存放查找表、系数矩阵这类只读数据。

用法很简单:

__constant__ float coeff[256];

// 在host端初始化
cudaMemcpyToSymbol(coeff, host_coeff, sizeof(float) * 256);

// 在kernel里直接使用
__global__ void kernel(float *out) {
    int idx = threadIdx.x;
    out[idx] = coeff[idx % 256] * input[idx];
}

我个人建议,只要数据是只读的、且所有线程访问相同地址,优先考虑常量内存。但注意,常量内存只有64KB,别塞太多东西。

纹理内存:二维访问的利器

纹理内存专门为空间局部性优化过的。如果你处理的是图像、矩阵这类二维数据,纹理内存能利用硬件缓存,让相邻像素的访问更快。它还支持硬件插值,做图像缩放时特别方便。

不过说实话,纹理内存在通用计算里用得不多。除非你明确需要空间局部性优化,否则用全局内存+共享内存的组合就够了。

寄存器:最快的存储,但数量有限

寄存器是每个线程私有的,延迟为0。但每个线程能用的寄存器数量有限(通常255个)。如果kernel里变量太多,编译器会把多余的变量“溢出”到本地内存——那性能就崩了。

小技巧:用--maxrregcount编译选项限制寄存器数量,可以控制occupancy。但别压得太狠,否则寄存器溢出反而更慢。我一般先设成64,然后根据profiler结果调整。

内存带宽优化策略总结

说了这么多,我给大家整理一个实用的检查清单:

优化目标 怎么做 我踩过的坑
全局内存合并访问 相邻线程访问相邻地址 曾经用列优先存储,结果带宽利用率不到10%
避免Bank Conflict 让不同线程访问不同bank 共享内存按列访问,忘了加padding
数据复用 用共享内存缓存重复数据 矩阵乘法没做分块,全局内存读爆了
常量内存广播 所有线程读同一个地址 把查找表放全局内存,浪费了广播特性
寄存器优化 控制变量数量,避免溢出 一个kernel用了300个寄存器,occupancy掉到25%

知识体系总览

下面这张图,是我自己画的内存体系结构。你看一眼就能明白各种内存的位置和关系:

CUDA内存层次结构 全局内存 (Global Memory) 容量最大(GB级) | 延迟最高(400-800 cycles) | 所有线程可访问 优化关键:内存合并访问 常量内存 (Constant Memory) 64KB | 只读 | 支持广播 适合:查找表、系数矩阵 纹理内存 (Texture Memory) 空间局部性优化 | 硬件插值 适合:图像处理、二维数据 共享内存 (Shared Memory) 48-96KB | 片上SRAM | 低延迟(几个cycles) 优化关键:避免Bank Conflict | 数据复用 寄存器 (Registers) 每个线程私有 | 延迟0 | 最多255个 延迟从低到高:寄存器 → 共享内存 → 常量/纹理内存 → 全局内存

你看,从寄存器到全局内存,延迟差了上百倍。所以优化的核心思路就一句话:把频繁访问的数据尽量往“上面”放。

我的个人习惯:写kernel之前,先想清楚每个数据应该放在哪层内存。全局内存只做输入输出,中间计算结果用共享内存缓存,常量数据放常量内存。这样设计出来的kernel,性能一般不会太差。

好了,这一章的内容就到这里。内存优化是个细活,需要结合profiler反复调。下一章咱们会讲NCCL的通信优化,到时候再聊。

可可精品资料库