提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
线程数基本函数与协助组
在伏特架构之前,一个线程束中的线程拥有同一个程序计算器,但各自有不同的寄存器状态,从而可以根据程序的逻辑判断选择不同的分支。虽然可以选择分支,但在执行时,各个分支是依次顺序执行的。在同一个时刻,一个线程束中的线程只能执行一个共同的指令或者闲置,这就是单指令-多线程(single instruction multiple thread,SIMT)的执行模式。
分支发散:一个线程束中的线程根据不同的条件,选择执行不同的判断语句。
if(condition)
{
A;
}
else
{
B;
}
分支发散是一个线程数的部分满足条件的线程执行计算,其他不满足的直接闲置,会导致一些需要执行判断语句的程序性能无法最大化。
一般来说,在编写核函数时要尽量避免分支发散。但很多情况,一些算法的需求是存在分支发散的情况的,像前面的数组相加的计算。使用判断语句:
if (n < N)
{
z[n] = x[n] + y[n];
}
该语句可能会导致最后一个线程块中的某些线程束发生分支发散的情况,故一般来说不会明显地影响程序地性能。
从伏特架构开始,引入了独立线程调度机制,每个线程有自己的程序计数器。这使得伏特架构有了一些以前的架构所没有的新的线程束内同步与通信的模式。
在前面共享内存中机型数组归约的时候,要在一个线程块中进行线程的同步,我们使用线程块同步函数__syncthreads()。可以将同步函数__syncthreads()进行换成一个更廉价的线程束同步函数 __syncwarp()。此函数原型为
void __syncwarp(unsigned mask=0xffffffff);
该函数的一个可选参数代表一个掩码的无符号整型数,默认全部32个二进制位都为1,代表该线程束中的所有线程都参与同步
首先,前面的线程块同步操作和以前一样:
for (int offset = blockDim.x >> 1; offset >= 32; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
上面代码里,for循环将剩下的最后一个32个线程(正好对应一个线程束)操作进行线程束同步。
在最后一个线程束的计算操作中使用线程束同步函数去进行同步:
for (int offset = 16; offset > 0; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncwarp();
}
其中条件判断语句if (tid < offset),将s_y[16] += s_y[32]这种情况排除了(一个线程束0-31)。
整个核函数代码如下:
void __global__ reduce_syncwarp(const real *d_x, real *d_y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
extern __shared__ real s_y[];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
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();
}
if (tid == 0)
{
atomicAdd(d_y, s_y[0]);
}
}
介绍些线程束的基本函数:
(1)__ballot_sync(mask,predicate)
改函数返回一个无符号整数。如果线程束内第n个线程参与计算且predicate值非0,则将返回无符号整数的第n个二进制位取1,否则取0。该函数的功能相当于从一个旧的掩码出发,产生一个新的掩码。
(2)__all_sync(mask,predicate)
线程束内所有参与线程的predicate值不为0才返回1,否则返回0。
(3)__any_sync(mask,predicate)
线程束内所有参与线程的predicate值有一个不为0就返回1,否则返回0。
(4)__shf1_sync(mask,v,predicate)
参与线程返回标号srcLane的线程中变量v的值。这是一个广播式数据交换,即将一个线程中的数据广播到所有(包括自己)线程中。
(5)__shf1_up_sync(mask,v,d,w)
标号为t的参与线程返回标号为t-d的线程中变量v值。标号满足t-d<0的线程返回原来的v。例如,当w=8,d=2时,该函数将第0-5号线程中变量v的值传送到第2-7号线程,而第0-1号线程返回它们原来的v。形象的说,是一种将数据向上平移的操作。
(6)__shf1_down_sync(mask,v,d,w)
标号为t的参与线程返回标号为t+d的线程中变量v值。标号满足t+d>=w的线程返回原来的v值。和上面的相反是一种将数据向下平移的操作。
(7)__shf1_xor_sync(mask,v,lanemask,w)
标号为 t 的参与线程返回标号为t ^ laneMask的线程中变量v值。t ^ laneMask表示两个整数按位异或运算的结果。当w=8,laneMask=2时,第0-7号线程的按位异或运算分别为(0-8)^ 2。
可以自己在官方技术文档里查看:https://docs.nvidia.com/cuda/cuda-c-programming-guide/#warp-shuffle-functions
或者官方中文:https://developer.nvidia.com/zh-cn/blog/using-cuda-warp-level-primitives/
在最后一个线程束处理求和操作时,原来的做法是线程束前一般的线程拿来和后一半的线程进行之间的数组里数据的求和。等价于将高位一半的线程束数据向下平移至低位,选择使用线程束洗牌函数。
for (int offset = 16; offset > 0; offset >>= 1)
{
y += __shfl_down_sync(FULL_MASK, y, offset);
}
相当于原来的数据加上经过数据向下平移的数据。去掉了线程束同步函数也去掉了用条件判断语句进行的线程号的限制。因为洗牌函数能够自动处理同步与“读-写”竞争的问题。
整个代码如下:
void __global__ reduce_shfl(const real *d_x, real *d_y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
extern __shared__ real s_y[];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
for (int offset = blockDim.x >> 1; offset >= 32; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
real y = s_y[tid];
for (int offset = 16; offset > 0; offset >>= 1)
{
y += __shfl_down_sync(FULL_MASK, y, offset);
}
if (tid == 0)
{
atomicAdd(d_y, y);
}
}
其中,还有个不同点是:在进行线程束内的循环计算之前,将共享内存的数据复制到了寄存器内存中。这里使用寄存器内存要比使用共享内存要高效。
在并行算法中,需要若干线程间的协作,要协作就必须要有同步机制。协作组可以看作线程块和线程束同步机制的推广,它提供了更加灵活的线程协作方式,包括线程块内部的同步与协作、线程块之间的及设备之间的同步与协作。
使用协作组功能时,要包含如下头文件:
#include
而且还要注意所有与协作相关的数据类型和函数都定义在命名空间:
using namespace cooperative_groups
定义和初始化一个thread_block()对象:
thread_block g = this_thread_block();
this_thread_block()相当于一个线程块类型的常量,把g包装成立一个类型。例如,g.sync()完全等价于__syncthreads(),g.group_index完全等价于cuda中的内建变量blockIdx,g.thread_index完全等价于cuda的内建变量threadIdx。
其中,可以将线程组分割为更加细的线程组。
thread_block g32 = tiled_partition(g32,4);
当线程大小已知时,可用如下模板定义:
// 这样定义的线程组一般称为线程片
thread_block_tile<32> g = tiled_partition<32>(this_thread_block());
使用协作组进行数组归约的代码:
void __global__ reduce_cp(const real *d_x, real *d_y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
const int n = bid * blockDim.x + tid;
extern __shared__ real s_y[];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
for (int offset = blockDim.x >> 1; offset >= 32; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
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);
}
if (tid == 0)
{
atomicAdd(d_y, y);
}
}
提高线程的利用率:因为在前面的数组归约中,使用折半归约法,所使用的线程都是大小为一半的。比如,使用大小为128的线程块,第一次计算时,offset=64,有一半的线程闲置,offset=32时,有3/4闲置,以此类推。利用率只有(1/2+1/4+…)=1/7。
void __global__ reduce_cp(const real *d_x, real *d_y, const int N)
{
const int tid = threadIdx.x;
const int bid = blockIdx.x;
extern __shared__ real s_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();
for (int offset = blockDim.x >> 1; offset >= 32; offset >>= 1)
{
if (tid < offset)
{
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
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);
}
if (tid == 0)
{
d_y[bid] = y;
}
}
线程数基本函数与协助组,内容比较多,一些概念比较难理解。
如博客内容有侵权行为,可及时联系删除!
CUDA 编程:基础与实践
https://docs.nvidia.com/cuda/
https://docs.nvidia.com/cuda/cuda-runtime-api
https://github.com/brucefan1983/CUDA-Programming
https://docs.nvidia.com/cuda/cuda-c-programming-guide/#warp-shuffle-functions
https://developer.nvidia.com/zh-cn/blog/using-cuda-warp-level-primitives/