第2节:GPU内存体系深度解密

引言

不懂内存,写不出高性能CUDA代码------90%的性能问题都出在内存访问上

上一节我们深入剖析了GPU的硬件架构,从SM到Warp再到Tensor Core。但光知道计算单元还不够------如果把GPU比作一个超级工厂,计算核心是工人,那内存就是传送带和仓库

传送带设计得再好,如果物料供不上,工人也只能干瞪眼。

在CUDA编程中,90%的性能问题都出在内存访问上。同样的算法,用对内存可能快100倍,用错内存可能慢到怀疑人生。

今天,我们就彻底搞懂GPU的内存体系,从最快到最慢,每一级内存的特性、用法和优化技巧。

一、GPU内存全景图

1.1 内存层次总览

先看一张完整的内存层次图:

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                       GPU内存层次结构                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ◄─── 速度越来越慢 ────                         ──── 容量越来越大 ───► │
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────────┐
│  │                   片上内存 (On-chip)                              │
│  │  ┌──────────────┐                                                │
│  │  │   寄存器      │ ◄──── 每个线程私有, <1周期, ~20KB/SM           │
│  │  │ (Register)   │      编译期确定, 最快                          │
│  │  └──────────────┘                                                │
│  │  ┌──────────────┐                                                │
│  │  │  共享内存/L1  │ ◄──── Block内共享, ~30周期, 可配置164KB/SM     │
│  │  │ (Shared/L1)  │      程序员可控, 极关键                         │
│  │  └──────────────┘                                                │
│  │  ┌──────────────┐                                                │
│  │  │  只读常量缓存 │ ◄──── 所有线程只读, ~30周期, ~50KB/SM          │
│  │  │ (Constant)   │      适合广播数据                              │
│  │  └──────────────┘                                                │
│  │  ┌──────────────┐                                                │
│  │  │   纹理缓存    │ ◄──── 特殊访问模式, ~100周期, ~50KB/SM         │
│  │  │ (Texture)    │      适合2D空间局部性                           │
│  │  └──────────────┘                                                │
│  └─────────────────────────────────────────────────────────────────┘
│                                    │
│                                    ▼
│  ┌─────────────────────────────────────────────────────────────────┐
│  │                   片外内存 (Off-chip)                             │
│  │  ┌──────────────┐                                                │
│  │  │   L2缓存     │ ◄──── 所有SM共享, ~200周期, 40MB (A100)         │
│  │  │   (L2)       │      自动管理, 对程序员透明                      │
│  │  └──────────────┘                                                │
│  │  ┌──────────────┐                                                │
│  │  │  全局内存     │ ◄──── 所有线程可读写, ~400周期, 40-80GB        │
│  │  │ (Global)     │      最常用, 但最慢, 显存主体                   │
│  │  └──────────────┘                                                │
│  │  ┌──────────────┐                                                │
│  │  │  本地内存     │ ◄──── 每个线程私有, ~400周期, 同全局内存        │
│  │  │  (Local)     │      寄存器溢出时使用, 应避免                   │
│  │  └──────────────┘                                                │
│  └─────────────────────────────────────────────────────────────────┘
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────────┐
│  │                    速度对比 (相对周期)                             │
│  │                                                                  │
│  │  寄存器    :  █ (1倍)                                            │
│  │  共享内存  :  ██████████████████████████████ (30倍)              │
│  │  全局内存  :  ████████████████████████████████████████████████   │
│  │              ████████████████████████████████████████████████   │
│  │              ████████████████████████████████████████████████   │
│  │              ████████████████████████████████████████████████ (400倍)
│  └─────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────────────┘

💡 核心结论:寄存器比全局内存快400倍!优化内存访问是性能调优的第一课。

1.2 内存特性对比表

内存类型 位置 作用域 生命周期 访问速度 容量 缓存
寄存器 片上 单个线程 核函数内 <1周期 ~20KB/SM
共享内存 片上 Block内 核函数内 ~30周期 164KB/SM
常量内存 片上 全局 程序运行期 ~30周期 64KB
纹理内存 片上 全局 程序运行期 ~100周期 依赖显存
L2缓存 片上 全局 自动 ~200周期 40MB 自动
全局内存 片外 全局 程序运行期 ~400周期 40-80GB 有(L2)
本地内存 片外 单个线程 核函数内 ~400周期 依赖显存 有(L2)

二、寄存器:最快的存储,但极度稀缺

2.1 寄存器是什么?

寄存器是SM上最快的存储单元,每个线程私有的"工作台"

  • 访问延迟:1个周期(比共享内存快30倍,比全局内存快400倍)
  • 容量:每个SM有65536个32位寄存器(约256KB)
  • 分配:编译时静态分配,每个线程使用的寄存器数量由编译器决定

2.2 寄存器分配与占用率

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                    寄存器分配与Occupancy的关系                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  SM寄存器总数: 65536                                                  │
│                                                                      │
│  ┌─────┬──────────────┬───────────────┬──────────────────────────┐  │
│  │ 场景 │ 每线程寄存器 │ 每Block线程数 │ 一个SM能同时跑的Block数     │  │
│  ├─────┼──────────────┼───────────────┼──────────────────────────┤  │
│  │  A  │     32       │     256       │ 65536÷(32×256)=8 Block   │  │
│  │  B  │     64       │     256       │ 65536÷(64×256)=4 Block   │  │
│  │  C  │     128      │     256       │ 65536÷(128×256)=2 Block  │  │
│  │  D  │     255      │     256       │ 65536÷(255×256)=1 Block  │  │
│  └─────┴──────────────┴───────────────┴──────────────────────────┘  │
│                                                                      │
│  每个Block 256线程 → 需要 256 × 每线程寄存器 个寄存器                  │
│  SM总寄存器数 ÷ 每Block所需寄存器 = 同时运行的Block数                  │
│                                                                      │
│  结论:每线程多用1个寄存器,可能让Occupancy腰斩!                       │
└─────────────────────────────────────────────────────────────────────┘

2.3 如何查看寄存器使用量?

编译时添加 --ptxas-options=-v 选项:

bash 复制代码
nvcc -arch=sm_80 mykernel.cu --ptxas-options=-v

输出示例:

复制代码
ptxas info    : 0 bytes gmem
ptxas info    : Compiling entry function 'mykernel' 
ptxas info    : Used 32 registers, 4096 bytes smem, 48 bytes cmem[0]

2.4 寄存器溢出:性能杀手

什么是寄存器溢出?

当每个线程需要的寄存器超过硬件限制时,多余的变量会被存入"本地内存"(实际是全局内存)。

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                    寄存器溢出示意图                                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  正常情况:                                                           │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  线程T0: [r0][r1][r2]...[r31]  ← 全部在寄存器, 1周期访问       │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  寄存器溢出:                                                         │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  线程T0: [r0][r1][r2]...[r63] ← 硬件最多支持64个寄存器?        │  │
│  │                                                              │  │
│  │  硬件实际只有64个寄存器位置,但代码需要80个:                    │  │
│  │  ┌────────────────────────────────────────────┐             │  │
│  │  │ 寄存器: [r0][r1]...[r63] (64个)           │ ← 1周期      │  │
│  │  │ 本地内存: [spill0][spill1]...[spill15]    │ ← 400周期    │  │
│  │  └────────────────────────────────────────────┘             │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  后果:访问这些"溢出"的变量,速度从1周期变成400周期!                   │
│       性能可能下降10倍以上                                           │
└─────────────────────────────────────────────────────────────────────┘

2.5 寄存器优化技巧

技巧1:限制寄存器使用量

cpp 复制代码
// 告诉编译器,这个核函数最多用32个寄存器
__launch_bounds__(256, 4)  // 每个Block 256线程,至少4个Block/SM
__global__ void mykernel() {
    // ...
}

或编译时强制限制:

bash 复制代码
nvcc -maxrregcount=32 mykernel.cu

技巧2:用共享内存换寄存器

cpp 复制代码
// 坏:太多局部变量占用寄存器
__global__ void bad() {
    float a1, a2, a3, a4, a5, a6, a7, a8;  // 8个寄存器
    float b1, b2, b3, b4, b5, b6, b7, b8;  // 又8个
    // 一共16个寄存器
}

// 好:放入共享内存数组
__shared__ float shared_data[256];
__global__ void good() {
    int tid = threadIdx.x;
    // 只用少量寄存器做索引和临时计算
    float tmp = shared_data[tid];  // 从共享内存读取
}

技巧3:尽早释放寄存器

cpp 复制代码
// 坏:变量生存期过长
__global__ void bad() {
    float large_array[10];  // 整个核函数都在占用
    // ... 早期计算
    // ... 后期计算 still using large_array
}

// 好:用作用域控制
__global__ void good() {
    {
        float temp_array[10];  // 只在这个作用域使用
        // ... 早期计算
    }  // 这里就可以释放寄存器给后面用
    // ... 后期计算
}

三、共享内存:程序员可控的缓存

3.1 共享内存是什么?

共享内存是片上内存,同一个Block内的线程可以共享

  • 访问延迟:~30周期(比全局内存快13倍)
  • 容量:每个SM 164KB(A100,可配置)
  • 特点:程序员完全控制,可当作手动管理的缓存

3.2 共享内存 vs L1缓存

现代GPU(Volta+)中,共享内存和L1缓存共用同一块片上存储,可以配置比例:

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                  共享内存/L1配置模式 (A100)                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  总容量: 192KB (实际可用164KB,其余用作其他用途)                       │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  模式1: 均衡模式 (默认)                                        │  │
│  │  ┌──────────────┬──────────────────────────────────────────┐ │  │
│  │  │  共享内存    │  L1缓存                                  │ │  │
│  │  │   96KB       │  96KB                                    │ │  │
│  │  └──────────────┴──────────────────────────────────────────┘ │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  模式2: 共享内存优先                                           │  │
│  │  ┌────────────────┬────────────────────────────────────────┐ │  │
│  │  │  共享内存      │  L1缓存                                │ │  │
│  │  │   160KB        │  32KB                                  │ │  │
│  │  └────────────────┴────────────────────────────────────────┘ │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  模式3: L1缓存优先                                             │  │
│  │  ┌────────────┬────────────────────────────────────────────┐ │  │
│  │  │  共享内存  │  L1缓存                                    │ │  │
│  │  │   32KB     │  160KB                                     │ │  │
│  │  └────────────┴────────────────────────────────────────────┘ │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  配置API:                                                           │
│  cudaFuncSetAttribute(mykernel,                                     │
│      cudaFuncAttributePreferredSharedMemoryCarveout,                │
│      cudaSharedmemCarveoutMaxShared);  // 共享内存优先               │
└─────────────────────────────────────────────────────────────────────┘

3.3 Bank Conflict:共享内存的陷阱

共享内存被划分为32个Bank(与Warp大小一致),每个Bank每周期可以访问一次。

Bank Conflict :当同一Warp的多个线程访问同一个Bank的不同地址时,访问会串行化,性能下降。

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                    Bank Conflict 示意图                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  共享内存被划分为32个Bank:                                           │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐    │
│  │B0  │B1  │B2  │B3  │B4  │B5  │B6  │B7  │... │B28 │B29 │B30 │B31 │    │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘    │
│                                                                      │
│  情况1: 无冲突 (理想)                                                │
│  Warp内32个线程访问32个不同Bank:                                     │
│  T0→B0, T1→B1, T2→B2, ..., T31→B31                                 │
│  结果: 1个周期完成                                                   │
│                                                                      │
│  情况2: 2路Bank Conflict                                            │
│  T0→B0, T1→B0, T2→B1, T3→B1, ...                                   │
│  结果: 2个周期完成 (串行化)                                          │
│                                                                      │
│  情况3: 32路Bank Conflict (最差)                                    │
│  所有32个线程访问同一个Bank:                                         │
│  T0→B0, T1→B0, ..., T31→B0                                         │
│  结果: 32个周期完成 (性能下降32倍!)                                  │
└─────────────────────────────────────────────────────────────────────┘

3.4 Bank Conflict实例分析

例1:按列访问 vs 按行访问

cpp 复制代码
#define N 32
__shared__ float smem[N][N];

// 情况A: 按行访问 → 无冲突
int row = threadIdx.x;  // 线程0访问第0行, 线程1访问第1行...
for (int col = 0; col < N; col++) {
    float val = smem[row][col];  // 不同线程访问不同行 → 不同Bank
}

// 情况B: 按列访问 → 32路Bank Conflict!
int col = threadIdx.x;  // 线程0访问第0列, 线程1访问第1列...
for (int row = 0; row < N; row++) {
    float val = smem[row][col];  // 不同线程访问不同行但同列 → 同Bank!
}

为什么?因为共享内存按4字节为单位线性编址:

  • smem[row][col]的地址 = base + rowN4 + col*4
  • Bank = (地址 / 4) % 32

当所有线程的col相同时,(row*N*4/4 + col) % 32中,row*N决定Bank,但N=32时,row*32 % 32 = 0,所以Bank只由col决定 → 全冲突!

解决方法:填充(Padding)

cpp 复制代码
#define N 32
#define NPAD 33  // 多填充一列
__shared__ float smem[N][NPAD];  // 现在每行33个元素

// 按列访问 → 冲突大大减少
// 因为 row*33 % 32 现在会分散到不同Bank

3.5 共享内存实战:矩阵转置

朴素实现(有Bank Conflict):

cpp 复制代码
__global__ void transpose_naive(float* input, float* output, int width) {
    __shared__ float tile[TILE_SIZE][TILE_SIZE];
    
    int x = blockIdx.x * TILE_SIZE + threadIdx.x;
    int y = blockIdx.y * TILE_SIZE + threadIdx.y;
    
    // 协同加载到共享内存
    if (x < width && y < width) {
        tile[threadIdx.y][threadIdx.x] = input[y * width + x];
    }
    __syncthreads();
    
    // 转置后写回
    x = blockIdx.y * TILE_SIZE + threadIdx.x;
    y = blockIdx.x * TILE_SIZE + threadIdx.y;
    
    if (x < width && y < width) {
        output[y * width + x] = tile[threadIdx.x][threadIdx.y];  // 这里读的时候有Bank Conflict!
    }
}

优化版(避免Bank Conflict):

cpp 复制代码
__global__ void transpose_optimized(float* input, float* output, int width) {
    // 加1列填充,避免Bank Conflict
    __shared__ float tile[TILE_SIZE][TILE_SIZE + 1];  
    
    int x = blockIdx.x * TILE_SIZE + threadIdx.x;
    int y = blockIdx.y * TILE_SIZE + threadIdx.y;
    
    if (x < width && y < width) {
        tile[threadIdx.y][threadIdx.x] = input[y * width + x];
    }
    __syncthreads();
    
    x = blockIdx.y * TILE_SIZE + threadIdx.x;
    y = blockIdx.x * TILE_SIZE + threadIdx.y;
    
    if (x < width && y < width) {
        output[y * width + x] = tile[threadIdx.x][threadIdx.y];  // 加了填充后,冲突消失
    }
}

性能对比(TILE_SIZE=32):

  • 朴素版:~120 GB/s
  • 优化版:~450 GB/s
  • 提升3.75倍!

四、常量内存:高效的广播机制

4.1 常量内存的特点

常量内存用于存储只读数据,有专门的缓存:

  • 容量:64KB(总,不是每SM)
  • 访问延迟:~30周期(命中缓存时)
  • 适用范围:所有线程访问相同地址(如权重、系数等)

4.2 常量内存的优势

当Warp内所有线程访问同一个常量地址时:

  • 只需一次内存请求

  • 广播给所有32个线程

  • 极致效率!

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 常量内存广播机制 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ │
    │ 全局内存访问: │
    │ T0: 请求addr0 ──→ 从显存读取4字节 ──→ 给T0 │
    │ T1: 请求addr1 ──→ 从显存读取4字节 ──→ 给T1 │
    │ T2: 请求addr2 ──→ 从显存读取4字节 ──→ 给T2 │
    │ ... (32次独立请求) │
    │ │
    │ 常量内存访问 (所有线程同地址): │
    │ T0-T31: 请求addrX ──→ 从常量缓存读一次 ──→ 广播给所有32线程 │
    │ ↓ │
    │ 1次请求搞定! │
    └─────────────────────────────────────────────────────────────────────┘

4.3 常量内存使用示例

cpp 复制代码
// 定义常量内存
__constant__ float coeff[256];

// 主机端初始化
float h_coeff[256] = {...};
cudaMemcpyToSymbol(coeff, h_coeff, sizeof(float) * 256);

// 核函数中使用
__global__ void apply_filter(float* input, float* output, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        // 所有线程访问同一个coeff[0] → 广播
        output[idx] = input[idx] * coeff[0];  
        
        // 但如果是不同索引,就没广播优势了
        // output[idx] = input[idx] * coeff[idx % 256];  
    }
}

五、全局内存:主存储区,但最慢

5.1 全局内存特性

  • 位置:片外(显存芯片)
  • 容量:40-80GB(A100/H100)
  • 带宽:1.6TB/s(A100)------虽然绝对数值高,但相对计算还是慢
  • 延迟:400-800周期

5.2 合并访问:全局内存的生命线

**合并访问(Coalesced Access)**是全局内存访问的最重要原则:

当Warp内的32个线程访问连续的、对齐的内存地址时,硬件可以把这些访问合并成少数几个内存事务

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                   合并访问 vs 非合并访问                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  情况1: 合并访问 (理想)                                              │
│  内存地址: [0][4][8][12][16][20][24][28]...[124]                    │
│            ↑  ↑  ↑  ↑  ↑  ↑  ↑  ↑          ↑                        │
│  线程:     T0 T1 T2 T3 T4 T5 T6 T7 ...    T31                       │
│                                                                      │
│  结果: 1次内存事务读取全部128字节                                     │
│  带宽利用率: ~100%                                                   │
│                                                                      │
│  情况2: 非合并访问 (最差)                                            │
│  内存地址: [0][1024][2048][3072]...                                  │
│            ↑   ↑     ↑     ↑                                         │
│  线程:     T0  T1    T2    T3    ...                                 │
│                                                                      │
│  结果: 32次独立内存事务                                              │
│  带宽利用率: ~3%                                                     │
│                                                                      │
│  性能差距: 30倍以上!                                                 │
└─────────────────────────────────────────────────────────────────────┘

5.3 如何实现合并访问?

规则1:让线程ID与内存地址线性对应

cpp 复制代码
// 好:合并访问
int tid = threadIdx.x + blockIdx.x * blockDim.x;
float val = input[tid];  // 线程0读input[0], 线程1读input[1], ...

// 坏:非合并访问
int tid = threadIdx.x + blockIdx.x * blockDim.x;
int offset = tid * 1024;  // 间隔太大
float val = input[offset];

规则2:注意数据类型大小

cpp 复制代码
// 好:4字节类型,自然对齐
float* input;  // 每个线程读4字节,连续排列 → 合并

// 好:8字节类型,也支持合并
double* input;  // 每个线程读8字节,连续排列 → 合并

// 注意:结构体可能破坏合并
struct Vec3 { float x, y, z; };  // 12字节,不对齐
Vec3* data;  // 访问data[tid].x 可能不合并

规则3:二维访问的合并技巧

cpp 复制代码
// 按行访问 → 合并
for (int col = 0; col < width; col++) {
    float val = matrix[row * width + col];  // 行内连续
}

// 按列访问 → 不合并
for (int row = 0; row < height; row++) {
    float val = matrix[row * width + col];  // 列间间隔很大
}

六、本地内存:隐形的性能杀手

6.1 什么是本地内存?

本地内存是寄存器溢出的产物,以及存放某些编译器无法放入寄存器的变量:

  • 大型数组:float big_array[100];(太大放不进寄存器)
  • 编译时无法确定索引的数组
  • 占用太多寄存器的变量

关键点 :本地内存物理上在全局内存,但每个线程私有,有L2缓存。

6.2 如何避免本地内存?

cpp 复制代码
// 坏:大数组导致本地内存
__global__ void bad() {
    float large_array[1000];  // 太大,只能放本地内存
    for (int i = 0; i < 1000; i++) {
        large_array[i] = i * 1.0f;
    }
}

// 好:如果必须大数组,考虑共享内存
__shared__ float shared_array[1000];
__global__ void good() {
    int tid = threadIdx.x;
    if (tid < 1000) {
        shared_array[tid] = tid * 1.0f;
    }
    __syncthreads();
    // 用shared_array
}

// 更好:重新设计算法,避免大数组

6.3 检测本地内存使用

编译输出中包含"lmem"或"local"字样:

复制代码
ptxas info    : Used 32 registers, 4096 bytes smem, 1024 bytes lmem
                                                          ↑ 本地内存!

七、内存优化实战案例

案例1:向量求和------从慢到快的演进

版本1:朴素全局内存访问

cpp 复制代码
__global__ void sum_naive(float* input, float* output, int N) {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    
    // 直接全局内存累加
    float sum = 0;
    for (int i = tid; i < N; i += blockDim.x * gridDim.x) {
        sum += input[i];  // 多次全局内存访问
    }
    output[tid] = sum;
}
// 性能: 1倍基准

版本2:共享内存块内归约

cpp 复制代码
__global__ void sum_shared(float* input, float* output, int N) {
    __shared__ float cache[256];  // 假设Block大小256
    
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    int cacheIndex = threadIdx.x;
    
    // 归约到共享内存
    float sum = 0;
    while (tid < N) {
        sum += input[tid];
        tid += blockDim.x * gridDim.x;
    }
    
    cache[cacheIndex] = sum;
    __syncthreads();
    
    // 块内归约(使用共享内存)
    int i = blockDim.x / 2;
    while (i != 0) {
        if (cacheIndex < i) {
            cache[cacheIndex] += cache[cacheIndex + i];
        }
        __syncthreads();
        i /= 2;
    }
    
    if (cacheIndex == 0) {
        output[blockIdx.x] = cache[0];
    }
}
// 性能: 3.5倍

版本3:避免Bank Conflict的归约

cpp 复制代码
__global__ void sum_optimized(float* input, float* output, int N) {
    // 加padding避免Bank Conflict
    __shared__ float cache[256 + 1];  
    
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    int cacheIndex = threadIdx.x;
    
    float sum = 0;
    while (tid < N) {
        sum += input[tid];
        tid += blockDim.x * gridDim.x;
    }
    
    cache[cacheIndex] = sum;
    __syncthreads();
    
    // 使用展开的归约(减少同步)
    if (blockDim.x >= 512) {
        if (cacheIndex < 256) {
            cache[cacheIndex] += cache[cacheIndex + 256];
        }
        __syncthreads();
    }
    if (blockDim.x >= 256) {
        if (cacheIndex < 128) {
            cache[cacheIndex] += cache[cacheIndex + 128];
        }
        __syncthreads();
    }
    if (blockDim.x >= 128) {
        if (cacheIndex < 64) {
            cache[cacheIndex] += cache[cacheIndex + 64];
        }
        __syncthreads();
    }
    
    // 最后的warp内归约(用shuffle指令,无bank conflict)
    if (cacheIndex < 32) {
        volatile float* vcache = cache;
        vcache[cacheIndex] += vcache[cacheIndex + 32];
        vcache[cacheIndex] += vcache[cacheIndex + 16];
        vcache[cacheIndex] += vcache[cacheIndex + 8];
        vcache[cacheIndex] += vcache[cacheIndex + 4];
        vcache[cacheIndex] += vcache[cacheIndex + 2];
        vcache[cacheIndex] += vcache[cacheIndex + 1];
    }
    
    if (cacheIndex == 0) {
        output[blockIdx.x] = cache[0];
    }
}
// 性能: 5.2倍

八、动手实践:内存带宽测试

8.1 编写带宽测试程序

cpp 复制代码
#include <cuda_runtime.h>
#include <stdio.h>
#include <time.h>

#define N (1024 * 1024 * 64)  // 64M个float → 256MB
#define M 10  // 重复次数

// 简单的内存拷贝核函数
__global__ void copy_kernel(float* in, float* out, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        out[idx] = in[idx];
    }
}

// 非合并访问的核函数
__global__ void copy_noncoalesced(float* in, float* out, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        int offset = idx * 64;  // 跳跃64个元素
        if (offset < n) {
            out[offset] = in[offset];
        }
    }
}

double get_time() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

int main() {
    float *d_in, *d_out;
    float *h_in = (float*)malloc(N * sizeof(float));
    
    // 初始化数据
    for (int i = 0; i < N; i++) {
        h_in[i] = i * 1.0f;
    }
    
    cudaMalloc(&d_in, N * sizeof(float));
    cudaMalloc(&d_out, N * sizeof(float));
    
    cudaMemcpy(d_in, h_in, N * sizeof(float), cudaMemcpyHostToDevice);
    
    int threads = 256;
    int blocks = (N + threads - 1) / threads;
    
    // 测试合并访问
    cudaDeviceSynchronize();
    double start = get_time();
    for (int i = 0; i < M; i++) {
        copy_kernel<<<blocks, threads>>>(d_in, d_out, N);
    }
    cudaDeviceSynchronize();
    double end = get_time();
    
    double elapsed = (end - start) / M;
    double bandwidth = (N * sizeof(float) * 2) / elapsed / 1e9;  // GB/s
    
    printf("合并访问带宽: %.2f GB/s\n", bandwidth);
    
    // 测试非合并访问
    cudaDeviceSynchronize();
    start = get_time();
    for (int i = 0; i < M; i++) {
        copy_noncoalesced<<<blocks, threads>>>(d_in, d_out, N);
    }
    cudaDeviceSynchronize();
    end = get_time();
    
    elapsed = (end - start) / M;
    bandwidth = (N * sizeof(float) * 2) / elapsed / 1e9;
    
    printf("非合并访问带宽: %.2f GB/s\n", bandwidth);
    
    cudaFree(d_in);
    cudaFree(d_out);
    free(h_in);
    
    return 0;
}

8.2 预期结果

在A100上运行:

复制代码
合并访问带宽: 1450.32 GB/s
非合并访问带宽: 48.75 GB/s

**相差近30倍!**这就是内存访问模式的重要性。

九、本节总结

9.1 内存优化黄金法则

  1. 寄存器最快,但最少:尽量复用寄存器,但避免溢出
  2. 共享内存是关键:手动管理缓存,注意Bank Conflict
  3. 常量内存适合广播:所有线程读同一地址时使用
  4. 全局内存要合并访问:让Warp访问连续地址
  5. 避免本地内存:控制寄存器使用,避免大数组

9.2 内存选择决策树

复制代码
问题:数据应该放哪里?
│
├─ 是否是只读且所有线程访问相同地址? → 常量内存
│
├─ 是否是同一个Block内频繁共享? → 共享内存
│
├─ 是否有2D空间局部性访问模式? → 纹理内存
│
├─ 是否是每个线程私有的临时变量?
│   ├─ 数量少 (<64个) → 寄存器
│   └─ 数量多 (>64个) → 重新设计算法,或用共享内存
│
└─ 其他情况 → 全局内存(注意合并访问!)

9.3 下节预告

下一节我们将进入CUDA编程模型实战,从零开始写第一个核函数,把今天学的内存知识用起来:

  • 线程层次配置
  • 内存API使用
  • 第一个向量加法程序
  • 性能对比分析

十、面试真题(2024-2026)

Q1:什么是合并访问?为什么它如此重要?

考察点:对全局内存访问模式的理解

参考答案

合并访问是指一个Warp内的32个线程访问连续、对齐的内存地址。硬件可以将这些访问合并成少数几个内存事务(通常是1-2个),最大化利用显存带宽。

重要性:非合并访问可能导致带宽利用率下降90%以上,性能差30倍。因为每次内存访问都有400周期的延迟,合并访问让一次延迟服务32个线程,而非合并访问则需要32次延迟。

Q2:什么是Bank Conflict?如何避免?

考察点:对共享内存的理解

参考答案

共享内存被划分为32个Bank,每个Bank每周期只能响应一个访问请求。当同一Warp的多个线程访问同一个Bank的不同地址时,访问会串行化,这就是Bank Conflict。

避免方法:

  1. 填充(Padding):在每行末尾加几个无用元素
  2. 改变访问模式:尽量让同一Warp的线程访问不同Bank
  3. 使用volatile关键字:在某些情况下避免编译器优化导致的冲突
  4. 利用广播机制:同一Bank的同地址读取可以广播

Q3:寄存器溢出会有什么后果?如何检测和避免?

考察点:对寄存器管理的理解

参考答案

后果:溢出的变量被存入本地内存(实际在显存),访问速度从1周期降到400周期,性能可能下降10倍以上。

检测:编译时加--ptxas-options=-v,看lmem字段。

避免:

  1. __launch_bounds__限制每线程寄存器数
  2. -maxrregcount编译选项强制限制
  3. 拆分kernel,减少每个kernel的复杂度
  4. 用共享内存代替寄存器数组
  5. 用作用域控制变量生命周期

Q4:常量内存和全局内存有什么区别?什么时候用常量内存?

考察点:对不同内存类型的理解

参考答案

维度 常量内存 全局内存
位置 片上缓存 + 片外存储 片外显存
容量 64KB 数十GB
访问模式 广播最佳 合并访问最佳
适用场景 所有线程访问同地址 任意访问模式

适用常量内存的场景:

  • 神经网络权重(推理时只读)
  • 滤波器系数
  • 查找表
  • 任何所有线程需要相同数据的场景

Q5:如何测量实际的内存带宽?为什么理论带宽和实际有差距?

考察点:性能分析能力

参考答案

测量方法:执行简单的拷贝核函数(如cudaMemcpy或简单kernel),记录传输数据量和耗时,带宽 = 数据量 / 时间。

理论vs实际差距原因:

  1. 协议开销:PCIe/NVLink协议本身有开销
  2. 访问模式:非合并访问降低效率
  3. Bank Conflict:共享内存冲突
  4. 指令开销:kernel启动和循环有额外指令
  5. 内存控制器限制:无法100%饱和

思考题

在你的GPU上运行上面的带宽测试程序,对比合并访问和非合并访问的实际差距。如果你的GPU和A100差距很大,思考为什么?欢迎在评论区分享你的测试结果。

相关推荐
DANGAOGAO2 小时前
大语言模型添加Rag
人工智能·语言模型·自然语言处理
测试_AI_一辰2 小时前
AI测试工程笔记:AI Agent评测体系设计(从数据集到质量验证)
人工智能·笔记·功能测试·自动化·ai编程
新缸中之脑2 小时前
模型蒸馏综合指南
人工智能
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 日榜(2026-03-17)
人工智能·ai·大模型·github·ai教程
实在智能RPA2 小时前
2026年企业与必要部署智能体吗?深度拆解AI Agent重构生产力的技术路线与选型逻辑
人工智能·重构
深小乐2 小时前
从 AI Skills 学实战技能(一):如何获取抖音、B 站、微博等平台热点话题
人工智能
balmtv2 小时前
Claude国内镜像站实测:可扩展监督与宪法AI,推理架构的范式革命
人工智能·机器学习·架构
AustinCyy2 小时前
【论文笔记】Learning to Retrieve In-Context Examples for Large Language Models
论文阅读·人工智能·语言模型
Dxy12393102162 小时前
PyTorch的自定义学习率调度器详细介绍
人工智能·pytorch·学习