CUDA2_GPU的内存体系及其优化指南
CUDA(二):GPU的内存体系及其优化指南
原文作者:紫气东来
原文链接:https://zhuanlan.zhihu.com/p/654027980
概述
在冯·诺依曼架构的硬件中实现高性能计算,最重要的两点是:访存和计算。这两点分别对应着 IO bound 和 compute bound,硬件系统的内存体系深刻影响着这两点。本文深入探讨GPU的内存体系,并通过规约(Reduction)操作的实践案例,展示如何进行CUDA编程优化。
一、GPU的内存体系
1.1 各级内存及其特点
CUDA 内存模型结合了主机和设备的内存系统,具有完整的层次结构,可以显式地进行控制和优化。
1.1.1 全局内存(Global Memory)
特点:
- GPU中最大的内存空间,通常说的"显存"中的大部分都是全局内存
- 延迟最高,访问速度相对较慢
- 生命周期贯穿应用程序的整个生命周期
- 可以在任何SM设备上被访问
主要用途:
- 为核函数提供数据
- 在主机与设备、设备与设备之间传递数据
使用方式:
// 数据传输
cudaMemcpy(d_x, h_x, M, cudaMemcpyHostToDevice);
// 静态声明
__device__ T x; // 单个变量
__device__ T y[N]; // 固定长度的数组
1.1.2 常量内存(Constant Memory)
特点:
- 存储在片下设备内存上,通过特殊的常量内存缓存(constant cache)进行缓存读取
- 只读内存
- 容量有限,仅有 64 KB
- 访问速度比全局内存高,但前提是一个线程束(warp)中的32个线程要读取相同的常量内存数据
使用方式:
// 在核函数外定义
__constant__ T constant_data;
// 从主机复制数据到常量内存
cudaMemcpyToSymbol(constant_data, host_data, size);
1.1.3 纹理内存(Texture Memory)和表面内存(Surface Memory)
特点:
- 类似于常量内存,是一种具有缓存的全局内存
- 有相同的可见范围和生命周期
- 一般仅可读(表面内存也可写)
- 容量比常量内存更大
- 使用方式与常量内存不同
1.1.4 寄存器(Register)
特点:
- 线程能独立访问的资源
- 位于**片上(on chip)**存储
- 访问速度最快
- 容量较小
使用方式:
// 核函数中不加任何限定符的变量通常存放在寄存器中
const int n = blockDim.x * blockIdx.x + threadIdx.x;
c[n] = a[n] + b[n]; // n 就是寄存器变量
可见性和生命周期:
- 仅被一个线程可见
- 每个线程都有自己的副本
- 生命周期与所属线程一致
1.1.5 局部内存(Local Memory)
特点:
- 用法类似于寄存器
- 寄存器中放不下的变量会放在局部内存中
- 索引值不能在编译时确定的数组也可能放在局部内存中
- 从硬件角度看,局部内存只是全局内存的一部分,因此延迟也很高
- 每个线程最多能使用高达 512 KB 的局部内存
注意: 使用过多局部内存会降低程序性能。
1.1.6 共享内存(Shared Memory)
特点:
- 存在于芯片上,具有仅次于寄存器的读写速度
- 数量有限
- 对整个线程块可见
- 生命周期与整个线程块一致
使用方式:
__shared__ real s_y[128]; // 静态共享内存
可见性:
- 每个线程块拥有一个共享内存变量的副本
- 一个线程块中的所有线程都可以访问该线程块的共享内存
- 不能访问其他线程块的共享内存
主要作用:
- 减少对全局内存的访问
- 改善对全局内存的访问模式
1.1.7 L1和L2缓存
结构:
- 每个 SM 都有一个 L1 缓存
- 所有 SM 共享一个 L2 缓存
用途:
- 存储局部内存和全局内存中的数据
- 包括寄存器中溢出的部分
- 减少延时
物理结构:
- 在最新的GPU架构中,L1缓存、纹理缓存及共享内存三者是统一的
- 从编程角度看:
- 共享内存是可编程的缓存(用户完全操控)
- L1和L2缓存是不可编程的缓存(用户最多能引导编译器做一些选择)
1.1.8 内存特点对比表
| 内存类型 | 位置 | 访问速度 | 容量 | 作用域 | 生命周期 |
|---|---|---|---|---|---|
| 寄存器 | 片上 | 最快 | 很小 | 线程 | 线程 |
| 局部内存 | 片外(全局内存的一部分) | 慢 | 较大(512KB/线程) | 线程 | 线程 |
| 共享内存 | 片上 | 很快 | 有限 | 线程块 | 线程块 |
| 全局内存 | 片外 | 慢 | 很大 | 全局 | 应用程序 |
| 常量内存 | 片外(有缓存) | 较快 | 64KB | 全局 | 应用程序 |
| 纹理内存 | 片外(有缓存) | 较快 | 较大 | 全局 | 应用程序 |
1.2 SM构成及典型GPU对比
1.2.1 SM(Streaming Multiprocessor)的组成
一个 GPU 由多个 SM 构成。一个 SM 包含以下资源:
存储资源:
- 一定数量的寄存器
- 一定数量的共享内存
- 常量内存的缓存
- 纹理和表面内存的缓存
- L1缓存
计算资源:
- 线程束调度器(warp scheduler)
- 执行核心:
- 若干整型数运算的核心(INT32)
- 若干单精度浮点数运算的核心(FP32)
- 若干双精度浮点数运算的核心(FP64)
- 若干单精度浮点数超越函数的特殊函数单元(SFUs)
- 若干混合精度的张量核心(tensor cores)
1.2.2 主流GPU产品对比
| GPU | V100 | A100 | H100 | L40S |
|---|---|---|---|---|
| 架构 | Volta | Ampere | Hopper | Ada Lovelace |
| 内存接口 | 4096-bit HBM2 | 5120-bit HBM2 | 5120-bit HBM3 | GDDR6 |
| 内存大小 | 32GB/16GB | 40GB | 80GB | 48GB |
| 内存带宽 | 900 GB/s | 1555 GB/s | 3000 GB/s | 864 GB/s |
| SM数量 | 80 | 108 | 132 | 142 |
| 纹理单元 | 320 | 432 | 528 | 576 |
| L2缓存大小 | 6144 KB | 40 MB | 50 MB | 96 MB |
| 共享内存/SM | 最高96KB | 最高164KB | 最高228KB | 最高128KB |
| 寄存器文件/SM | 256 KB | 256 KB | 256 KB | 256 KB |
| 峰值FP16算力 | 31.4 TFLOPS | 78 TFLOPS | 120 TFLOPS | 90.52 TFLOPS |
关键观察:
- 从V100到H100,内存带宽提升了3.3倍(900 GB/s → 3000 GB/s)
- L2缓存大小显著增加(6MB → 50MB)
- SM数量和共享内存容量都在持续增长
1.3 GPU之外:近存计算与存算一体
为了进一步降低访存成本,获得更高的性能,近存计算与存算一体逐渐成为热门方向。
1.3.1 近存计算:Graphcore IPU
架构特点:
- 没有高速的片外存储,将存储放到了片上
- 整个芯片由1472个核心(Tile)组成
- 每个Tile由独立的计算单元和存储单元组成
- 每个Tile有624KB的SRAM
- 总片上存储:624KB × 1472 = 900MB
计算模式:
- 采用纯分布式架构
- 使用MIMD计算架构(与NVIDIA CUDA的SIMT不同)
- 每个Tile可以独立执行不同的指令,独立访存
- Tile之间的memory不能共享访问,只能访问自己的local memory
- 整个芯片的访存带宽 = Tile访存带宽 × Tile数量
1.3.2 存算一体:后摩智能 H30
核心思想:
通过对存储器单元本身进行算法嵌入,使得计算可以在存储器单元内完成。
特点:
- 包含多个存算单元,既能存储数据也能处理数据
- 打破传统芯片性能瓶颈并提升能效比
- 物理算力可达到256TOPS
- 实现大算力、低功耗、低成本
二、通过规约(Reduction)操作理解GPU内存体系
2.0 规约算法概述
Reduce 算法可以描述为:
x = x_0 ⊗ x_1 ⊗ x_2 ⊗ x_3 ... ⊗ x_{n-1} ⊗ x_n
其中 ⊗ 可表示为求 sum、min、max、avg 等操作,最后获得的输出相比于输入一般维度上会递减。
GPU中的树形计算方式:
在GPU中,reduce采用了一种树形的计算方式。由于GPU没有针对global数据的同步操作,只能针对block的数据进行同步,因此一般将reduce分为两个阶段:
- 第一阶段:开启m个block计算出m个小份的reduce值
- 第二阶段:使用一个block将m个小份再次进行reduce,得到最终结果
实验代码开源地址: https://github.com/ifromeast/cuda_learning/tree/main/02_reduce
2.1 仅使用全局内存实现规约
2.1.1 折半归约法
对于数组归约的并行计算问题,要从一个数组出发,最终得到一个数。假如数组元素个数是2的整数次方,可以将数组后半部分的各个元素与前半部分对应的数组元素相加。如果重复此过程,最后得到的第一个数组元素就是最初的数组中各个元素的和。这就是所谓的折半归约(binary reduction)法。
2.1.2 实现代码
void __global__ reduce_global(real *d_x, real *d_y)
{
const int tid = threadIdx.x;
real *x = d_x + blockDim.x * blockIdx.x;
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
{
if (tid < offset)
{
x[tid] += x[tid + offset];
}
__syncthreads();
}
if (tid == 0)
{
d_y[blockIdx.x] = x[0];
}
}
2.1.3 关键点说明
-
同步函数
__syncthreads():保证一个线程块中的所有线程在执行该语句后面的语句之前都完全执行了该语句前面的语句 -
指针操作:
real *x = d_x + blockDim.x * blockIdx.x;将数组 d_x 中第blockDim.x * blockIdx.x个元素的地址赋给指针 x -
独立归约:for 循环内在各个线程块内对其中的数据独立地进行归约,不同线程块之间不需要同步
-
位操作优化:在 offset 的计算过程中使用了位操作,对于2的幂而言更加高效
-
部分归约:该核函数仅将一个长度为 10^8 的数组 d_x 归约到一个长度为 10^8/128 的数组 d_y
-
内存限制:因为
__global__修饰符的限制,d_y 和 x 均是全局内存的变量
2.2 使用共享内存实现规约
2.2.1 优化动机
由于全局内存访问速度最低,因此性能较低。本节使用对整个线程块可见的共享内存来实现同样的规约操作。
2.2.2 实现方式
使用 __shared__ 修饰共享内存变量 s_y,长度等于线程块大小。然后将全局内存中的数据复制到共享内存中,每个线程块都有一个共享内存变量的副本。
const int tid = threadIdx.x;
const int idx = blockIdx.x * blockDim.x + threadIdx.x;
__shared__ real s_y[128];
s_y[tid] = (idx < N) ? d_x[idx] : 0.0;
__syncthreads();
2.2.3 资源分配说明
GPU需要分配两种资源:
- 存储资源:共享内存数组
- 计算资源:根据thread数量确定
- 一个block中分配128个thread线程
- 32个线程为一组(即一个 warp)
- 绑定在一个SIMD单元
- 128个线程 = 4组SIMD单元
2.2.4 使用共享内存的优势
- 不再要求全局内存数组的长度 N 是线程块大小的整数倍
- 在规约的过程中不会改变全局内存数组中的数据(在仅使用全局内存时,数组 d_x 中的部分元素被改变)
- 访问速度更快:在核函数中对共享内存访问的次数越多,加速效果越明显
2.3 使用动态共享内存实现规约
2.3.1 动态共享内存的必要性
在使用共享内存数组时,如果指定了一个固定的长度(如128),这种静态的方式可能会导致错误的发生,因此有必要使用动态操作。
2.3.2 修改方法
修改1:执行配置中添加第三个参数
<<<grid_size, block_size, sizeof(real) * block_size>>>
前两个参数分别是网格大小和线程块大小,第三个参数是核函数中每个线程块需要定义的动态共享内存的字节数,其默认值为零。
修改2:改变共享内存变量的声明方式
extern __shared__ real s_y[];
与静态共享内存的声明方式有两点不同:
- 必须加上限定词
extern - 不能指定数组大小
2.3.3 性能对比
编译运行代码:
nvcc reduce_gpu.cu -o reduce
通过 nvprof 命令查看GPU各部分的耗时:
nvprof ./reduce
单精度结果(由于该例子中不存在对内存的频繁读写,因此几种方式的性能差别不大):
Type Time(%) Time Calls Avg Min Max Name
GPU activities:
98.25% 30.8438s 300 102.81ms 80.686ms 259.15ms [CUDA memcpy HtoD]
0.44% 137.86ms 100 1.3786ms 1.3756ms 1.3822ms reduce_global(float*, float*)
0.44% 137.66ms 300 458.85us 343.93us 863.09us [CUDA memcpy DtoH]
0.43% 136.51ms 100 1.3651ms 1.3647ms 1.3666ms reduce_shared(float*, float*)
0.43% 136.50ms 100 1.3650ms 1.3647ms 1.3664ms reduce_dynamic(float*, float*)
双精度结果(放大访存速度的差别):
编译方式:
nvcc reduce_gpu.cu -DUSE_DP -o reduce_dp
此时可以看到全局内存的性能出现了明显下降:
Type Time(%) Time Calls Avg Min Max Name
GPU activities:
98.65% 60.8587s 300 202.86ms 189.54ms 304.60ms [CUDA memcpy HtoD]
0.65% 398.54ms 300 1.3285ms 1.2460ms 2.9135ms [CUDA memcpy DtoH]
0.26% 157.32ms 100 1.5732ms 1.5677ms 1.5803ms reduce_global(double*, double*)
0.22% 137.32ms 100 1.3732ms 1.3716ms 1.3746ms reduce_shared(double*, double*)
0.22% 137.31ms 100 1.3731ms 1.3714ms 1.3750ms reduce_dynamic(double*, double*)
2.4 规约的其他优化方法
2.4.1 使用原子函数
优化动机:
在前边几个版本的数组归约函数中,核函数并没有做全部的计算,而只是将一个长一些的数组 d_x 变成了一个短一些的数组 d_y。所有操作所用时间约为 7.5 ms(单精度),而在 GPU 中实际计算的时间仅为 1.4ms 左右。
如果能在 GPU 中计算出最终结果,则有望显著地减少整体的计算时间。有两种方法:
- 用另一个核函数将较短的数组进一步归约
- 在先前的核函数的末尾利用原子函数进行归约,直接得到最终结果
问题分析:
如果简单地将代码改为:
if (tid == 0) {
d_y[0] += s_y[0];
}
这会导致错误,因为:
- 该语句在每一个线程块的第 0 号线程都会被执行
- 执行次序是不确定的
- 该语句可以分解为两个操作:读取 d_y[0] 并与 s_y[0] 相加,然后写入 d_y[0]
- 如果一个线程还未将结果写入 d_y[0],另一个线程就读取了 d_y[0],会导致错误结果
原子函数解决方案:
if (tid == 0)
{
atomicAdd(d_y, s_y[0]);
}
原子函数特点:
atomicAdd(address, val)的第一个参数是待累加变量的地址,第二个参数是累加的值- 将地址 address 中的旧值 old 读出,计算 old + val,然后将计算的值存入地址 address
- 这些操作在一次原子事务(atomic transaction)中完成,不会被别的线程中的原子操作所干扰
- 原子函数不能保证各个线程的执行具有特定的次序,但能够保证每个线程的操作一气呵成
性能提升:
使用原子函数后总时间变为 2.8 ms,相比于之前的方式,性能提升接近3倍:
Type Time(%) Time Calls Avg Min Max Name
GPU activities:
66.95% 194.36ms 100 1.9436ms 1.8185ms 2.0081ms reduce(float const *, float*, int)
33.00% 95.797ms 101 948.48us 1.4400us 95.642ms [CUDA memcpy HtoD]
0.05% 133.09us 100 1.3300us 1.2160us 2.6560us [CUDA memcpy DtoH]
分析:
- GPU中计算耗时从1.4ms增加到1.8ms(因为d_y也在GPU中计算)
- DevicetoHost 的时间占比大幅缩减(从GPU输出结果的数量变少)
2.4.2 使用线程束函数与协作组
线程束(Warp)基础:
- 线程束是 SM 中基本的执行单元
- 一个线程束由32个连续线程组成
- 按照单指令多线程(SIMT)方式执行(所有线程执行相同指令,每个线程在私有数据上操作)
- 如果同一线程束中的线程执行不同的指令,会发生线程束分化(warp divergence),导致性能明显下降
优化1:使用线程束同步函数
当所涉及的线程都在一个线程束内时,可以将线程块同步函数 __syncthreads 换成更加廉价的线程束同步函数 __syncwarp:
for (int offset = blockDim.x >> 1; offset >= 32; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
for (int offset = 16; offset > 0; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncwarp();
}
- 当
offset >= 32时,使用线程块同步函数__syncthreads - 当
offset <= 16时,使用束内同步函数__syncwarp
优化2:使用线程束洗牌函数
函数 __shfl_down_sync 的作用是将高线程号的数据平移到低线程号中去,这正是归约问题中需要的操作:
for (int offset = 16; offset > 0; offset >>= 1)
{
y += __shfl_down_sync(FULL_MASK, y, offset);
}
相比之前的版本,有两处不同:
- 在进行线程束内的循环之前,将共享内存中的数据复制到了寄存器。因为寄存器一般来说比共享内存更高效
- 去掉了束同步函数,因为洗牌函数能够自动处理同步与读-写竞争问题
优化3:使用协作组(Cooperative Groups)
协作组可以看作是线程块和线程束同步机制的推广,提供了更为灵活的线程协作方式。
使用协作组需要包含头文件:
#include <cooperative_groups.h>
using namespace cooperative_groups;
可以用函数 tiled_partition 将一个线程块划分为若干片(tile),每一片构成一个新的线程组。目前仅可以将片的大小设置为 2 的正整数次方且不大于 32(即 2、4、8、16 和 32)。
real y = s_y[tid];
thread_block_tile<32> g = tiled_partition<32>(this_thread_block());
for (int i = g.size() >> 1; i > 0; i >>= 1)
{
y += g.shfl_down(y, i);
}
性能对比:
Type Time(%) Time Calls Avg Min Max Name
GPU activities:
29.49% 190.45ms 100 1.9045ms 1.8181ms 2.0072ms reduce_syncwarp(float const *, float*, int)
27.84% 179.82ms 100 1.7982ms 1.7960ms 1.8183ms reduce_shfl(float const *, float*, int)
27.82% 179.65ms 100 1.7965ms 1.7957ms 1.7976ms reduce_cp(float const *, float*, int)
14.80% 95.571ms 301 317.51us 1.4390us 95.122ms [CUDA memcpy HtoD]
0.06% 384.69us 300 1.2820us 1.2150us 1.7920us [CUDA memcpy DtoH]
这3种方式的性能相比之前的方式都有所提高。
2.4.3 进一步分析和优化
线程利用率问题:
在前边的例子中,使用大小为 128 的线程块:
- 当 offset = 64 时,只用了 1/2 的线程进行计算
- 当 offset = 32 时,只用了 1/4 的线程进行计算
- 当 offset = 1 时,只用了 1/128 的线程进行计算
- 归约过程一共用了 log2(128) = 7 步
- 归约过程中线程的平均利用率只有 (1/2 + 1/4 + ...) / 7 ≈ 1/7
优化策略:在归约之前累加多个数据
用一个寄存器变量 y,在循环体中对读取的全局内存数据进行累加:
real y = 0.0;
const int stride = blockDim.x * gridDim.x;
for (int n = bid * blockDim.x + tid; n < N; n += stride)
{
y += d_x[n];
}
s_y[tid] = y;
__syncthreads();
包装函数实现:
real reduce(const real *d_x)
{
const int ymem = sizeof(real) * GRID_SIZE;
const int smem = sizeof(real) * BLOCK_SIZE;
real h_y[1] = {0};
real *d_y;
CHECK(cudaMalloc(&d_y, ymem));
reduce_cp<<<GRID_SIZE, BLOCK_SIZE, smem>>>(d_x, d_y, N);
reduce_cp<<<1, 1024, sizeof(real) * 1024>>>(d_y, d_y, GRID_SIZE);
CHECK(cudaMemcpy(h_y, d_y, sizeof(real), cudaMemcpyDeviceToHost));
CHECK(cudaFree(d_y));
return h_y[0];
}
这里,将GRID_SIZE取为10240,将BLOCK_SIZE取为128。当数据量为 N = 100000000 时,在归约前每个线程将先累加几十个数据。
性能结果:
完整计算时间只需要0.85ms,GPU上核函数的计算时间极大缩减:
Type Time(%) Time Calls Avg Min Max Name
GPU activities:
62.32% 95.388ms 1 95.388ms 95.388ms 95.388ms [CUDA memcpy HtoD]
37.59% 57.529ms 200 287.64us 6.4000us 572.79us reduce_cp(float const *, float*, int)
0.09% 135.46us 100 1.3540us 1.3110us 2.5600us [CUDA memcpy DtoH]
进一步优化:使用静态全局内存
在上面的包装函数中,需要为数组 d_y 分配与释放设备内存。实际上,设备内存的分配与释放是比较耗时的。一种优化方案是使用静态全局内存代替动态全局内存,因为静态内存是编译期间就会分配好的。
__device__ real static_y[GRID_SIZE];
real reduce(const real *d_x)
{
real *d_y;
CHECK(cudaGetSymbolAddress((void**)&d_y, static_y));
const int smem = sizeof(real) * BLOCK_SIZE;
reduce_cp<<<GRID_SIZE, BLOCK_SIZE, smem>>>(d_x, d_y, N);
reduce_cp<<<1, 1024, sizeof(real) * 1024>>>(d_y, d_y, GRID_SIZE);
real h_y[1] = {0};
CHECK(cudaMemcpy(h_y, d_y, sizeof(real), cudaMemcpyDeviceToHost));
return h_y[0];
}
计算时间从 0.85ms 缩短到了 0.6 ms。
2.5 优化方法总结
2.5.1 各种方法的性能对比
| 计算方法 | 计算结果 | 计算时间(ms) | 单次加速比 | 累计加速比 |
|---|---|---|---|---|
| CPU | 33554432.0 | 600 | 1 | 1 |
| GPU (全局内存) | 123633392.0 | 7.2 | 83 | 83 |
| GPU (静态共享内存) | 123633392.0 | 7.2 | 1 | 83 |
| GPU (动态共享内存) | 123633392.0 | 7.2 | 1 | 83 |
| GPU (原子函数) | 123633392.0 | 2.2 | 3.3 | 274 |
| GPU (束同步函数) | 123633392.0 | 2.2 | 1 | 274 |
| GPU (洗牌函数) | 123633392.0 | 2.1 | 1.05 | 288 |
| GPU (协作组) | 123633392.0 | 2.1 | 1 | 288 |
| GPU (增大线程利用率) | 123000064.0 | 0.85 | 2.5 | 719 |
| GPU (静态全局内存) | 123000064.0 | 0.6 | 1.4 | 1006 |
2.5.2 关键优化技术总结
-
使用共享内存代替全局内存
- 共享内存位于片上,访问速度远快于全局内存
- 适用于需要频繁访问的数据
-
使用原子函数避免数据竞争
- 保证操作的原子性
- 减少主机与设备之间的数据传输
-
使用线程束级别的同步和洗牌函数
__syncwarp()比__syncthreads()更轻量- 洗牌函数自动处理同步问题
- 避免线程束分化
-
提高线程利用率
- 在归约前让每个线程累加多个数据
- 减少归约过程中的线程闲置
-
使用静态全局内存
- 避免运行时的内存分配和释放开销
- 编译期分配,效率更高
三、GPU内存优化的核心原则
3.1 内存访问模式优化
3.1.1 合并访问(Coalesced Access)
定义:
当一个线程束(warp)中的32个线程访问连续的内存地址时,这些访问可以被合并成少数几个内存事务,大大提高内存带宽利用率。
最佳实践:
- 确保线程 i 访问数组的第 i 个元素
- 避免跨步访问(strided access)
- 对齐内存访问(aligned access)
示例:
// 好的访问模式 - 合并访问
int tid = threadIdx.x + blockIdx.x * blockDim.x;
float value = array[tid];
// 差的访问模式 - 跨步访问
int tid = threadIdx.x + blockIdx.x * blockDim.x;
float value = array[tid * stride]; // stride > 1 会导致非合并访问
3.1.2 Bank冲突(Bank Conflict)
共享内存的Bank结构:
- 共享内存被划分为32个bank
- 连续的32位字被分配到连续的bank中
- 如果一个warp中的多个线程访问同一个bank的不同地址,会发生bank冲突
避免Bank冲突的方法:
- 确保同一warp中的线程访问不同的bank
- 使用padding技术
- 调整数据布局
示例:
// 会产生bank冲突
__shared__ float shared[32][32];
float value = shared[threadIdx.x][threadIdx.y];
// 避免bank冲突 - 添加padding
__shared__ float shared[32][33]; // 多一列
float value = shared[threadIdx.x][threadIdx.y];
3.2 内存层次使用策略
3.2.1 寄存器优先原则
- 尽可能使用寄存器存储频繁访问的变量
- 寄存器访问速度最快,但数量有限
- 避免寄存器溢出到局部内存
3.2.2 共享内存作为缓存
- 将全局内存中的数据加载到共享内存
- 在共享内存中进行多次计算
- 最后将结果写回全局内存
典型模式:
__shared__ float tile[TILE_SIZE][TILE_SIZE];
// 1. 从全局内存加载到共享内存
tile[ty][tx] = global_data[gid];
__syncthreads();
// 2. 在共享内存中进行计算
float result = 0;
for (int i = 0; i < TILE_SIZE; i++) {
result += tile[ty][i] * tile[i][tx];
}
__syncthreads();
// 3. 写回全局内存
global_result[gid] = result;
3.2.3 常量内存的使用场景
- 所有线程读取相同的数据
- 数据量不超过64KB
- 只读数据
3.3 内存带宽优化
3.3.1 计算访存比(Compute-to-Memory Ratio)
定义:
计算访存比 = 算术操作数量 / 内存访问数量
优化策略:
- 提高计算访存比,减少内存访问
- 重用已加载的数据
- 使用融合操作(fused operations)
3.3.2 隐藏内存延迟
方法:
- 增加占用率(Occupancy):让更多的线程块同时运行
- 指令级并行(ILP):在一个线程中执行多个独立操作
- 异步内存操作:使用异步拷贝指令
3.4 数据布局优化
3.4.1 AoS vs SoA
AoS(Array of Structures):
struct Particle {
float x, y, z;
float vx, vy, vz;
};
Particle particles[N];
SoA(Structure of Arrays):
struct Particles {
float x[N], y[N], z[N];
float vx[N], vy[N], vz[N];
};
选择原则:
- 如果访问模式是访问单个粒子的所有属性,使用AoS
- 如果访问模式是访问所有粒子的单个属性,使用SoA(更适合GPU)
3.4.2 数据对齐
- 确保数据结构按照128字节边界对齐
- 使用
__align__修饰符 - 避免未对齐的内存访问
四、性能分析方法
4.1 使用nvprof进行性能分析
4.1.1 基本使用
# 基本性能分析
nvprof ./program
# 详细的内存分析
nvprof --print-gpu-trace ./program
# 分析特定的指标
nvprof --metrics gld_efficiency,gst_efficiency ./program
4.1.2 关键指标
内存相关指标:
gld_efficiency:全局内存加载效率gst_efficiency:全局内存存储效率shared_efficiency:共享内存效率l2_cache_hit_rate:L2缓存命中率
计算相关指标:
sm_efficiency:SM利用率achieved_occupancy:实际占用率ipc:每周期指令数
4.2 使用Nsight Compute
Nsight Compute 是NVIDIA的新一代性能分析工具,提供更详细的分析。
# 基本分析
ncu ./program
# 详细分析特定kernel
ncu --kernel-name myKernel --launch-skip 0 --launch-count 1 ./program
# 生成报告
ncu --export report ./program
4.3 使用Nsight Systems
用于系统级别的性能分析,可以看到CPU和GPU的时间线。
# 生成性能报告
nsys profile -o report ./program
# 在GUI中查看
nsys-ui report.qdrep
4.4 性能优化流程
-
识别瓶颈
- 使用profiler找出最耗时的kernel
- 分析是内存受限还是计算受限
-
针对性优化
- 内存受限:优化内存访问模式
- 计算受限:提高指令级并行度
-
验证优化效果
- 重新运行profiler
- 对比优化前后的指标
-
迭代优化
- 继续寻找新的瓶颈
- 重复优化过程
五、实践指导与最佳实践
5.1 内存优化检查清单
全局内存优化:
共享内存优化:
寄存器优化:
5.2 常见陷阱与解决方案
5.2.1 线程束分化
问题:
if (threadIdx.x < 16) {
// 分支A
} else {
// 分支B
}
解决方案:
- 重新组织算法避免分支
- 使用warp-level函数
- 确保分支在warp边界对齐
5.2.2 过度使用共享内存
问题:
使用过多共享内存会降低占用率。
解决方案:
- 平衡共享内存使用和占用率
- 考虑使用寄存器代替共享内存
- 使用动态共享内存按需分配
5.2.3 忽略内存对齐
问题:
未对齐的内存访问会导致性能下降。
解决方案:
// 确保数据结构对齐
struct __align__(16) MyStruct {
float4 data;
};
5.3 不同应用场景的优化策略
5.3.1 矩阵运算
- 使用tiling技术
- 优化共享内存使用
- 考虑使用Tensor Cores(支持的GPU)
5.3.2 规约操作
- 使用树形归约
- 利用warp-level原语
- 最小化全局内存访问
5.3.3 卷积操作
- 使用共享内存缓存输入数据
- 优化边界处理
- 考虑使用cuDNN库
六、总结与展望
6.1 核心要点回顾
-
GPU内存层次结构
- 寄存器:最快,容量最小,线程私有
- 共享内存:很快,容量有限,线程块共享
- 全局内存:最慢,容量最大,所有线程可访问
- L1/L2缓存:自动管理,不可编程
-
优化原则
- 最大化内存带宽利用率
- 最小化内存访问延迟
- 提高计算访存比
- 避免线程束分化
-
实践技巧
- 使用合并访问
- 避免bank冲突
- 合理使用共享内存
- 利用原子操作和warp-level函数
6.2 性能优化的一般流程
- Profile First:先分析,找出真正的瓶颈
- Optimize Bottleneck:针对瓶颈进行优化
- Measure Again:验证优化效果
- Iterate:持续迭代优化
6.3 未来发展趋势
-
更大的片上内存
- 共享内存容量持续增长
- L2缓存大幅增加
-
新的内存技术
- HBM3及更高带宽的内存
- 近存计算和存算一体
-
编程模型的演进
- 更高级的抽象
- 自动优化工具的改进
6.4 学习资源
官方文档:
- CUDA C Programming Guide
- CUDA Best Practices Guide
- Nsight Compute Documentation
推荐书籍:
- 《CUDA C编程权威指南》
- 《Programming Massively Parallel Processors》
在线资源:
- NVIDIA Developer Blog
- GitHub上的CUDA示例代码
- 相关技术论文
参考资料
[1] 《CUDA C编程权威指南》程润伟, Max Grossman, Ty McKercher 著,颜成钢, 殷建, 李亮 译,机械工业出版社,2017-6
[2] brucefan1983/CUDA-Programming: Sample codes for my CUDA programming book (github.com)
[3] https://developer.download.nvidia.cn/assets/cuda/files/reduction.pdf
[4] 深度了解 NVIDIA Ampere 架构
[5] 有了琦琦的棍子:深入浅出GPU优化系列:reduce优化
[6] NVIDIA Hopper Architecture In-Depth | NVIDIA Technical Blog
[7] NVIDIA Ada GPU Architecture Whitepaper
[8] GitHub - Liu-xiandong/How_to_optimize_in_GPU
[9] GitHub - BBuf/how-to-optim-algorithm-in-cuda
[10] ifromeast/cuda_learning: learning how CUDA works (github.com)
注: 本文档基于知乎文章"CUDA(二):GPU的内存体系及其优化指南"整理而成,包含了原文的核心内容,并补充了实践指导和最佳实践建议。