1. CUDA内存模型全景:理解Host与Device内存架构

大家好,我是你们的老朋友。今天咱们聊聊CUDA内存模型。

说实话,我见过太多新手一上来就写cudaMalloc,然后数据拷贝完就开始算。结果呢?性能惨不忍睹。为什么?因为他们根本不了解GPU的内存长什么样。

你想想看,CPU和GPU之间隔着PCIe总线,这玩意儿带宽再高也就几十GB/s。而GPU自己的显存带宽,动不动就几百GB/s甚至上TB/s。这差距,说白了就是高速公路和乡间小道的区别。

所以,理解内存模型,是CUDA优化的第一步,也是最重要的一步。

1.1 Host与Device:两个世界

Host就是CPU端,Device就是GPU端。它们各自有独立的内存空间。

  • Host内存:就是我们平时用的DDR内存,容量大(几十GB到几百GB),但带宽一般。
  • Device内存:就是GPU上的显存,容量相对小(几GB到几十GB),但带宽极高。

数据要从Host搬到Device,或者反过来,都得经过PCIe总线。这个操作叫Host-Device数据传输。

核心要点:尽量减少Host和Device之间的数据传输。能一次传完,就别分十次。

我记得有个项目,客户把数据分成了1000个小块,每块都单独cudaMemcpy一次。结果光拷贝就占了90%的时间。我改成一次大块传输,性能直接翻了5倍。嗯,这就是血的教训。

1.2 全局内存:GPU的“主存”

全局内存(Global Memory)是GPU上最大的存储空间。所有线程都能访问它。但代价是——慢。

全局内存的延迟大概在400-800个时钟周期。什么概念?你算一次浮点加法只要几个周期,等一个数据却要几百个周期。这就像你去食堂打饭,排队的时间比吃饭的时间还长。

怎么优化?合并访问(Coalesced Access)是关键。

我的习惯:写kernel之前,先画个内存访问模式图。确保相邻线程访问相邻地址。这样硬件会把多次访问合并成一次大块传输,效率高得多。

// 好的合并访问:相邻线程访问相邻地址
__global__ void good_kernel(float *data) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    data[idx] = data[idx] * 2.0f;  // 线程0访问data[0],线程1访问data[1]...
}

// 差的非合并访问:跨步访问
__global__ void bad_kernel(float *data) {
    int idx = threadIdx.x * 32 + blockIdx.x * blockDim.x;
    data[idx] = data[idx] * 2.0f;  // 线程0访问data[0],线程1访问data[32]...
}

1.3 共享内存:片上“缓存”

共享内存(Shared Memory)是GPU芯片上的SRAM,速度比全局内存快两个数量级。延迟只有几个时钟周期。

但容量很小——每个SM(流多处理器)通常只有几十KB到一百多KB。你得精打细算着用。

共享内存的作用是什么?线程间协作。同一个block里的线程可以通过共享内存交换数据,不用反复访问慢速的全局内存。

避坑指南:我曾经在共享内存里声明了一个大数组,结果kernel直接报错。为什么?因为每个block能用的共享内存是有限的。你声明太多,硬件会限制block的并发数量,甚至直接编译失败。

// 共享内存的典型用法:矩阵分块
__global__ void matmul_kernel(float *A, float *B, float *C, int N) {
    __shared__ float tileA[16][16];
    __shared__ float tileB[16][16];
    
    int row = blockIdx.y * 16 + threadIdx.y;
    int col = blockIdx.x * 16 + threadIdx.x;
    
    float sum = 0.0f;
    for (int t = 0; t < N / 16; t++) {
        // 从全局内存加载到共享内存
        tileA[threadIdx.y][threadIdx.x] = A[row * N + t * 16 + threadIdx.x];
        tileB[threadIdx.y][threadIdx.x] = B[(t * 16 + threadIdx.y) * N + col];
        __syncthreads();
        
        // 从共享内存计算
        for (int k = 0; k < 16; k++) {
            sum += tileA[threadIdx.y][k] * tileB[k][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

1.4 寄存器:最快的存储

寄存器(Registers)是GPU上最快的存储,没有之一。延迟为0个周期。每个线程都有自己的私有寄存器。

但数量有限——每个线程通常只有几十到几百个寄存器。如果你声明太多局部变量,编译器会“溢出”(spill)到本地内存,速度瞬间掉下来。

我的建议:用--ptxas-options=-v编译选项查看寄存器使用量。如果超过32个,就要考虑优化了。

1.5 本地内存:寄存器的“备胎”

本地内存(Local Memory)听起来像“本地”的,其实它存在全局内存里。只是每个线程私有的而已。

什么时候会用本地内存?寄存器不够用的时候。编译器会自动把多余的变量放到本地内存里。

本地内存的延迟和全局内存一样高。所以,尽量避免寄存器溢出。

1.6 常量内存:只读的“广播”

常量内存(Constant Memory)是只读的,容量64KB。它有个特殊能力——广播。当同一个warp里的所有线程访问同一个常量地址时,硬件只需要一次读取,然后广播给所有线程。

适合放什么?查找表、系数、配置参数等只读数据。

__constant__ float coeff[256];

__global__ void kernel(float *data) {
    int idx = threadIdx.x;
    // 所有线程访问同一个coeff[0],硬件会广播
    data[idx] = data[idx] * coeff[0];
}

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

纹理内存(Texture Memory)也是只读的,但它有硬件缓存,特别适合二维或三维的随机访问模式。

比如图像处理、体渲染这些场景。纹理内存会自动处理边界条件,还支持插值。

我个人很少用纹理内存,因为大多数场景用共享内存+全局内存就能搞定。但如果你做图像处理,纹理内存是个好选择。

1.8 层级关系与带宽差异

好了,咱们把所有的内存类型串起来,看看它们的层级关系:

CUDA内存层级结构 Host内存 (CPU端) — 带宽: ~50 GB/s PCIe总线 — 带宽: ~16 GB/s (PCIe 3.0 x16) 全局内存 (Global Memory) — 带宽: ~900 GB/s (HBM2) 常量内存 (64KB) 纹理内存 (只读缓存) 共享内存 (~48-164 KB/SM) 寄存器 (Registers) — 延迟: 0周期, 每个线程最多255个 本地内存 (Local Memory) — 寄存器溢出时使用, 实际在全局内存中 延迟对比 寄存器: 0周期 | 共享内存: ~5周期 | 全局内存: ~400-800周期 常量内存: ~1周期 (广播时) | 纹理内存: 取决于缓存命中

这张图很直观。从上到下,速度越来越快,容量越来越小。你想想看,优化说白了就是:把频繁访问的数据放到更快的存储里。

内存类型 位置 容量 延迟 作用域
寄存器 SM内部 每个线程最多255个 0周期 单个线程
共享内存 SM内部 48-164 KB/SM ~5周期 单个Block
常量内存 显存 64 KB ~1周期(广播) 所有线程(只读)
纹理内存 显存 取决于显存 取决于缓存 所有线程(只读)
全局内存 显存 几GB~几十GB 400-800周期 所有线程
本地内存 显存 取决于寄存器溢出 400-800周期 单个线程
Host内存 CPU端 几十GB~几百GB ~100ns CPU线程

总结一下我的经验:

  • 能放寄存器的,别放共享内存
  • 能放共享内存的,别放全局内存
  • 能一次传完的,别分多次传
  • 能合并访问的,别乱跳

嗯,这就是CUDA内存模型的全景。说白了,就是一张地图。你知道了哪里快哪里慢,才能规划出最优的数据路径。

我曾经有个项目,优化前kernel跑200ms,优化后只用了15ms。怎么做到的?就是把热点数据从全局内存搬到了共享内存,然后做了合并访问。就这么简单。

但简单不代表容易。你得理解每一层内存的特性,才能用好它们。

可可精品资料库