ECE408 Lecture 15 Parallel Computation Patterns Reduction Trees
这次回顾ECE408 Lecture 15,这次介绍了Reduction Trees。
课程主页:
搬运视频:
Amdahl定律
Amdahl定律描述一个程序的可优化程度,一个程序分为并行和非并行部分,只有并行部分可以优化:
其中:
- $k$是可并行的比例;
- $p$是并行的资源;
所以加速比例为$\frac{1}{(1-k)+\left(\frac{k}{p}\right)}$。
Reduction无处不在,往往是并行计算的最后阶段
- Reduction将一组输入转化为单个值;
- Max, Min, Sum, Product;
- 基本上,任何满足以下条件的运算符/函数都是Reduction:
- 满足结合律和交换律;
- 有幺元,即一个特殊的元素,sum时是0,product时是1;
Partition和Summarize
- 数据集的常用策略
- 如果基本操作满足结合律和交换律(即,可重新排序)
- 将数据集分成更小的块;
- 让每个线程处理一个块;
- 使用Reduction Tree将每个块的结果汇总为最终答案;
- 如果基本操作满足结合律和交换律(即,可重新排序)
- 大数据云框架(来自谷歌和其他公司)为这种模式提供支持;
Reduction使其他技术成为可能
- Reduction归约通常需要作为并行计算的最后阶段;
- 例如:私有化
- 多个线程写入输出位置;
- 复制输出位置,使每个线程都有一个私有的输出位置;
- 使用Reduction Tree将私有位置的值组合到原始输出位置;
实例:锦标赛使用”max” Reduction
一个Reduction的示例就是锦标赛:
顺序Reduction算法需要$O(N)$次操作
- 将结果初始化为归约操作的特殊值$I$;
- Max Reduction对应最小值;
- Min Reduction对应最大值;
- Sum Reduction对应0;
- Production Reduction对应1;
- 遍历输入,对结果值和当前输入值进行Recution运算;
- 伪代码:
- result <- I;
- for each value X in input
- result <- result op X;
- for each value X in input
- result <- I;
- 伪代码:
并行Reduction需要$O(\log N)$次操作
并行算法效率很高
对于$N$个输入,总操作数为:
所示的并行算法工作效率高:需要与顺序算法相同的工作量(开销可能不同)。
但是需要大量资源
- 对于$N$个输入值,步数为$\log (N)$,如果有足够的执行资源:
- $N=1,000,000$需要$20$步!
- 我们需要多少并行度?
- 平均而言,$(N-1)/\log(N)$,在我们的示例中为$50,000$。
- 但是峰值是$N/2$,在我们的示例中为$500,000$。
Sum Reduction例子
- 并行实现:
- 在每个步骤中将每个线程中的两个值相加;
- 递归地将线程数减半;
- 对$n$个元素执行$\log(n)$步,需要$n/2$个线程;
- 假设使用共享内存进行in-place reduction;
- 原始向量在设备全局内存中;
- 共享内存用于保存部分和向量;
- 每一步都使部分和向量更接近总和;
- 最后的总和将在元素0中;
- 由于使用共享内存,全局内存流量应该最小;
图示
代码
// Stride is distance to the next value being
// accumulated into the threads mapped position
// in the partialSum[] array
for (unsigned int stride = 1;
stride <= blockDim.x; stride *= 2)
{
__syncthreads();
if (t % stride == 0)
partialSum[2*t]+= partialSum[2*t+stride];
}
如果不使用__syncthreads()
,那么结果会有问题:
Block完成后的几个选项
- 在所有reduction步骤之后,线程0
- 从
partialSum[0]
写入块的总和; - 进入由
blockIdx.x
索引的全局向量;
- 从
- 向量的长度为$N/ (2\times \mathrm{numBlocks})$;
- 如果长度很小,将向量传输到主机并在CPU上求和;
- 如果长度很大,再次启动内核(再一次),内核也可以使用原子操作累加到全局sum,稍后将介绍;
Segmented Reduction
执行资源分析
- 所有线程在第一步中都处于活动状态。在所有后续步骤中,两个控制流路径:
- 执行加法,或者什么都不做;
- 什么都不做仍然会消耗执行资源;
- 最多一半的线程在第一步之后执行加法:
- (所有具有奇数索引的线程在第一步之后被禁用);
- 第五步之后,整个wraps什么都不做:资源利用率低,但没有divergence;
- 活动线程束只有一个活动线程;
- 最多五个步骤(如果限制为1024个线程)。
更好的策略
让我们试试这个方法:
- 在每一步中;压缩部分和到partialSum数组的第一个位置;
这样做可以使活动线程保持连续。
示例
代码
for (unsigned int stride = blockDim.x; stride >= 1; stride /= 2)
{
__syncthreads();
if (t < stride)
partialSum[t] += partialSum[t+stride];
}
执行资源分析
- 给定1024个线程;
- 块加载2048个元素到共享内存;
- 前六步没有分支分歧:
- 1024、512、256、128、64和32个连续线程处于活动状态;
- 每个warp中的线程全部处于活动状态或全部处于非活动状态;
- 最后六个步骤有一个活动warp(最后五个步骤有分支分歧);
完整算法
__shared__ float partialSum[2*BLOCK_SIZE];
unsigned int t = threadIdx.x;
unsigned int start = 2*blockIdx.x*blockDim.x;
partialSum[t] = input[start + t];
partialSum[blockDim.x+t] = input[start + blockDim.x + t];
for (unsigned int stride = blockDim.x; stride >= 1; stride /= 2)
{
__syncthreads();
if (t < stride)
partialSum[t] += partialSum[t+stride];
}
小结
- 尽管“操作”的数量是$N$,但每个“操作”都涉及复杂得多的地址计算和中间结果操作。
- 如果并行代码在单线程硬件上执行,它会比基于原始顺序算法的代码慢。
进一步改进
- 该问题的计算强度较低
- 每读取一个4B值一个操作;
- 因此,请关注内存合并并避免计算资源使用不当;
- 试验超参数:
- 每块的线程数量;
- 每个SM的块数量;
- 线程粒度(3-1Reduction);
终止状态
- 较小的块可能看起来很有吸引力:
- 当wrap处于活动状态时;
- 每个SM的每个块都有一个warp;
- 但可能有更好的方法,例如:
- 在32个元素(或64个或128个元素)处停止减少,然后交给下一个内核;
整体流程
假设有8个SM,所以有16个block:
- 将整个数据集分成16块;
- 读取数据,直到足以填满共享内存;
- 计算,直到不需要某些线程为止。
然后加载更多数据!
重复直到数据用完;
- 然后让并行度下降。
- (在Host上收集16个值并进行Reduction。)
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Doraemonzzz!
评论
ValineLivere