关于《OPENCL异构并行计算》中工作组间同步的分析

来源:互联网 发布:翻墙软件大全 编辑:程序博客网 时间:2024/05/21 21:45

《OPENCL异构并行计算》中讲了如何利用使用同步锁的机制对各个工作组之间进行同步,这里对其进行简单分析:

对应那本书6.5节最后的内核代码,他要做一个对巨大数组进行求和的操作,这里数组大到一个工作组无法容纳数组的长度,而多次计算又浪费时间,那么怎么做到比如说一个长度为1024X1024X256的一维数组在每个工作组最大大小只有256的设备上实现并行计算然后求出整个数组所有元素之和的问题呢?

我们在这里不考虑例如int溢出的问题,我们假设这几亿个元素加起来的值不会出现溢出。

那么书中给了这样的思路:由于每个工作组大小只有256,那么在一个并行计算周期内,我们可以把数组分成1024X1024X256/256=1024X1024个工作组来同时计算出1024X1024个256个元素相加的和,然后把这1024X1024个元素在第二个周期,用设备进行求和,工作组个数为1024X1024/256=4096,然后计算下一次需要几个工作组来计算:4096/256=16,由于这里的16小于一个工作组的大小256,就没必要进行第三轮并行计算了,直接在主机端让CPU进行16个组元素的求和,即16X256个元素的求和,那简直太方便了,因此,这里的规则为:第一周期用前1024X1024个工作组进行并行计算,计算完成之后把1024X1024个元素进行第二轮求和,第二轮求和用的是前4096个工作组,4096个工作组之后的工作组就不使用了,然后计算出的4096个结果放在前16个工作组中就完成了设备端的操作,同时,不要忘了最后把最后剩余元素的个数,也就是16X256作为参数传回主机端。

书中的内核代码采用了局部存储器优化,也就是第一轮的时候先把全局数组的原始数据拷贝到局部数组里面,然后只用工作组的第一个工作项进行求和运算,其他工作项只负责把元素加载到他对应的局部存储器的位置,然后看他还有没有作用,比如他还有用(例如第一个工作组的第二个工作项,当他加载完原始元素的第二个元素之后,他还要负责当第一轮运算结束之后,改变第一个工作组局部存储器的第二个位置的值为第一轮计算中第二个工作组计算出来的和),他就会等待他对应的工作组算完然后把对应局部存储器的值更新为新计算的结果;不然他的任务就算结束了,比如第17个工作组的第二项,他就只负责初始化原始数据。

而对于每个工作组的第一个工作项,他们负责进行每一轮操作中对整个工作组的求和操作,然后把计算出的结果放在第二轮对应的内存中,如第18个工作组的第一项在第一轮运算中,负责求整个工作组元素的和,同时求完之后,把他的计算结果放入全局缓存内存的第18个位置,然后等待第一个工作组的第18个工作项来取到第一个工作组的局部内存的第18个位置。

那么这里需要注意的就是同步的问题了,以上面的例子来说,我们要保证一个工作组进行计算的过程中,他访问的局部存储器内容不会改变,因为工作组之间没有同步的方案,那么有可能出现第一个工作组还没有加完,他才加到第100个元素,而这时第150个工作组就已经算完,并把它的计算结果写入了第一个工作组的第150个工作项中,这样会导致第一个工作项加的值不是原始数组的第150个值,而是第150个工作项计算的结果,这样会导致结果错误,因此这里需要加锁。

那么锁该怎么加呢?拿第1个工作组为例,注意他的索引为0,让第一个工作组的第一个工作项加锁1号,对应代码:

If(index==0)

Atomic_or(&plocks[group_index],1);

即给1号锁上锁,锁完之后他就要进行计算第一个工作组的所有元素的和,就是下面的for循环,很简单不做解释,然后计算完之后,他不能马上进行第二轮的计算,因为他要进行的第二轮的计算的前提是第1到256个工作组都已经完成了他们的计算才行,那么这个时候我们要让第一个工作组等待,等待的方法就是等待刚才的1号锁被解锁,代码如下:

Int lock;

Do

{

Lock= atomic_add(&plocks[group_index],0);

}while(lock!=0)

根据书中的解释我们可以知道0是解锁状态,1是加锁状态,这里让lock值为atomic_add(&plocks[group_index],1)实际上是进行了一个+0的空操作,原来锁被锁住了值为1,让他一直对这个锁+0实际上并不改变锁的状态,而循环条件是lock!=0换句话说lock为0即被解锁了才跳出循环进行下一轮操作,即让此工作组等待解锁。

那么解锁的操作时谁来做的呢?

我们知道对于第1个工作组来说,他在第二轮实际上计算的是第1到256个工作组在第一轮的计算结果的和,那么他就需要等待索引为0~255的数组全部计算完毕才能进行下一轮的操作,而这里我不知道作者是不是意思是最后一个工作组,也就是第256个工作组计算完了,就认为第1~256个工作组全局计算完了就可以进行第二轮的操作了,作者这里就是这样认为的。即让第256个工作组为第一个工作组解锁,即让索引为255个工作组解锁索引为0的第一号锁。同理让第256*n的工作组解锁第n号锁,代码如下:

If((group_index & (GROUP_NUMBER_OF_WORKINTEMS-1))== GROUP_NUMBER_OF_WORKINTEMS-1)

{

Atomic_xchg(&plocks[group_index/ GROUP_NUMBER_OF_WORKINTEMS],0);

}

其中group_index是工作组的全局索引,如第256个工作组的group_index为255,二进制为11111111;GROUP_NUMBER_OF_WORKINTEMS为一个工作组中工作项的个数,这里为256,减一为255。那么把group_index 和 (GROUP_NUMBER_OF_WORKINTEMS-1)相"与",即&运算,看结果是不是GROUP_NUMBER_OF_WORKINTEMS-1(11111111&11111111=11111111),如255&255=255而254&255=254(11111110&11111111=11111110)不等于255,这样就确认了只让第256*n的工作组进行解锁了。而if块的内容就是把group_index/ GROUP_NUMBER_OF_WORKINTEMS号锁的值换成(xchg)0,即解锁这个锁。

其实这里我觉得作者这样做是不对的,他不应只等待第256个工作组计算完就认为可以进行第二轮计算了,而是应该等待第1到256号工作组全部计算完才能进行下一轮运算,就是说应该让每一个工作组计算完了之后,相应锁的值+1,等到第一个工作组检测到锁的值为256之后,就进行第二轮运算比较合理,也可能是我层次不够,不知道底层有什么优化或者同步导致的无法理解吧。

然后就是写代码决定让哪些工作组进行下一轮的运算,哪些结束运算,其实这个倒很简单,就是用下面的代码实现的,首先在开头用循环:

While(group_count>= GROUP_NUMBER_OF_WORKINTEMS && group_index < group_count)来定义,每一轮计算进行的条件是目前计算工作组的数目大于一个工作组的工作项的个数(开头讲过,如果结果个数小于256就不用并行化计算而是拿出来让主机端的CPU进行计算了),这就是第一个限制条件;同时还要满足这个工作组的索引位置小于这一轮需要的工作组的个数,这个就更容易理解了,第二轮只需要4096个工作组,自然第5000个工作组就不用了。

而group_count的大小每一轮都会更新成上一个大小的1/ GROUP_NUMBER_OF_WORKINTEMS倍,原因一目了然。

而对于等待加锁的过程,同样也是这个道理,只有那些需要进入下一轮运算的工作组才需要等待锁,因此在等待加锁的代码行中加入了以下限制:

if(group_index < group_count&&index==0)

第一个限制条件就是上面说的,而为什么要index==0呢?换句话说,为什么不让这个工作组的所有工作项同时等待第一轮的结束,而只是让第一个工作项等待就可以了呢?

这里我们注意解锁之后工作项还会做的一件事情,就是:pData=pDst,看代码就会知道这一句是用来把上一轮的工作组的计算结果赋值给这个工作项要访问的局部内存中的一个操作,即第一个工作组的第二个工作项这时候就要保存第2个工作组的计算结果了。

这里我觉得作者写的也不够清楚,我是认为应该去掉这个限制条件,让所有的工作项都等待对应工作组的计算结束才允许他更新局部存储器的值,不然会出现第2个工作组还没有计算完,第一个工作组的第二个工作项就把pDst里面的值更新了,这样他就读不到正确的第二组的计算结果了。除非还是像我上面说的那样,我层次不够,不知道底层有什么优化或者同步导致的无法理解吧。

然后就是进行下一轮的计算了,首先需要让参与计算的所有工作项来更新所在工作组局部存储器的内容,对应代码:

tmpBuffer[index] = pData[address_offset];

注意这里的pData数组第一轮是从原始数组pSrc1中取数据,第二轮开始之后就是从缓存数组pDst中取数据了。然后不要忘了等待这一组所有工作项全部加载完毕再进行下一步的计算:

Barrier(CLK_LOCAL_MEM_FENCE);

这里由于第一个工作项会等待其他工作组上一轮的计算,所以倒不用担心局部存储器获得不了正确的值,只要pDst中的值正确,他的值就会正确,但正如上面所说的,我认为可能pDst的值会出现不正确的情况。

最后不要忘了把最后一轮剩余的元素个数传给主机端,以便让主机端对仅剩的小于256个计算结果进行求和操作,就是内核函数的最后两行,这里不再列出。

那么对整个内核函数,以数组大小为1024X1024X256为例,举几个工作项的例子:

对于第1024X1023X256+2个工作项,由于他不是第1024X1023组的第一个工作项,因此他的行为如下:赋值第1024X1023组局部存储器第2个位置(tmpBuffer[index] = pData[address_offset];),然后等待本组左右工作项赋值完毕(Barrier(CLK_LOCAL_MEM_FENCE)),然后计算下一轮需要多少个工作项(group_count/=GROUP_NUMBER_OF_WORKINTEMS),把他的pData值指向改成pDst (pData=pDst),然后发现下一轮不需要他了,退出循环(While(group_count>= GROUP_NUMBER_OF_WORKINTEMS && group_index < group_count))。

对于第1024X1023X256+1个工作项,由于他是第1024X1023组的第一个工作项,因此他的行为如下:加锁第1024X1023号锁(Atomic_or(&plocks[group_index],1);),赋值第1024X1023组局部存储器第1个位置,然后等待本组左右工作项赋值完毕,然后计算第1024X1023组的所有元素之和(for到sum),把求得的和赋给pDst[1024X1023-1],这里由于他的工作组索引和255的相与操作为255,所以解锁1024X4号锁,然后计算下一轮需要多少个工作项,然后发现自己下一轮并不需要进行计算,于是就没必要等待解锁,跳过(if(group_index < group_count&&index==0))把他的pData值指向改成pDst,然后发现下一轮不需要他了,退出循环。

对于第1023X4X256+2个工作项,由于他不是第1023X4组的第一个工作项,因此他的行为如下:赋值第1023X4组局部存储器第2个位置,然后等待本组左右工作项赋值完毕,然后计算下一轮需要多少个工作项,然后发现下一轮还需要他了,他就继续就行第二轮操作,同样也是赋值局部存储器然后等待,然后第二轮计算完了不需要进行第三轮,退出。

对于第1023X4X256+1工作项,由于他是第1023X4组的第一个工作项,因此他的行为如下:加锁第1023X4号锁,赋值第1023X4组局部存储器第1个位置,然后等待本组左右工作项赋值完毕,然后计算第1023X4组的所有元素之和,把求得的和赋给pDst[1023X4-1],这里由于他和255的相与操作不为255,所以不进行解锁操作,然后计算下一轮需要多少个工作项,然后发现自己下一轮还需要进行计算,于是就等待第1024X4号锁解锁,解锁之后把他的pData值指向改成pDst,然后进行下一轮的计算,依次类推。

对于第一个工作项,在执行一系列操作的基础上,最后要把需要主机端计算的元素个数传回去。

这就是整个内核函数的执行过程,上面两个疑问如果哪位大神能给我解释了,我将不胜感激,欢迎大家指出错误,以便我进行改正。

This is OK.

0 0
原创粉丝点击