NVIDIA CUDA 高度并行处理器编程(三):CUDA存储器
前两节中写了如何编写调用大量线程来计算矩阵加法和乘法的 kernel 函数。但这些线程要处理的数据是先从全局储存器中访问,而全局储存器是通过 DRAM 来实现,访问速度慢,而且它的路径上容易发生拥塞现象,只允许很少线程继续访问,因此导致一些 SM 处于空闲状态。下面主要解决存储器的访问速度慢的问题。
1. 存储区访问效率的重要性
矩阵乘法中每个线程就是计算一个结果矩阵中的值:
for(int k = 0;k < width;++k)
PValue += d_M[Row * width + k] * d_N[k * Width + Col];
该循环每次循环都访问两边存储器,d_M、d_N 各一次,并执行两次浮点运算,乘法一次,加法一次。浮点运算与全局存储器访问操作的比值(compute to Global Memory Access,CFMA)就等于1.0。
当今许多设备的全局存储器访问宽带达到了 200GB/S,每个单精度浮点数占 4 个字节,那么单精度数据的加载速度不高于 50GFLOPS。 如果 CFMA 为 1.0 那么 kernel 函数中每秒可执行浮点数运算的不高于 50GFLOPS,这对于许多高端 GPU 能达到的峰值性能 1500GFLOPS 是挺少的了。所以我们要提高 CFMA 来提高 GPU 的利用率。
2. CUDA 设备的存储器的类型
下图底部,有全局存储器和常数存储器,主机代码可以对两者进行读写。主机可以访问设备全局存储器,与设备之间传输和复制数据。当所有线程同时访问相同位置时,常数存储器为设备提供短延时、高宽带和只读访问。
寄存器和共享存储器是片上存储器。这两种存储器中的变量可以以高度并行的方式访问。寄存器分配给单个线程,每个线程只能访问分配给自己的寄存器。共享存储器分配给线程块,同一个块中的所有线程都可以访问共享存储器中的变量。
下图是现代冯诺依曼模型机中的主存和寄存器,访问主存比访问寄存器中的数据需要多出访存的指令,而且主存利用 DRAM 技术实现,延迟高,所以访问主存中的数据比访问寄存器中的慢。
CUDA 模型中的全局存储器相当于冯诺依曼模型中的主存,寄存器相当于冯诺依曼模型中的寄存器堆。
处理单元和线程: 现代计算机中的一个线程相当于冯诺依曼模型中的一个处理器。现代处理器提供了上下文切换功能,多个线程分时共享处理器,可以暂停一个线程,执行其他线程,然后再重新启动这个线程。一些处理器提供了多个处理单元,允许多个线程同时执行,下图展示了 SIMD 的设计模式,所有的处理单元共享一个 PC 和 IR 寄存器,所有线程执行同一条指令。
共享存储器与寄存器虽然都是片上存储器,但共享存储器是芯片上存储空间的一部分。处理器访问局部存储器还是要执行内存加载操作,与访问全局存储器一样。然而共享存储器在片内访问共享存储器比访问全局存储器有更低的延迟和更高的宽带,由于需要内存加载操作,所以比寄存器有更高的延迟和更低的宽带。
在 CUDA 中共享存储器和寄存器之间的一个重要区别是共享存储器中的变量可以被线程块中的所有线程共享,而寄存器对线程是私有的。
变量声明:
变量声明 | 存储器 | 作用域 | 生命周期 |
---|---|---|---|
除数组以外的自动变量 | 寄存器 | 线程 | kernel 函数 |
自动数组变量 | 局部存储器 | 线程 | kernel 函数 |
__device__ __local__ int LocalVar; | 局部存储器 | 线程 | kernel 函数 |
__device__ __shared__ int SharedVar; | 共享存储器 | 块 | kernel 函数 |
__device__ int GlobalVar; | 全局存储器 | 网络 | 应用程序 |
__device__ __constant__ int ConstVar; | 常数存储器 | 网格 | 应用程序 |
local memory 的数据是被保存在显存中的,速度很慢。无法确定大小的数组和较大的结构体都会放在局部存储器中。
使用__local__ ,__shared__ ,__constant__ 时,__device__可选。
变量声明 | 声明位置 |
---|---|
__shared__ | kernel 函数或设备函数 |
__constant__ | 任何函数体外 |
CUDA 指针: 用来指向全局存储器中的数据对象。在 kernel 函数和设备函数中有两种使用指针的方法。
- 如果一个对象是主机端函数分配的,则指向对象的指针由 cudaMalloc() 函数初始化,并且可以作为参数传递给 kernel 函数。
//in host function
float *A;
cudaMelloc((void **)&A, size);
- 把全局存储器中变量的地址赋予指针变量,例如:
//in kernel function
float *ptr = &GlobalVar;
3. 用分块减少内存流量
我们可以先将要计算的元素分批从全局存储器中加载到每个块内的共享存储器,以此来减少访问全局存储器所带来的内存消耗。下面以两个 4 x 4 方阵、TILE_WIDTH 大小为 16 为例:
block[0][0] 在
- 阶段 1 会将用 4 个线程将左侧矩阵左上角 2 x 2 的 4 个元素与上方矩阵左上角 2 x 2 的 4 个元素加载到共享存储器的方阵中,然后 4 个线程同时计算对应共享存储器中小向量的内积并累加到 PValue 中,这里的 PValue 是自动类型,每个线程中的寄存器都会保存一个副本。
- 阶段 2 将将用 4 个线程将左侧矩阵右上角 2 x 2 的 4 个元素与上方矩阵左下角 2 x 2 的 4 个元素加载到共享存储器的方阵中,然后 4 个线程同时计算对应共享存储器中小向量的内积并累加到 PValue 中。此时针对 block[0][0] 的计算结束。
在计算 block[0][0] 同时,block[0][1],block[1][0],block[1][1]也在进行同样的运算,当所有 block 的阶段 2 结束后,矩阵乘法完成。
矩阵乘法的 kernel 函数:
#define TILE_WIDTH 16
__global__ void MatrixMulKernel(float *d_M, float *d_N, float *d_P, int m, int k, int n){
__shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
__shared__ float Nds[TILE_WIDTH][TILE_WIDTH];
int bx = blockIdx.x;
int by = blockIdx.y