【CUDA 基础】3.4 避免分支分化


title: 【CUDA 基础】3.4 避免分支分化
categories:
- CUDA
- Freshman
tags:
- 规约问题
- 分支分化
toc: true
date: 2018-04-17 23:32:55

Abstract: 介绍规约问题中的分支分化问题
Keywords: 规约问题,分支分化 此篇有些结果和参考书中结果相反,需要更深入的技术才能解决

开篇废话

我坚持写博客是因为我上次最困惑最难过的那段时间通过写博客改变了我的非常不好的情况,所以我认为写些东西梳理自己的思路能够改变我的生活,所以我会一直坚持,学习的内容是没有止境的,所以博客也可以写很多。
写博客为了收入我之前也想过,最后放弃了,因为如果你的目的就是挣钱,有写博客这大把时间还不如出去跑个滴滴或者送个外卖来得快,所以我把之前有的捐赠部分都取消掉了,我并不否定那些博客写的质量非常高的人因此有收入,做得好,帮助到人了就可以获得收入,但是为了收入去帮助人,那叫服务。所以我后面可能会挂一个小广告,但是绝对不会因为广告搞得博客非常凌乱,而且收入应该只用作服务器和域名费用,仅此而已,我个人对于做事非常看重目的,我的目的是分享知识,并不是收入,收入只是附加的。
本文介绍一个并行计算中最常见的典型情况,并行分化(线程束分化的等价问题),以及规约问题,以及其初步优化。

并行规约问题

在串行编程中,我们最最最常见的一个问题就是一组特别多数字通过计算变成一个数字,比如加法,也就是求这一组数据的和,或者乘法,这种计算当有如下特点的时候,我们可以用并行归约的方法处理他们:

  1. 结合性
  2. 交换性

对应的加法或者乘法就是交换律和结合律,在我们的数学分析系列已经详细的介绍了加法和乘法的结合律和交换律的证明。所以对于所有有这两个性质的计算,都可以使用归约式计算。
为什么叫归约,归约是一种常见的计算方式(串并行都可以),一开始我听到这个名字的时候应该是在两年前了,感觉很迷惑,后来发现,归约的归有递归的意思,约就是减少,这样就很明显了,每次迭代计算方式都是相同的(归),从一组多个数据最后得到一个数(约)。
归约的方式基本包括如下几个步骤:

  1. 将输入向量划分到更小的数据块中
  2. 用一个线程计算一个数据块的部分和
  3. 对每个数据块的部分和再求和得到最终的结果。

数据分块保证我们可以用一个线程块来处理一个数据块。
一个线程处理更小的块,所以一个线程块可以处理一个较大的块,然后多个块完成整个数据集的处理。
最后将所有线程块得到的结果相加,就是结果,这一步一般在cpu上完成。

归约问题最常见的加法计算是把向量的数据分成对,然后用不同线程计算每一对元素,得到的结果作为输入继续分成对,迭代的进行,直到最后一个元素。
成对的划分常见的方法有以下两种:

  1. 相邻配对:元素与他们相邻的元素配对
  2. 交错配对:元素与一定距离的元素配对

图中将两种方式表现的很清楚了,我们可以用代码实现以下。
首先是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这类关键词。
如下图所表示的那样,我们对相邻元素配对进行内核实现的流程描述:

根据上一小节介绍:
第一步:是把这个一个数组分块,每一块只包含部分数据,如上图那样(图中数据较少,但是我们假设一块上只有这么多。),我们假定这是线程块的全部数据
第二步:就是每个线程要做的事,橙色圆圈就是每个线程做的操作,可见线程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,
完整的执行逻辑如下,

注意主机端和设备端的分界,注意设备端的数据分块。
完整的可执行代码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的代码就是reducneighbored的代码,可见还是有微弱的差别的。

改善并行规约的分化

完整内容参考https://face2ai.com/CUDA-F-3-4-避免分支分化/

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
CUDA编程是一种用于并行计算的编程模型,它允许开发者利用GPU的并行计算能力来加速计算任务。CUDA编程的基本步骤包括编写源代码、预处理、编译、汇编和链接,最终生成可执行文件。\[1\]在CUDA程序中,可以使用主机函数和核函数。主机函数在主机上执行,而核函数在GPU上执行。编译器nvcc会将纯粹的C++代码交给C++编译器处理,而自己负责编译剩下的部分。CUDA程序的源文件扩展名通常是.cu。\[2\] 在CUDA编程中,核函数中的数据与线程是一一对应的。通过使用"单指令-多线程"的方式编写代码,可以将数组元素指标与线程指标对应起来。例如,可以使用以下代码来计算数组元素的索引: unsigned int idx_x = blockDim.x * blockIdx.x + threadIdx.x;\[3\] 总结来说,CUDA编程基础包括编写源代码、编译、汇编和链接,使用主机函数和核函数,以及将数据与线程对应起来。这些基础知识可以帮助开发者利用GPU的并行计算能力来加速计算任务。 #### 引用[.reference_title] - *1* *2* *3* [CUDA 编程 基础与实践(樊哲勇) 摘录](https://blog.csdn.net/weixin_47955824/article/details/116491638)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值