引言
不懂内存,写不出高性能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 内存优化黄金法则
- 寄存器最快,但最少:尽量复用寄存器,但避免溢出
- 共享内存是关键:手动管理缓存,注意Bank Conflict
- 常量内存适合广播:所有线程读同一地址时使用
- 全局内存要合并访问:让Warp访问连续地址
- 避免本地内存:控制寄存器使用,避免大数组
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。
避免方法:
- 填充(Padding):在每行末尾加几个无用元素
- 改变访问模式:尽量让同一Warp的线程访问不同Bank
- 使用
volatile关键字:在某些情况下避免编译器优化导致的冲突 - 利用广播机制:同一Bank的同地址读取可以广播
Q3:寄存器溢出会有什么后果?如何检测和避免?
考察点:对寄存器管理的理解
参考答案 :
后果:溢出的变量被存入本地内存(实际在显存),访问速度从1周期降到400周期,性能可能下降10倍以上。
检测:编译时加--ptxas-options=-v,看lmem字段。
避免:
__launch_bounds__限制每线程寄存器数-maxrregcount编译选项强制限制- 拆分kernel,减少每个kernel的复杂度
- 用共享内存代替寄存器数组
- 用作用域控制变量生命周期
Q4:常量内存和全局内存有什么区别?什么时候用常量内存?
考察点:对不同内存类型的理解
参考答案:
| 维度 | 常量内存 | 全局内存 |
|---|---|---|
| 位置 | 片上缓存 + 片外存储 | 片外显存 |
| 容量 | 64KB | 数十GB |
| 访问模式 | 广播最佳 | 合并访问最佳 |
| 适用场景 | 所有线程访问同地址 | 任意访问模式 |
适用常量内存的场景:
- 神经网络权重(推理时只读)
- 滤波器系数
- 查找表
- 任何所有线程需要相同数据的场景
Q5:如何测量实际的内存带宽?为什么理论带宽和实际有差距?
考察点:性能分析能力
参考答案 :
测量方法:执行简单的拷贝核函数(如cudaMemcpy或简单kernel),记录传输数据量和耗时,带宽 = 数据量 / 时间。
理论vs实际差距原因:
- 协议开销:PCIe/NVLink协议本身有开销
- 访问模式:非合并访问降低效率
- Bank Conflict:共享内存冲突
- 指令开销:kernel启动和循环有额外指令
- 内存控制器限制:无法100%饱和
思考题 :
在你的GPU上运行上面的带宽测试程序,对比合并访问和非合并访问的实际差距。如果你的GPU和A100差距很大,思考为什么?欢迎在评论区分享你的测试结果。