3.4 避免分支分化
Abstract: 介绍规约问题中的分支分化问题
Keywords: 规约问题,分支分化
此篇有些结果和参考书中结果相反,需要更深入的技术才能解决
避免分支分化
本文介绍一个并行计算中最常见的典型情况,并行分化(线程束分化的等价问题),以及规约问题,以及其初步优化。
并行规约问题
在串行编程中,我们最常见的一个问题就是一组特别多数字通过计算变成一个数字,比如加法,也就是求这一组数据的和,或者乘法,这种计算当有如下特点的时候,我们可以用并行规约的方法处理它们:
- 结合性
- 交换性
对应的加法或者乘法就是交换律和结合律,在我们的数学分析系列已经详细地介绍了加法和乘法的结合律和交换律的证明。所以对于所有有这两个性质的计算,都可以使用规约式计算。
为什么叫规约,规约是一种常见的计算方式(串并行都可以),一开始我听到这个名字的时候应该是在两年前了,感觉很迷惑,后来发现,规约实际上是指通过递归的方式逐步减少数据量,每次迭代计算方式都是相同的(递归性质),从一组多个数据最后得到一个数(约化),这样就很明显了。
规约的方式基本包括如下几个步骤:
- 将输入向量划分到更小的数据块中
- 用一个线程计算一个数据块的部分和
- 对每个数据块的部分和再求和得到最终的结果
数据分块保证我们可以用一个线程块来处理一个数据块。 一个线程处理更小的块,所以一个线程块可以处理一个较大的块,然后多个块完成整个数据集的处理。 最后将所有线程块得到的结果相加,就是结果,这一步一般在CPU上完成。
规约问题最常见的加法计算是把向量的数据分成对,然后用不同线程计算每一对元素,得到的结果作为输入继续分成对,迭代地进行,直到最后一个元素。
成对的划分常见的方法有以下两种:
-
相邻配对:元素与它们相邻的元素配对

-
交错配对:元素与一定距离的元素配对

图中将两种方式表现得很清楚了,我们可以用代码实现一下。
首先是CPU版本实现交错配对规约计算的代码:
int recursiveReduce(int *data, int const size)
{
// terminate check
if (size == 1)
return data[0];
// renew the stride
int const stride = size / 2;
if (size % 2 == 1)
{
for (int i = 0; i < stride; i++)
{
data[i] += data[i + stride];
}
data[0] += data[size - 1];
}
else
{
for (int i = 0; i < stride; i++)
{
data[i] += data[i + stride];
}
}
// call
return recursiveReduce(data, stride);
}
和书上的代码有些不同,因为书上的代码没有考虑数组长度非2的整数幂次的结果。所以我加了一个处理奇数数组最后一个无人配对的元素的处理。
这个加法运算可以改成任何满足结合律和交换律的计算。比如乘法,求最大值等。
下面我们就来通过不同的配对方式,不同的数据组织来看CUDA的执行效率。
并行规约中的分化
线程束分化已经明确说明了 ,有判断条件的地方就会产生分支,比如if和for这类关键词。
如下图所表示的那样,我们对相邻元素配对进行内核实现的流程描述:
(img)(https://tony4ai-1251394096.cos.ap-hongkong.myqcloud.com/blog_images/3_21.png)
根据上一小节介绍:
第一步:是把这个一个数组分块,每一块只包含部分数据,如上图那样(图中数据较少,但是我们假设一块上只有这么多),我们假定这是线程块的全部数据。
第二步:就是每个线程要做的事,橙色圆圈就是每个线程做的操作,可见线程threadIdx.x=0的线程进行了三次计算,奇数线程一直在陪跑,没做过任何计算,但是根据3.2中介绍,这些线程虽然什么都不干,但是不可以执行别的指令,4号线程做了两步计算,2号和6号只做了一次计算。
第三步:将所有块得到的结果相加,就是最终结果。
这个计算划分就是最简单的并行规约算法,完全符合上面我们提到的三步走的套路。
值得注意的是,我们每次进行一轮计算(黄色框,这些操作同时并行)的时候,部分全局内存要进行一次修改,但只有部分被替换,而不被替换的,也不会在后面被使用到,如蓝色框里标注的内存,就被读了一次,后面就完全没有人管了。
我们现在把我们的内核代码贴出来:
__global__ void reduceNeighbored(int * g_idata, int * g_odata, unsigned int n)
{
//set thread ID
unsigned int tid = threadIdx.x;
//boundary check
if (tid >= n) return;
//convert global data pointer to the
int *idata = g_idata + blockIdx.x * blockDim.x;
//in-place reduction in global memory
for (int stride = 1; stride < blockDim.x; stride *= 2)
{
if ((tid % (2 * stride)) == 0)
{
idata[tid] += idata[tid + stride];
}
//synchronize within block
__syncthreads();
}
//write result for this block to global mem
if (tid == 0)
g_odata[blockIdx.x] = idata[0];
}
这里面唯一要注意的地方就是同步指令:
__syncthreads();
原因还是能从图上找到,我们的每一轮操作都是并行的,但是不保证所有线程能同时执行完毕,所以需要等待,执行得快的等待慢的,这样就能避免块内的线程竞争内存了。
被操作的两个对象之间的距离叫做跨度(stride),也就是变量stride。
完整的执行逻辑如下:

注意主机端和设备端的分界,注意设备端的数据分块。
完整的可执行代码Github: https://github.com/Tony-Tan/CUDA_Freshman
这里把主函数贴出来,但注意里面包含后面的核函数执行部分,所以想要运行还是去github上拉一下吧,顺便点个star:
int main(int argc, char** argv)
{
.........
int size = 1 << 24;
.........
dim3 block(blocksize, 1);
dim3 grid((size - 1) / block.x + 1, 1);
.........
//cpu reduction
int cpu_sum = 0;
iStart = cpuSecond();
for (int i = 0; i < size; i++)
cpu_sum += tmp[i];
printf("cpu sum:%d \n", cpu_sum);
iElaps = cpuSecond() - iStart;
printf("cpu reduce elapsed %lf ms cpu_sum: %d\n", iElaps, cpu_sum);
//kernel 1:reduceNeighbored
CHECK(cudaMemcpy(idata_dev, idata_host, bytes, cudaMemcpyHostToDevice));
CHECK(cudaDeviceSynchronize());
iStart = cpuSecond();
warmup <<<grid, block >>>(idata_dev, odata_dev, size);
cudaDeviceSynchronize();
iElaps = cpuSecond() - iStart;
cudaMemcpy(odata_host, odata_dev, grid.x * sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x; i++)
gpu_sum += odata_host[i];
printf("gpu warmup elapsed %lf ms gpu_sum: %d<<<grid %d block %d>>>\n",
iElaps, gpu_sum, grid.x, block.x);
//kernel 1:reduceNeighbored
CHECK(cudaMemcpy(idata_dev, idata_host, bytes, cudaMemcpyHostToDevice));
CHECK(cudaDeviceSynchronize());
iStart = cpuSecond();
reduceNeighbored << <grid, block >> >(idata_dev, odata_dev, size);
cudaDeviceSynchronize();
iElaps = cpuSecond() - iStart;
cudaMemcpy(odata_host, odata_dev, grid.x * sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x; i++)
gpu_sum += odata_host[i];
printf("gpu reduceNeighbored elapsed %lf ms gpu_sum: %d<<<grid %d block %d>>>\n",
iElaps, gpu_sum, grid.x, block.x);
//kernel 2:reduceNeighboredLess
CHECK(cudaMemcpy(idata_dev, idata_host, bytes, cudaMemcpyHostToDevice));
CHECK(cudaDeviceSynchronize());
iStart = cpuSecond();
reduceNeighboredLess <<<grid, block>>>(idata_dev, odata_dev, size);
cudaDeviceSynchronize();
iElaps = cpuSecond() - iStart;
cudaMemcpy(odata_host, odata_dev, grid.x * sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x; i++)
gpu_sum += odata_host[i];
printf("gpu reduceNeighboredLess elapsed %lf ms gpu_sum: %d<<<grid %d block %d>>>\n",
iElaps, gpu_sum, grid.x, block.x);
//kernel 3:reduceInterleaved
CHECK(cudaMemcpy(idata_dev, idata_host, bytes, cudaMemcpyHostToDevice));
CHECK(cudaDeviceSynchronize());
iStart = cpuSecond();
reduceInterleaved << <grid, block >> >(idata_dev, odata_dev, size);
cudaDeviceSynchronize();
iElaps = cpuSecond() - iStart;
cudaMemcpy(odata_host, odata_dev, grid.x * sizeof(int), cudaMemcpyDeviceToHost);
gpu_sum = 0;
for (int i = 0; i < grid.x; i++)
gpu_sum += odata_host[i];
printf("gpu reduceInterleaved elapsed %lf ms gpu_sum: %d<<<grid %d block %d>>>\n",
iElaps, gpu_sum, grid.x, block.x);
// free host memory
.....
}
代码太长不美观,删减一下,只留下了内核执行部分,可见,主函数只有最后一个循环求和的过程是要注意别忘了的,其他都是常规操作。
还有一点,需要注意实际任务中数组不可能每次都是2的整数幂,如果不是2的整数幂需要确定数组边界。

上图就是执行结果,为什么有那么多,因为我把下面两个经过优化的也装进去了,黄色框框里是我们上面这段代码执行结果和时间,warmup是为了启动GPU防止首次启动计算时GPU的启动过程耽误时间,影响效率测试,warmup的代码就是reduceNeighbored的代码,可见还是有微弱的差别的。