Kepler 计算设备
Kepler是NVIDIA发布的第三代CUDA计算设备的代号,这一系列产品主要有两大类:GK104和GK110,没有记错的话,GK104的运算能力是3.0,而GK110则具备了完整的Kepler架构组件,计算能力为3.5。两种计算能力的硬件在体系结构上面有一些差异,这些差异可能会在这一系列文章的后续版本中进行详细地讲解。今天我们讨论的是计算能力3.5的GK110架构新引入的一项特性:Read-Only Cache,以及讨论这一新的内存特性与CUDA原有的costant memory及global memory的差异。通过实际的例子,让我们一起来探索该如何使用这一新的特性。
Read-Only Data Cache
这一名词可以简单翻译为“只读数据缓存”,那么,它是什么呢?
Read-Only Data Cache是一种数据缓存技术,它允许用户使用GPU的纹理缓存流水线来读取Global Memory中的只读数据。前面的句子有点长,下面让我来详细地说明一下。首先,什么是GPU的纹理缓存流水线呢?其实细节我也不是很清楚,但是这并非核心,读者和我所需要了解的是,GPU在访问存储器时,对于不同的内存类型(global memory、constant memory、shared memory等等),它会使用不同的流水线来进行load/store操作,而缓存流水线就是这些流水线中的其中一种。因此,使用纹理缓存流水也就意味着它与正常的读取global memory所使用的是不同的流水线,两者在理论上应该是可以并行的。其次,什么是Global Memory中的只读数据呢?这其实是笔者臆想出来的一个表达,实际情况是这样的,如果用户有部分存放在Global Memory上的数据在CUDA kernel函数运行的时候保持不变,这部分数据就可以称作Global Memory中的只读数据。
那么,这个“只读数据缓存”有什么作用呢?
我们知道,在访问Global Memory时,为了获得更好的性能,我们必须确保程序对Global Memory的访问是Coalesced的,什么是Coalesced?简单来说就是一个线程块在访问Global Memory时所需要遵循的内存对齐要求(这种对齐要求的详细讲解可能会在以后的系列文章中涉及)。如果不能保证这一对齐策略,那么程序的访存性能将会受到影响,具体有多少跟硬件与访问的特定上下文有关,这里就不展开了。这时候,假如我们需要访问的内存在运行期间是不变的,那么,我们便可以使用Read-Only Data Cache!你问我为什么?因为纹理内存的访问特性就是无序访问,而Read-Only Data Cache正是为了这一访存方式所设计的!因此,在某些特定场合,理论上Read-Only Data Cache将会带来意想不到的大幅度性能提升。
做一个总结吧,其实从Read-Only Data Cache使用纹理缓存流水就可以看出,NVIDIA的实际目的就是为了简化程序员对于纹理内存(Texture Memory)的使用,而实际上确实也是这样的,在3.5的硬件之前,要想使用访问纹理的方法访问Global Memory,需要做一些Texture Bind函数的调用工作,而这些工作不但非常繁琐,而且还具有使用上的限制(具体就不细讲了),有了这一新特性,纹理内存的使用被极大地简化了,是一个值得引起CUDA程序员重视的技术备选项。
Constant Memory
我想,作为一名CUDA程序员,Constant Memory的意义在这里无需赘述了,我下面想说的,是Constant Memory适宜的应用场合,而这个场合问题可能一直困扰着相当多的程序员(为什么我使用Constant Memory时性能总是比直接访问Global Memory慢?)。
首先,Constant Memory的特性:
1. Constant Memory是物理上存在的,它占据设备内存的一个64KB的空间。
2. 对于Constant Memory的访问是通过一个流水多处理器上的8KB的缓存来完成的。
3. Constant的设计初衷是用来向同一warp中的线程进行广播的。
下面,我们来详细讲述三种特性分别是什么以及它们对编程策略的影响:
第一项很直观,我们略过。
对于第二项,我想说明的是,流水多处理器对于Constant Memory的访问并不是直接的,而是通过了一层缓存(Cache)来进行,这一缓存的细节目前笔者也不是很了解,只知道它的大小是8KB。另外,对于一个CUDA程序,它最多只能使用64KB的Constant Memory,多了会出错(可能程序将无法运行?我没有测试过,因为我从来没有用到过那么多Constant Memory)。
第三项非常重要!“广播”意味着同一warp中的所有线程都能在一个时钟周期内收到某一特定地址的Constant Memory值。然后呢?请注意,“广播”同时也意味着如果在同一时刻同一warp中的线程访问的是Constant Memory中的不同位置的话,那么由于一个时钟周期只能广播一个值,所以这样的访存模型将会消耗多个时钟周期!明白了没?如果你对某个Constant Memory的访问是这样的:xx = yy[threadIdx.x],其中yy代表Constant Memory,那么它的性能可能和你所预想的性能差别很大!
Global Memory
Global Memory暂时就不多讲了,列出来的原因是对它的访问也作为了测试比较的对照之一。Global Memory访问需要注意的一点是要保证访问是Coalesced的。
三种内存访问的性能比较
1. Global Memory
首先来看一看测试中我们需要用到的数据的声明:
__device__ double gpu_tab_W[6][6*6] = { {0.0}, {0.0}, {0.0}, { 1.0/48.0, -6.0/48.0, 12.0/48.0, -8.0/48.0, 23.0/48.0, -30.0/48.0, -12.0/48.0, 24.0/48.0, 23.0/48.0, 30.0/48.0, -12.0/48.0, -24.0/48.0, 1.0/48.0, 6.0/48.0, 12.0/48.0, 8.0/48.0}, {0.0}, { 1.0/3840.0, -10.0/3840.0, 40.0/3840.0, -80.0/3840.0, 80.0/3840.0, -32.0/3840.0, 237.0/3840.0, -750.0/3840.0, 840.0/3840.0, -240.0/3840.0, -240.0/3840.0, 160.0/3840.0, 1682.0/3840.0, -1540.0/3840.0, -880.0/3840.0, 1120.0/3840.0, 160.0/3840.0, -320.0/3840.0, 1682.0/3840.0, 1540.0/3840.0, -880.0/3840.0, -1120.0/3840.0, 160.0/3840.0, 320.0/3840.0, 237.0/3840.0, 750.0/3840.0, 840.0/3840.0, 240.0/3840.0, -240.0/3840.0, -160.0/3840.0, 1.0/3840.0, 10.0/3840.0, 40.0/3840.0, 80.0/3840.0, 80.0/3840.0, 32.0/3840.0} }; __constant__ double gpu_tab_W_c[6][6*6] = { {0.0}, {0.0}, {0.0}, { 1.0/48.0, -6.0/48.0, 12.0/48.0, -8.0/48.0, 23.0/48.0, -30.0/48.0, -12.0/48.0, 24.0/48.0, 23.0/48.0, 30.0/48.0, -12.0/48.0, -24.0/48.0, 1.0/48.0, 6.0/48.0, 12.0/48.0, 8.0/48.0}, {0.0}, { 1.0/3840.0, -10.0/3840.0, 40.0/3840.0, -80.0/3840.0, 80.0/3840.0, -32.0/3840.0, 237.0/3840.0, -750.0/3840.0, 840.0/3840.0, -240.0/3840.0, -240.0/3840.0, 160.0/3840.0, 1682.0/3840.0, -1540.0/3840.0, -880.0/3840.0, 1120.0/3840.0, 160.0/3840.0, -320.0/3840.0, 1682.0/3840.0, 1540.0/3840.0, -880.0/3840.0, -1120.0/3840.0, 160.0/3840.0, 320.0/3840.0, 237.0/3840.0, 750.0/3840.0, 840.0/3840.0, 240.0/3840.0, -240.0/3840.0, -160.0/3840.0, 1.0/3840.0, 10.0/3840.0, 40.0/3840.0, 80.0/3840.0, 80.0/3840.0, 32.0/3840.0} };哈哈,先不要感到头晕,其实上面的数据可以简单分为两类:
1、使用__device__关键字声明的存储在Global Memory中的数据。
2、使用__constant__关键字声明的存储在Constant Memory中的数据。
明白了这个,那么下面让我们看看如何操作它们。
首先来看程序,该程序是笔者从目前实际工程中的程序中抽离出来的一小段代码,我们来看看用哪种访存特性会更好:
__global__ void kel_global_P (int np, int idx, double *out) { __shared__ double buffer[GPU_THREADS][2][HARD_CODED_LDP]; int tid = threadIdx.x + blockIdx.x * blockDim.x; int tn = blockDim.x * gridDim.x; double *p1 = buffer[threadIdx.x][0]; double *q1 = buffer[threadIdx.x][1]; double sum = 0.0; double *tab = gpu_tab_W[5]; for (int i = tid; i < np; i += tn) { p1[0] = 1.0; for (int x = 1; x < idx; ++x) p1[x] = sin((double)x * i * p1[x-1]); for (int x = 0; x < idx; ++x) { q1[x] = 0.0; for (int y = 0; y < idx; ++y) q1[x] += tab[i % 36] * p1[y]; } for (int x = 0; x < idx; ++x) sum += q1[x]; out[i] = sum; } }下面我们来简单讲解一下,在这段代码中,核心的代码是对于tab的访问,程序第11行将tab赋值为gpu_tab_W[5],然后在程序的第21行访问它,这时各个线程将会对Global Memory进行访存操作。其它部分的代码读者可以不用管,因为它们对性能对比没有影响。
同样的功能让我们来看看用Constant Memory该如何访问呢:
__global__ void kel_constant_P (int np, int idx, double *out) { __shared__ double buffer[GPU_THREADS][2][HARD_CODED_LDP]; int tid = threadIdx.x + blockIdx.x * blockDim.x; int tn = blockDim.x * gridDim.x; double *p1 = buffer[threadIdx.x][0]; double *q1 = buffer[threadIdx.x][1]; double sum = 0.0; for (int i = tid; i < np; i += tn) { p1[0] = 1.0; for (int x = 1; x < idx; ++x) p1[x] = sin((double)x * i * p1[x-1]); for (int x = 0; x < idx; ++x) { q1[x] = 0.0; for (int y = 0; y < idx; ++y) q1[x] += gpu_tab_W_c[5][i % 36] * p1[y]; } for (int x = 0; x < idx; ++x) sum += q1[x]; out[i] = sum; } }请注意,这一次,在程序的第20行,我们使用gpu_tab_W_c[5][i % 36]来直接访问Constant Memory。可以看出,对于同一warp中的不同线程,它们在同一时刻访问的并非同一Constant Memory地址,因此可以预见,理论上这种访存方式将会带来性能损失。
3. Read-Only Data Cache
想要使用Read-Only Data Cache需要符合某些条件,它必要的(但不是充分的)条件是在内存地址前加上__restrict__限定符,但是这样也不能完全保证程序执行时会真正使用Read-Only Data Cache,原因是编译器可能由于程序过于复杂而无法判断该引用的内存是否在运行期间是只读的。要保证使用Read-Only Data Cache,我们可以选择使用CUDA提供的__ldg()内建函数,具体的使用方法请看代码:
__global__ void kel_read_only_P (const double* __restrict__ tab, int np, int idx, double *out) { __shared__ double buffer[GPU_THREADS][2][HARD_CODED_LDP]; int tid = threadIdx.x + blockIdx.x * blockDim.x; int tn = blockDim.x * gridDim.x; double *p1 = buffer[threadIdx.x][0]; double *q1 = buffer[threadIdx.x][1]; double sum = 0.0; for (int i = tid; i < np; i += tn) { p1[0] = 1.0; for (int x = 1; x < idx; ++x) p1[x] = sin((double)x * i * p1[x-1]); for (int x = 0; x < idx; ++x) { q1[x] = 0.0; for (int y = 0; y < idx; ++y) q1[x] += __ldg(&tab[i % 36]) * p1[y]; } for (int x = 0; x < idx; ++x) sum += q1[x]; out[i] = sum; } }从程序的20行可以看到,我们使用__ldg()来强制程序在此时使用Read-Only Data Cache,而在程序的第2行,细心的读者可以发现,在double*后面多了一个__restrict__限定符。没错,这两处就是想要使用Read-Only Data Cache在Kernel函数中所需要的所有改变。
有读者可能会问,你这个tab是从哪里得到的呢?是不是直接将gpu_tab_W加上一个偏移值传入的呢?很遗憾,不是,要想把使用__device__限定符声明的数组作为指针传入,还有一些额外的事情需要读者去做:你需要使用cudaGetSymbolAddress()函数来获取数组在设备端的地址。
好了,现在让我们来看看三个函数的运行结果,我们用nvprof来测试它们的运行时间:
如我们所料,Read-Only Data Cache在这种无序访问的访存模型中性能更好,而Constant Memory在此时性能明显较弱。但是,等等,为什么Global Memory的运行时间反而更少?这笔者只能说真的不知道!或许是数据量太少?或许是访存正好符合对齐规则?我暂时没有详细研究,不过聪明的读者是否可以深入分析一下呢?
到这个时候,如果你还能跟上我的思路,那么,我们将进入最后的一项测试:Constant Memory广播测试。如何测?其实很简单,你只需要将以上代码中所有的:
[i % 36]修改为:
[x * idx + y]这本来是需要一点篇幅来解释的,但是呢笔者还是相信聪明的读者在这一点上应该可以一眼看出来原因,由于笔者实在是饿了,赶着吃饭,因此就不赘述啦~哈哈。下面给出修改后的代码的nvprof运行结果:
可以看到,这一次Constant Memory反败为胜了~什么?为什么Global Memory又赢了?笔者也不知道啊!只能感叹太神奇了!
最后,列一下本次实验所使用的环境吧:
GPU:NVIDIA Tesla K20m
CUDA version: 5.5
欢迎读者来信讨论!