05 · CUDA 内存优化
目标:理解 GPU 内存层级,掌握 Shared Memory Tiling 优化 GEMM 的原理,理解 bank conflict 和延迟隐藏机制
一、GPU 内存层级
高层抽象:
1 | 寄存器(Register) → Shared Memory → L2 Cache → HBM(显存) |
| 层级 | 作用域 | 延迟 | 容量(3080 Ti) |
|---|---|---|---|
| 寄存器 | 每个 thread 私有 | ~0 | 极小 |
| Shared Memory | block 内共享 | ~5ns | 每个 SM 约 100KB |
| L2 Cache | 所有 SM 共享 | 中等 | 6MB(4090D 72MB) |
| HBM | 所有 SM 共享 | 200-800 时钟周期 | 12GB |
核心推论: 同一时刻需要被多次读取的数据,应该先从 HBM 搬到 Shared Memory,再从 Shared Memory 读取计算——减少 HBM IO 次数。
二、CUDA 编程的固定骨架
所有 CUDA 程序围绕一个核心问题:CPU 和 GPU 是两块独立硬件,内存独立,不能直接互访。
1 | 1. 在 GPU 上分配内存(cudaMalloc) |
关键语法逐行说明:
1 | float *d_A; |
__global__:kernel 函数的标志,在 GPU 上执行,从 CPU 调用。函数参数里的指针必须是 GPU 显存指针,传 CPU 内存指针会崩溃。
__shared__:声明 Shared Memory 变量,block 内所有 thread 共享,生命周期等于 block 的生命周期。
三、GEMM 是什么
GEMM(General Matrix Multiply):C = A × B,A 是 M×K,B 是 K×N,C 是 M×N。
推理计算量最大的操作全是 GEMM:
- FFN 的 W₁、W₂ 矩阵乘法
- Attention 的 QKᵀ 点积
- Attention 的 score × V
三、Naive GEMM 的问题
每个 thread 计算 C 的一个元素,直接从 HBM 读数据:
1 | __global__ void gemm_naive(float* A, float* B, float* C, int n) { |
问题:A 的同一行被 N 个 thread 各从 HBM 读一次,B 的同一列被 M 个 thread 各从 HBM 读一次。大量重复的 HBM 读取。
四、Shared Memory Tiling
核心思路: 把 A 和 B 分成 TILE_SIZE × TILE_SIZE 的小块,每次协作把一个 tile 加载到 Shared Memory,block 内所有 thread 复用这份数据。
1 | HBM → Shared Memory(第一个 __syncthreads__ 等待这步完成) |
__syncthreads() 的作用:
- 第一个:等所有 thread 把数据从 HBM 搬到 Shared Memory。如果去掉,某些 thread 还没写完,其他 thread 就开始读,得到垃圾数据
- 第二个:等所有 thread 计算完毕,再加载下一个 tile,避免覆盖还在使用中的数据
1 | __global__ void gemm_tiled(float* A, float* B, float* C, int n) { |
每个 tile 只从 HBM 读一次,被 block 内 TILE_SIZE 个 thread 复用——HBM 访问减少 TILE_SIZE 倍。
五、实验结果(4090D,1024×1024)
1 | naive time=0.477 ms GFLOPS=4498.9 |
4090D 的 L2 Cache 是 72MB,几乎把整个 1024×1024 矩阵(4MB)缓存住了。naive kernel 的重复 HBM 读取大部分命中 L2,tiling 的优势被硬件缓存抵消。
两个版本都只达到 4090D 理论峰值(82.6 TFLOPS)的 5-7%——完全是 memory-bound,算力没有被用满。
优化效果取决于硬件: 在 L2 更小的老 GPU 上,tiling 的提升可以达到 5-10x。理解内存层级和数据复用是写高性能 kernel 的基础,不因为硬件自动缓存了就失去意义。
六、Bank Conflict
Shared Memory 被分成 32 个 bank,每个 bank 宽度 4 字节。同一个 warp 里的 thread 访问 Shared Memory 时,如果多个 thread 访问同一个 bank 的不同地址,会被串行化。
1 | 无冲突(每个 thread 访问不同 bank): |
tiled kernel 里的问题:
内循环 tileA[threadIdx.y][k] 中,所有 thread 的 k 相同,threadIdx.y 不同,访问的是同一列(列优先访问),间隔 TILE_SIZE 个元素,导致 bank conflict。
优化方式:加载时转置存储 tileA
1 | // 加载时转置 |
七、Warp 调度与指令执行
同一个 warp 内的 32 个 thread 在同一时刻执行同一条指令(SIMT)。
不同 warp 之间完全独立,各自执行各自的指令:
1 | Warp 0:执行 load A[i] |
SM 的调度器每个时钟周期选一个 ready 的 warp 发射指令,在多个 warp 之间切换,让执行单元始终有活干——这是延迟隐藏的底层机制。
参考材料
- CUDA C Programming Guide Ch5-6:https://docs.nvidia.com/cuda/cuda-c-programming-guide/
- how-to-optimize-gemm:https://github.com/yzhaiustc/Optimizing-GEMM-on-NVIDIA-Turing-GPUs
