转置只能在行读取列写入或者列读取行写入之间选择一个,这样就必然会引发非合并的访问;
可以利用一级缓存的性质可以提高性能;
本节利用共享内存,在共享内存中完成转置后写入全局内存,这样就可以避免交叉访问了。
下界
作为基准,下面的核函数是一个仅使用全局内存的矩阵转置的朴素实现。
按行读取,按列写入
__global__ void naiveGmem(float * in,float * out,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx_row=ix+iy*nx;
int idx_col=ix*ny+iy;
if (ix<nx && iy<ny)
{
out[idx_col]=in[idx_row];
}
}
全局内存读操作在线程束内是被合并的,而全局内存写操作在相邻线程间是交叉访问的。
上界
即,拷贝操作:按行读取,按行写入
__global__ void copyRow(float * in,float * out,int nx,int ny)
{
int ix=threadIdx.x+blockDim.x*blockIdx.x;
int iy=threadIdx.y+blockDim.y*blockIdx.y;
int idx=ix+iy*nx;
if (ix<nx && iy<ny)
{
out[idx]=in[idx];
}
}
为了避免交叉访问,我们可以使用二维共享内存缓存原始矩阵数据,然后从共享内存中读取一列存储到全局内存中,因为共享内存按列读取不会导致交叉访问那么严重的延迟,所以这种想法是可以提高效率的,但是前面一篇我们说这种案列访问共享内存会造成冲突,所以我们先来按照最简单的方式来使用共享内存:
__global__ void transformSmem(float * in,float* out,int nx,int ny)
{
__shared__ float tile[BDIMY][BDIMX];
unsigned int ix,iy,transform_in_idx,transform_out_idx;
// 1
ix=threadIdx.x+blockDim.x*blockIdx.x;
iy=threadIdx.y+blockDim.y*blockIdx.y;
transform_in_idx=iy*nx+ix;
// 2
unsigned int bidx,irow,icol;
bidx=threadIdx.y*blockDim.x+threadIdx.x;
irow=bidx/blockDim.y;
icol=bidx%blockDim.y;
// 3
ix=blockIdx.y*blockDim.y+icol;
iy=blockIdx.x*blockDim.x+irow;
// 4
transform_out_idx=iy*ny+ix;
if(ix<nx&& iy<ny)
{
tile[threadIdx.y][threadIdx.x]=in[transform_in_idx];
__syncthreads();
out[transform_out_idx]=tile[icol][irow];
}
}
计算原始矩阵中某块中的某数据的全局内存的一维索引transform_in_idx
ix=threadIdx.x+blockDim.x*blockIdx.x;
iy=threadIdx.y+blockDim.y*blockIdx.y;
transform_in_idx=iy*nx+ix;
通过其全局内存一维索引 按行读取并写入 共享内存中
tile[threadIdx.y][threadIdx.x]=in[transform_in_idx];
计算共享内存中,该数据的一维索引bidx
:
unsigned int bidx,irow,icol;
bidx=threadIdx.y*blockDim.x+threadIdx.x;
通过共享内存中一维索引,计算共享内存转置后,的局部二维坐标:
irow=bidx/blockDim.y;
icol=bidx%blockDim.y;
根据 转置后的局部二维坐标以及整体转置后的新长度宽度,计算在转置矩阵中的全局一维索引transform_out_idx
:
ix=blockIdx.y*blockDim.y+icol;
iy=blockIdx.x*blockDim.x+irow;
transform_out_idx=iy*ny+ix;
根据转置矩阵中全局一维索引 和 共享内存转置后的局部二维坐标,对转置矩阵进行按行写入
__syncthreads();
out[transform_out_idx]=tile[icol][irow];
共享内存有16路冲突,store没冲突,主要来自load,解决这个问题,我们使用的方法只有填充
通过给二维共享内存数组tile中的每一行添加列填充,可以将原矩阵相同列中的数据元素均匀地划分到共享内存存储体中。
需要填充的列数取决于设备的计算能力和线程块的大小。
__shared__ float tile[BDIMY][BDIMX+IPAD];
在共享内存数组中添加列填充消除了所有的存储体冲突,再次提升。
下面的核函数展开两个数据块的同时处理:每个线程现在转置了被一个数据块跨越的两个数据元素。
这种转化的目标是通过创造更多的同时加载和存储以提高设备内存带宽利用率。
__global__ void transformSmemUnrollPad(float * in,float* out,int nx,int ny)
{
__shared__ float tile[BDIMY*(BDIMX*2+IPAD)];
//1.
unsigned int ix,iy,transform_in_idx,transform_out_idx;
ix=threadIdx.x+blockDim.x*blockIdx.x*2;
iy=threadIdx.y+blockDim.y*blockIdx.y;
transform_in_idx=iy*nx+ix;
//2.
unsigned int bidx,irow,icol;
bidx=threadIdx.y*blockDim.x+threadIdx.x;
irow=bidx/blockDim.y;
icol=bidx%blockDim.y;
//3.
unsigned int ix2=blockIdx.y*blockDim.y+icol;
unsigned int iy2=blockIdx.x*blockDim.x*2+irow;
//4.
transform_out_idx=iy2*ny+ix2;
if(ix+blockDim.x<nx&& iy<ny)
{
unsigned int row_idx=threadIdx.y*(blockDim.x*2+IPAD)+threadIdx.x;
tile[row_idx]=in[transform_in_idx];
tile[row_idx+BDIMX]=in[transform_in_idx+BDIMX];
//5
__syncthreads();
unsigned int col_idx=icol*(blockDim.x*2+IPAD)+irow;
out[transform_out_idx]=tile[col_idx];
out[transform_out_idx+ny*BDIMX]=tile[col_idx+BDIMX];
}
}
由于共享内存数组tile是一维的,所以必须将二维线程索引转换为一维共享内存索引,以访问填充的一维共享内存。
通过展开的两块,更多的内存请求将同时处于运行状态并且读/写的吞吐量会提高。
调整线程块的维度,以找出最佳的执行配置
常量内存是专用内存,他用于只读数据和线程束统一访问某一个数据;
常量内存对内核代码而言是只读的,但是主机是可以修改(写)只读内存的,当然也可以读。
常量内存在DRAM上(和全局内存一样),而其有在片上对应的缓存,其片上缓存就和一级缓存和共享内存一样, 有较低的延迟,但是容量比较小,合理使用可以提高内和效率,每个SM常量缓存大小限制为64KB。
所有的片上内存,我们是不能通过主机赋值的,我们只能对DRAM上内存进行赋值。
全局内存按照连续对去访问最优,交叉访问最差;
共享内存 无冲突最优,都冲突就会最差;
常量内存的最优访问模式是线程束所有线程访问一个位置,那么这个访问是最优的。如果要访问不同的位置,就要编程串行了。
一个常量内存读取成本 与 线程束中线程 读取 唯一地址的数量 呈线性关系。
常量内存的声明方式:
__constant__
常量内存变量的生存周期与应用程序生存周期相同,其对网格内的所有线程都是可访问的,并且通过运行时函数对主机可访问。当CUDA独立编译被使用的,常量内存跨文件可见。
初始化常量内存使用:
cudaError_t cudaMemcpyToSymbol(const void *symbol, const void * src, size_t count, size_t offset, cudaMemcpyKind kind)
和我们之前使用的copy到全局内存的函数类似,参数也类似,包含传输到设备,以及从设备读取,kind的默认参数是传输到设备cudaMemcpyHostToDevice
。
在数值分析中经常使用模板操作,就是卷积,一维的卷积就是我们今天要写的例子,所谓模板操作就是把一组数据中中间连续的若干个产生一个输出:
在上述模板公式的例子下,系数c0、c1、c2和c3在所有线程中都是相同的并且不会被修改。这使它们成为常量内存最优的候选,因为它们是只读的,并将呈现一个广播式的访问模式:线程束中的每个线程同时引用相同的常量内存地址。
由于每个线程需要9个点来计算一个点,所以要使用共享内存来缓存数据,从而减少对全局内存的冗余访问。
#define BDIM 32 // block size
__constant__ float coef[RADIUS + 1];
void setup_coef_constant (void)
{
const float h_coef[] = {a0, a1, a2, a3, a4};
CHECK(cudaMemcpyToSymbol( coef, h_coef, (RADIUS + 1) * sizeof(float)));
}
__global__ void stencil_1d(float *in, float *out, int N)
{
// shared memory
__shared__ float smem[BDIM + 2 * RADIUS];
// index to global memory
int idx = blockIdx.x * blockDim.x + threadIdx.x;
while (idx < N)
{
// index to shared memory for stencil calculatioin
int sidx = threadIdx.x + RADIUS;
// Read data from global memory into shared memory
smem[sidx] = in[idx];
// read halo part to shared memory
if (threadIdx.x < RADIUS)
{
smem[sidx - RADIUS] = in[idx - RADIUS];
smem[sidx + BDIM] = in[idx + BDIM];
}
// Synchronize (ensure all the data is available)
__syncthreads();
// Apply the stencil
float tmp = 0.0f;
#pragma unroll
for (int i = 1; i <= RADIUS; i++)
{
tmp += coef[i] * (smem[sidx + i] - smem[sidx - i]);
}
// Store the result
out[idx] = tmp;
idx += gridDim.x * blockDim.x;
}
}
以上是常量内存和常量缓存的操作,我们作为对比,展示下只读缓存对应的操作.
只读缓存拥有从全局内存读取数据的专用带宽,所以,如果内核函数是带宽限制型的,那么这个帮助是非常大的,不同的设备有不同的只读缓存大小,Kepler SM有48KB的只读缓存,只读缓存对于分散访问的更好,
当所有线程读取同一地址的时候常量缓存最好,只读缓存这时候效果并不好,只读换粗粒度为32.
实现只读缓存可以使用两种方法
使用__ldg
函数
内部函数__ldg用于代替标准指针解引用,并且强制加载通过只读数据缓存
__global__ void kernel(float* output, float* input) {
...
output[idx] += __ldg(&input[idx]);
...
}
全局内存的限定指针
限定指针为const__restrict__
,以表明它们应该通过只读缓存被访问
void kernel(float* output, const float* __restrict__ input) {
...
output[idx] += input[idx];
}
使用只读缓存需要更多的声明和控制,在代码非常复杂的情况下以至于编译器都没办法保证制度缓存使用是否安全的情况下,建议使用 __ldg()
函数,更容易控制。
只读缓存独立存在,区别于常量缓存,常量缓存喜欢小数据,而只读内存加载的数据比较大,可以在非统一模式下访问。
我们修改上面的代码,得到只读缓存版本:
__global__ void stencil_1d_readonly(float * in,float * out,const float* __restrict__ dcoef)
{
__shared__ float smem[BDIM+2*TEMP_RADIO_SIZE];
int idx=threadIdx.x+blockDim.x*blockIdx.x;
int sidx=threadIdx.x+TEMP_RADIO_SIZE;
smem[sidx]=in[idx];
if (threadIdx.x<TEMP_RADIO_SIZE)
{
if(idx>TEMP_RADIO_SIZE)
smem[sidx-TEMP_RADIO_SIZE]=in[idx-TEMP_RADIO_SIZE];
if(idx<gridDim.x*blockDim.x-BDIM)
smem[sidx+BDIM]=in[idx+BDIM];
}
__syncthreads();
if (idx<TEMP_RADIO_SIZE||idx>=gridDim.x*blockDim.x-TEMP_RADIO_SIZE)
return;
float temp=.0f;
#pragma unroll
for(int i=1;i<=TEMP_RADIO_SIZE;i++)
{
temp+=dcoef[i-1]*(smem[sidx+i]-smem[sidx-i]);
}
out[idx]=temp;
//printf("%d:GPU :%lf,\n",idx,temp);
}
唯一的不同就是多了一个参数,这个参数在主机内是定义的全局内存
因为该系数最初是存储在全局内存中并且读入缓存中的,调用内核之前必须分配和初 始化全局内存以便在设备上存储系数
// allocate device memory
float *d_coef;
CHECK(cudaMalloc((float**)&d_coef, (RADIUS + 1) * sizeof(float)));
// set up coefficient to global memory
const float h_coef[] = {a0, a1, a2, a3, a4};
CHECK(cudaMemcpy(d_coef, h_coef, (RADIUS + 1) * sizeof(float),
cudaMemcpyHostToDevice);)
常量缓存与只读缓存:
支持线程束洗牌指令的设备最低也要3.0以上。
洗牌指令,shuffle instruction作用在线程束内,允许两个在同一线程束的线程相互访问对方的寄存器。
这就给线程束内的线程相互交换信息提供了了一种新的渠道,我们知道,核函数内部的变量都在寄存器中,一个线程束可以看做是32个内核并行执行,换句话说这32个核函数中寄存器变量在硬件上其实都是邻居,这样就为相互访问提供了物理基础。
线程束内线程相互访问数据不通过共享内存或者全局内存,使得通信效率高很多,线程束洗牌指令传递数据,延迟极低,且不消耗内存。线程束洗牌指令是线程束内线程通讯的极佳方式。
我们先提出一个叫做束内线程的概念,英文名lane,简单的说,就是一个线程束内的索引,所以束内线程的ID在 [0,31]内,且唯一,唯一是指线程束内唯一,一个线程块可能有很多个束内线程的索引,就像一个网格中有很多相同的threadIdx.x 一样,同时还有一个线程束的ID.
在一维线程块中,对于一个给定线程的束内线程索引和线程束索引可以按以下公式进行计算
unsigned int LaneID=threadIdx.x % 32;
unsigned int warpID=threadIdx.x / 32;
根据上面的计算公式,一个线程块内的threadIdx.x=1,33,65等对应的laneID都是1,但它们有不同的线程束ID。对于二维线程块,可以将二维线程坐标转换为一维线程索引,并应用前面的公式来确定束内线程和线程束的索引。
线程束洗牌指令有两组:一组用于整形变量,另一种用于浮点型变量。
每组一共有四种形式的洗牌指令。
int __shfl(int var,int srcLane,int width=warpSize);
这个函数能使线程束中的每个线程都可以直接从一个特定的线程中获取某个值。
var变量是一个线程束内线程都有的变量名,不过值不一定相同;var就是要接收广播的变量名。
接收哪个线程呢?谁接收呢?width的默认参数是32,可被设置为2~32之间2任何的指数。每width个连续线程的var值接收一个要广播的线程的var值,要广播的线程为(threadIdx.x / width) + srcLane
比如 __shfl(var,3 , 16);
我想得到3号线程内存的var值,而且width=16,那么就是,0 ~ 15的束内线程接收0+3位置处的var值,也就是3号束内线程的var值,16~32的束内线程接收16+3=19位置处的var变量。
其实就是右移
int __shfl_up(int var,unsigned int delta,int with=warpSize);
这个函数的作用是调用线程得到当前束内线程编号减去delta的编号的线程内的var值,with和__shfl中都一样,默认是32,作用效果如下:
如果是width其他值,我们可以根据前面的讲解,把线程束再分成若干个大小为with的块,进行上图的操作。
最左边两个元素没有前面的delta号线程,所以不做任何操作,保持原值。
左移
int __shfl_down(int var,unsigned int delta,int with=warpSize);
int __shfl_xor(int var,int laneMask,int with=warpSize);
xor是异或操作:只要两个不同就会得到真,否则为假
如果我们输入的laneMask是1,其对应的二进制是 000⋯001
当前线程的索引是0 ~ 31之间的一个数,那么我们用laneMask与当前线程索引进行抑或操作得到的就是目标线程的编号了,这里laneMask是1,那么我们把1与0~31分别抑或就会得到:
000001^000000=000001;
000001^000001=000000;
000001^000010=000011;
000001^000011=000010;
000001^000100=000101;
000001^000101=000100;
.
.
.
000001^011110=011111;
000001^011111=011110;
这就是当前线程的束内线程编号和目标线程束内县城编号之间的对应关系:
这就是4个线程束洗牌指令对整形的操作了。对应的浮点型不需要该函数名,而是只要把var改成float就行了,函数就会自动重载了。
下来我们用代码实现以下,看看每一个指令的作用效果,洗牌指令可以用于下面三种整数变量类型中:
标量变量
数组
向量型变量
这个就是 __shfl函数作用结果了,代码如下
__global__ void test_shfl_broadcast(int *in,int*out,int const srcLans)
{
int value=in[threadIdx.x];
value=__shfl(value,srcLans,BDIM);
out[threadIdx.x]=value;
}
这里面的过程就不用说了,注意var参数对应value就是我们要找的目标,srcLane这里是2,所以,我们取得了2号束内线程的value值给了当前线程,于是所有束内线程的value都是2了:
计算结果:
这里使用__shfl_up指令进行上移。代码如下
__global__ void test_shfl_up(int *in,int*out,int const delta)
{
int value=in[threadIdx.x];
value=__shfl_up(value,delta,BDIM);
out[threadIdx.x]=value;
}
运行结果:
这里使用__shfl_down指令进行上移。代码如下
__global__ void test_shfl_down(int *in,int*out,int const delta)
{
int value=in[threadIdx.x];
value=__shfl_down(value,delta,BDIM);
out[threadIdx.x]=value;
}
运行结果:
每个线程的源束内线程是不同的,并由它自身的束内线程索引加上偏移量来确定。偏移量可为正数也可为负数。
然后是循环移动,我们修改__shfl中的参数,把静态的目标改成一个动态的目标,如下:
__global__ void test_shfl_wrap(int *in,int*out,int const offset)
{
int value=in[threadIdx.x];
value=__shfl(value,threadIdx.x+offset,BDIM);
out[threadIdx.x]=value;
}
当offset=2的时候,得到结果:
前14个元素的值是可以预料到的,但是14号,15号并没有像shfl_down那样保持不变,而是获得了0号和1号的值,那么我们有必要相信,shfl中计算目标线程编号的那步有取余操作,对with取余,我们真正得到的数据来自
srcLane=srcLane%width;
这样就说的过去了,同理我们通过将srclane设置成-2的话就能得到对应的向上的环绕移动。
接着我们看看__shfl_xor像我说的这个操作非常之灵活,可以组合出任何你想要的到的变换,我们先来个简单的就是我们上面讲原理的时候得到的结论:
__global__ void test_shfl_xor(int *in,int*out,int const mask)
{
int value=in[threadIdx.x];
value=__shfl_xor(value,mask,BDIM);
out[threadIdx.x]=value;
}
mask我们设置成1,然后就能得到下面的结果:
我们要交换数组了,假如线程内有数组,然后我们交换数组的位置,我们可以用下面代码实现一个简单小数组的例子:
__global__ void test_shfl_xor_array(int *in,int*out,int const mask)
{
//1.
int idx=threadIdx.x*SEGM;
//2.
int value[SEGM];
for(int i=0;i<SEGM;i++)
value[i]=in[idx+i];
//3.
value[0]=__shfl_xor(value[0],mask,BDIM);
value[1]=__shfl_xor(value[1],mask,BDIM);
value[2]=__shfl_xor(value[2],mask,BDIM);
value[3]=__shfl_xor(value[3],mask,BDIM);
//4.
for(int i=0;i<SEGM;i++)
out[idx+i]=value[i];
}
有逻辑的地方代码就会变得复杂,我们从头看,首先我们定义了一个宏SEGM为4,然后每个线程束包含一个SEGM大小的数组,当然,这些数据数存在寄存器中的,如果数组过大可能会溢出到本地内存中,不用担心,也在片上,这个数组比较小,寄存器足够了。
我们看每一步都做了什么
接下来这个是个扩展了,交换了两个之间的一对值,并且这里是我们第一次写设备函数,也就是只能被核函数调用的函数:
__inline__ __device__
void swap(int *value,int laneIdx,int mask,int firstIdx,int secondIdx)
{
bool pred=((laneIdx%(2))==0);
if(pred)
{
int tmp=value[firstIdx];
value[firstIdx]=value[secondIdx];
value[secondIdx]=tmp;
}
value[secondIdx]=__shfl_xor(value[secondIdx],mask,BDIM);
if(pred)
{
int tmp=value[firstIdx];
value[firstIdx]=value[secondIdx];
value[secondIdx]=tmp;
}
}
__global__ void test_shfl_swap(int *in,int* out,int const mask,int firstIdx,int secondIdx)
{
//1.
int idx=threadIdx.x*SEGM;
int value[SEGM];
for(int i=0;i<SEGM;i++)
value[i]=in[idx+i];
//2.
swap(value,threadIdx.x,mask,firstIdx,secondIdx);
//3.
for(int i=0;i<SEGM;i++)
out[idx+i]=value[i];
}
在前面的5.3.1节中,已经介绍了如何使用共享内存来优化并行归约算法。
在本节中, 将介绍如何使用线程束洗牌指令来解决同样的问题。
基本思路非常简单,它包括3个层面的归约:
一个线程块中可能有几个线程束。对于线程束级归约来说,每个线程束执行自己的归约。每个线程不使用共享内存,而是使用寄存器存储一个从全局内存中读取的数据元素:
__inline__ __device__ int warpReduce(int localSum)
{
localSum += __shfl_xor(localSum, 16);
localSum += __shfl_xor(localSum, 8);
localSum += __shfl_xor(localSum, 4);
localSum += __shfl_xor(localSum, 2);
localSum += __shfl_xor(localSum, 1);
return localSum;
}
__global__ void reduceShfl(int * g_idata,int * g_odata,unsigned int n)
{
//set thread ID
__shared__ int smem[DIM];
unsigned int idx = blockDim.x*blockIdx.x+threadIdx.x;
//convert global data pointer to the
//1.
int mySum=g_idata[idx];
int laneIdx=threadIdx.x%warpSize;
int warpIdx=threadIdx.x/warpSize;
//2.
mySum=warpReduce(mySum);
//3.
if(laneIdx==0)
smem[warpIdx]=mySum;
__syncthreads();
//4.
mySum=(threadIdx.x<DIM)?smem[laneIdx]:0;
if(warpIdx==0)
mySum=warpReduce(mySum);
//5.
if(threadIdx.x==0)
g_odata[blockIdx.x]=mySum;
}
线程束级归约:
__inline__ __device__ int warpReduce(int localSum)
{
localSum += __shfl_xor(localSum, 16);
localSum += __shfl_xor(localSum, 8);
localSum += __shfl_xor(localSum, 4);
localSum += __shfl_xor(localSum, 2);
localSum += __shfl_xor(localSum, 1);
return localSum;
}
在这个函数返回之后,每个线程束的总和保存到基于线程索引和线程束大小的共享内存中
int mySum=g_idata[idx];
int laneIdx=threadIdx.x%warpSize;
int warpIdx=threadIdx.x/warpSize;
//2.
mySum=warpReduce(mySum);
//3.
if(laneIdx==0)
smem[warpIdx]=mySum;
对于线程块级归约,先同步块,然后使用相同的线程束归约函数将每个线程束的总和进行相加。
之后,由线程块产生的最终输出由块中的第一个线程保存到全局内存中
__syncthreads();
//4.
mySum=(threadIdx.x<DIM)?smem[laneIdx]:0;
if(warpIdx==0)
mySum=warpReduce(mySum);
//5.
if(threadIdx.x==0)
g_odata[blockIdx.x]=mySum;