CUDA性能优化
性能演进如下图所示
1 | 性能 (GFLOPS, V100 FP32) |
基础全局内存 Kernel
线程映射、内存索引、并行执行。
1 | __global__ void matmul_base(const float * A, const float* B, float* C, int M, int N, int K) { |
上面这种访问内存的方法,
对于单个线程内部,
x,y固定,i变化,则A的访问缓存友好;在 CUDA 硬件中,
threadIdx.x是变化最快的维度,因此同一个 Warp 内的线程,其x坐标必然是连续(或分段连续)的,而y坐标相对稳定。分析合并访问时,对于 Warp 级别,同一循环迭代(i相同),A访问的地址相同(广播),B访问的地址连续(合并访问)
A 的内存访问方式,依赖 L1 缓存广播,如果 N
过大,A 的一行可能超出 L1
缓存,导致缓存抖动,修改为下面这种共享内存的访问方式,
| 访问模式 | 代码 | 硬件行为 | 问题 |
|---|---|---|---|
| A 访问 | A[row * N + i] |
同 warp 内线程读 相同地址 | 广播依赖 L1 缓存 |
| B 访问 | B[i * K + col] |
同 warp 内线程读 连续地址 | 合并访问 |
| C 写入 | C[row * K + col] |
同 warp 内线程写 连续地址 | 合并写入 |
性能瓶颈在于全局内存访问次数
A:\(M\times K\times N\) 次读取,实际数据量 \(M\times N\rightarrow K\) 倍冗余;B:\(M\times N\times K\) 次读取,实际数据量 \(N\times K\rightarrow M\) 倍冗余;
共享内存 Tiling
通过共享内存复用数据,减少全局内存访问。
1 |
|
以 TILE_SIZE=16 为例,将 \(N\) 维度分成 \(N/TILE\_SIZE\) 个块,然后每个 block 的
16×16 线程协作
- 加载
A的16×16子块 和B的16×16子块 到 Shared Memory 16×16线程并行计算16×16次乘加- 重复 \(N/TILE\_SIZE\) 次
最终的数据复用效果是全局内存访问减少 16 倍
A的每个元素被 block 内 16 个线程复用(同一行的不同列)B的每个元素被 block 内 16 个线程复用(同一列的不同行)
Bank Conflict 优化
避免多个线程同时访问同一 Memory Bank,导致串行化。
Shared Memory Bank
GPU 共享内存物理结构分为 32 个 bank,每个 bank 每周期可服务 1 个 4B 访问,32 个连续 float 地址分布到 32 个 bank:
1
2 addr: 0 1 2 ... 31 32 33 ...
bank: [0] [1] [2] ... [31] [0] [1] ...
- 无冲突访问:warp 内 32 线程访问 32 个不同 bank, 1 周期完成
- 有冲突访问:warp 内多个线程访问同一 bank , 串行执行,周期数 = 冲突线程数
Tiling kernel 中的共享内存访问为
1 |
|
在访问时,同 warp 内:threadIdx.x
变化快,threadIdx.y 相对固定;
在计算时:固定 k,不同 threadIdx.y 访问
As [0][k], As [1][k], As [2][k]...,对应的地址计算为
1 | &As [threadIdx.y][k] = base + threadIdx.y * (TILE_SIZE) * 4 + k * 4 |
若 \(TILE\_SIZE = 16\),地址间隔为\(64B=2\times 32B\),Memory Bank索引为
1 | (base / 4 + threadIdx.y * 16 + k) % 32 |
结果导致threadIdx.y = 0 和 threadIdx.y = 2
索引相同,访问同一 Memory Bank,发生冲突!
可以采用添加Padding的方案,使列间距不是32的倍数
1 | __shared__ float As [TILE_SIZE][TILE_SIZE + PADDING]; // PADDING = 8 |
这种方案可以消除Bank Conflict,但是会增加共享内存占用,降低占用率
向量化内存访问
让每次内存请求携带更多有效数据,提升带宽利用率。
GPU 内存事务
GPU 全局内存访问特性为
- 最小事务粒度:32 字节 (L1 cache line) 或 128 字节 (L2)
- warp 内 32 线程访问连续 128 字节 → 1 个 128B 事务(高效访问)
- warp 内 32 线程访问分散地址 → 多个事务(低效访问)
1 | // 标量加载, 每次 4B |
对于 warp 加载 32 个 float,共 128B
标量方式:
- 32 条
ld.global.f32指令 - 硬件合并:128B 连续地址 → 1 个 128B 事务
- 指令解码/调度开销:32 条指令
向量化方式:
- 8 条
ld.global.v4.f32指令 - 硬件合并:8×16B = 128B → 1 个 128B 事务
- 指令开销:8 条指令
寄存器与 Occupancy 调优
延迟隐藏与资源平衡:
Occupancy = 活跃 warp 数 / 理论最大 warp 数;通过增加并发 warp 数,隐藏内存/计算延迟。