【infra之路】02-CUDA内存层次与合并访问

01讲了"谁在执行"(线程层次),这一课讲"数据放在哪"(内存层次)。GPU kernel 性能的 80% 取决于你如何管理内存,而不是计算本身。因为 GPU 的计算单元极快,但内存访问相对很慢------如何喂数据给计算单元,就是优化的核心。


GPU 内存全景图

复制代码
速度 ↑    容量 ↓

┌─────────────────────────────────────────────────────────┐
│  Register(寄存器)    每线程私有    ~1 cycle 延迟        │  ← 最快
│                        数量:每 SM 65536 个 32-bit       │
├─────────────────────────────────────────────────────────┤
│  Shared Memory         每 Block 共享   ~20-30 cycles    │  ← 用户可控的 L1
│  (共享内存)            大小:48-228 KB / SM            │
├─────────────────────────────────────────────────────────┤
│  L1 Cache              每 SM 私有      ~30-80 cycles    │  ← 硬件自动管理
│                        与 Shared Memory 共享物理 SRAM   │
├─────────────────────────────────────────────────────────┤
│  L2 Cache              全 GPU 共享    ~200-800 cycles   │
│                        A100: 40 MB, H100: 50 MB        │
├─────────────────────────────────────────────────────────┤
│  Global Memory(全局显存)  所有 SM 共享  ~300-600 cycles │  ← 最慢
│                          A100: 80 GB HBM2e             │
│                          带宽: ~2 TB/s                  │
└─────────────────────────────────────────────────────────┘

速度 ↓    容量 ↑

cycle 的全称是 Clock Cycle(时钟周期):在计算机体系结构和 GPU 编程中,它是衡量时间/延迟的最基本单位。你可以把它理解为 GPU 内部节拍器打出的 "一个节拍"。

速度差距:Global Memory 的一次访问,够 Register 执行几百条指令。这就是为什么你需要把频繁使用的数据搬到 Shared Memory 或 Register 里。

各层内存的属性总结:

内存类型 作用域 生命周期 速度 容量 谁管理
Register 单个线程 kernel 执行期间 ~1 cycle 255 个/线程 编译器
Shared Memory 一个 Block 内所有线程 Block 执行期间 ~20 cycles 48-228 KB/SM 程序员手动
L1 Cache 单个 SM 硬件决定 ~30 cycles 与 SM 共享 SRAM 硬件自动
L2 Cache 全 GPU 硬件决定 ~200 cycles 40-50 MB 硬件自动
Global Memory 全 GPU 程序员分配 ~300+ cycles 数十 GB 程序员分配

Global Memory 与 Coalesced Access(合并访问)

Global Memory 是最慢但容量最大的内存,几乎所有数据最初都在这里。如何访问它直接决定了 kernel 的带宽利用率。

什么是 Coalesced Access?

GPU 的 Global Memory 读写不是按单个线程发起的,而是以 warp(32 个线程)为单位发起的。一个 warp 访问 Global Memory 时,硬件会把 32 个线程的地址合并成尽量少的内存事务(memory transaction)。

完美合并(Coalesced) :32 个线程访问连续内存地址,硬件只需 1 次 128 字节的内存事务。

复制代码
Thread 0  → addr[0]     ┐
Thread 1  → addr[1]     │
Thread 2  → addr[2]     ├─→ 合并为 1 次 128B 事务 ✓
...                     │
Thread 31 → addr[31]    ┘

非合并(Uncoalesced) :32 个线程访问分散地址,硬件需要 32 次独立内存事务。

复制代码
Thread 0  → addr[0]     ┐
Thread 1  → addr[1024]  │
Thread 2  → addr[2048]  ├─→ 32 次独立事务 ✗ 带宽浪费 ~30x
...                     │
Thread 31 → addr[31744] ┘

关键规则

对于一维数组:threadIdx.x 相邻的线程访问相邻内存地址 → 完美合并。

cuda 复制代码
// ✓ 完美合并:连续线程访问连续地址
int tid = threadIdx.x + blockIdx.x * blockDim.x;
float val = array[tid];  // thread 0→[0], thread 1→[1], ...

// ✗ 非合并:步长访问
int tid = threadIdx.x + blockIdx.x * blockDim.x;
float val = array[tid * 32];  // thread 0→[0], thread 1→[32], thread 2→[64]...

二维数组的访问陷阱(矩阵运算必知)

C/CUDA 中矩阵是行优先存储 (row-major):matrix[row][col] 在内存中是 matrix[row * num_cols + col]

cuda 复制代码
// ✓ 合并访问:threadIdx.x 对应列(同一行内连续)
int row = threadIdx.y + blockIdx.y * blockDim.y;
int col = threadIdx.x + blockIdx.x * blockDim.x;
float val = matrix[row * num_cols + col];
// 同一 warp 内 threadIdx.x 连续 → col 连续 → 地址连续 → 合并 ✓

// ✗ 非合并:threadIdx.x 对应行(跨行访问)
int row = threadIdx.x + blockIdx.x * blockDim.x;
int col = threadIdx.y + blockIdx.y * blockDim.y;
float val = matrix[row * num_cols + col];
// 同一 warp 内 threadIdx.x 连续 → row 连续 → 地址跳跃 num_cols → 不合并 ✗

结论:操作矩阵时,让 threadIdx.x 对应列方向(内存连续方向)。 这在写 matmul、attention 等 kernel 时至关重要。

对齐要求

起始地址最好是 128 字节对齐(即 32 个 float 的整数倍),否则一次 warp 访问可能跨越两个 128B segment,需要 2 次事务。


Shared Memory(共享内存)

Shared Memory 是 CUDA 优化最重要的武器。它位于 SM 芯片上,速度接近寄存器,容量有限但远快于 Global Memory。

为什么需要 Shared Memory?

核心场景:Block 内多个线程需要读取相同的数据。如果每个线程都从 Global Memory 读,就浪费了大量带宽。把数据先搬到 Shared Memory,然后所有线程从 Shared Memory 读------带宽节省 N 倍(N = Block 大小)。

复制代码
不使用 Shared Memory:
  Global Memory → Thread 0 读 data[i]
  Global Memory → Thread 1 读 data[i]   ← 重复读取,浪费带宽
  Global Memory → Thread 2 读 data[i]

使用 Shared Memory:
  Global Memory → Thread 0 读 data[i] → 存入 Shared Memory
  Shared Memory → Thread 1 读 data[i]  ← 高速读取
  Shared Memory → Thread 2 读 data[i]

典型用法:Tiled Matrix Multiplication

详细解释可看【infra之路】Tiled Matrix Multiplication详解

cuda 复制代码
__global__ void matMulShared(float *A, float *B, float *C, int N) {
    // 每个 Block 负责 C 的一个 TILE_SIZE × TILE_SIZE 子矩阵
    __shared__ float sA[TILE_SIZE][TILE_SIZE];  // 声明共享内存
    __shared__ float sB[TILE_SIZE][TILE_SIZE];

    int row = threadIdx.y + blockIdx.y * TILE_SIZE;
    int col = threadIdx.x + blockIdx.x * TILE_SIZE;

    float sum = 0.0f;

    // 沿 K 维度分块:每次加载一个 tile 到 shared memory
    for (int t = 0; t < N / TILE_SIZE; t++) {
        // 协作加载:每个线程负责加载 A 和 B 的一个元素
        sA[threadIdx.y][threadIdx.x] = A[row * N + (t * TILE_SIZE + threadIdx.x)];
        sB[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * N + col];

        __syncthreads();  // 必须同步:确保所有线程都加载完了

        // 在 tile 内做计算
        for (int k = 0; k < TILE_SIZE; k++) {
            sum += sA[threadIdx.y][k] * sB[k][threadIdx.x];
        }

        __syncthreads();  // 必须同步:确保所有线程都用完了再加载下一个 tile
    }

    C[row * N + col] = sum;
}

Bank Conflicts(存储体冲突)

Shared Memory 内部被分成 32 个 Bank(与 warp 的 32 个线程对应)。理想情况下,每个线程访问不同 bank,没有冲突。

复制代码
Bank 分配规则:地址 addr 落在 bank = (addr / 4) % 32  (假设 4 字节元素)

Bank 0:  addr[0],  addr[32], addr[64], ...
Bank 1:  addr[1],  addr[33], addr[65], ...
...
Bank 31: addr[31], addr[63], addr[95], ...

冲突场景:同一 warp 中多个线程访问同一 bank 的不同地址 → 串行化(N 个线程冲突 → N-way bank conflict)。

cuda 复制代码
// ✗ 所有线程访问同一列 → 全部命中 bank 0 → 32-way conflict
sA[threadIdx.x][0]  // 所有线程读 sA 的第 0 列

// ✓ 每个线程访问不同行不同列 → 无 conflict
sA[threadIdx.x][threadIdx.x]  // 对角线访问

经典解决方案:给二维 Shared Memory 数组加 padding

cuda 复制代码
// 原来:32×32,列访问会导致 bank conflict
__shared__ float sA[32][32];

// 解决:32×33,错开 bank 映射
__shared__ float sA[32][33];  // 多一列 padding
// 现在 sA[0][0]→bank 0, sA[1][0]→bank 1, sA[2][0]→bank 2...

特殊情况 :所有线程访问同一地址(broadcast),不会产生 conflict,硬件会广播该值给所有线程。


Register(寄存器)

寄存器是最快的存储,每个线程私有。编译器会自动把局部变量分配到寄存器。

关键权衡:寄存器使用 vs Occupancy

  • 每个 SM 有固定数量的寄存器(A100: 65536 个 32-bit)

  • 每个线程使用的寄存器越多,SM 能容纳的活跃线程数越少

  • 活跃线程数少 → 可用 warp 少 → 内存延迟隐藏不住 → 性能下降

    例:A100,每 SM 65536 个寄存器,最大 2048 个线程

    如果每线程用 32 个寄存器:65536 / 32 = 2048 个线程 → 满 occupancy ✓
    如果每线程用 128 个寄存器:65536 / 128 = 512 个线程 → 只有 25% occupancy ✗

实践建议

  • 避免在 kernel 中使用大量局部变量或大数组(会被溢出到 Local Memory,即慢速 Global Memory)
  • __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) 提示编译器优化寄存器分配
  • nsight compute 查看实际寄存器使用量

综合示例:Shared Memory 在 Attention 中的应用

FlashAttention 的核心思想就是把本课讲的内存层次运用到极致:

复制代码
标准 Attention 的问题:
  O(N²) 的 Attention 矩阵太大,必须写到 Global Memory(HBM)
  → 两次 HBM 读写(写 S 矩阵 + 读 S 矩阵),带宽瓶颈

FlashAttention 的解法:
  把 Q, K, V 切成小块(tiles)
  每个 tile 加载到 SRAM(Shared Memory)
  在 SRAM 内完成局部 attention 计算
  直接累加到输出,不需要把整个 S 矩阵写回 HBM
  → HBM 访问量从 O(N²) 降到 O(N)

这正是第 3-4 周要深入的 FlashAttention 原理,但本质就是今天讲的:把数据搬到更快的内存层级,减少对慢速内存的访问。


本课小结

概念 要点
内存层次 Register > Shared > L1 > L2 > Global,速度差几个数量级
Coalesced Access 同一 warp 的线程访问连续地址 → 合并为 1 次内存事务
矩阵访问 threadIdx.x 对应列方向(内存连续方向)才能合并
Shared Memory Block 内共享,程序员手动管理,用 __syncthreads() 同步
Bank Conflict 多线程访问同 bank 不同地址会串行化,用 padding 解决
Broadcast 所有线程读同一地址不冲突,硬件自动广播
Register 权衡 每线程寄存器越多 → occupancy 越低 → 可能性能下降
FlashAttention 本质 把 attention 计算搬到 SRAM,减少 HBM 访问

自检问题

在继续下一课之前,确认你理解了这些:

  1. 一个 warp 访问 Global Memory 时,32 个线程访问连续地址需要几次内存事务?(答:1 次)
  2. 为什么操作矩阵时要让 threadIdx.x 对应列而不是行?(答:列方向内存连续,满足 coalesced access)
  3. __syncthreads() 在使用 Shared Memory 时为什么必须?(答:确保所有线程都完成加载/消费,避免数据竞争)
  4. 如果 kernel 中声明了一个 float arr[100] 的局部数组,它会被放在哪?(答:寄存器放不下时溢出到 Local Memory,即 Global Memory,很慢)