【问题标题】:last warp loop unrolling in Nvidia's parallel reduction tutorial problemNvidia 的并行缩减教程问题中的最后一个扭曲循环展开
【发布时间】:2022-01-02 19:45:59
【问题描述】:

在 Nvidia 的并行缩减教程here 中,我在理解“最后一个扭曲循环展开”技术背后的逻辑时遇到了问题。

如果是thread31tid=31),在展开循环之前:

这个线程只执行这些操作:

sdata[31] += sdata[31+64]
sdata[31] += sdata[31+32]

但循环展开后(如下图):

thread31 的条件if(tid < 32) 为真,并且将为其执行warpReduce 函数,因此所有这些在展开循环版本中不会执行的操作现在都将执行:

sdata[31] += sdata[31+32] //for second time
sdata[31] += sdata[31+16]
...
sdata[31] += sdata[31+1]

这背后的逻辑是什么?

【问题讨论】:

    标签: parallel-processing cuda nvidia reduction loop-unrolling


    【解决方案1】:

    第一:

    sdata[31] += sdata[31+32] //for second time
    

    不,不是这样,它不会被第二次执行。当 s 变量从 64 右移到 32 时,循环终止,并且循环体不会为 s=32 执行。因此,上面的语句在循环体中执行,因为那将暗示s=32,它被循环终止条件排除在外。

    现在,谈谈你的问题。确实,这两种情况之间存在行为差异,但最后唯一重要的结果是 sdata[0],这种行为差异不影响 sdata[0] 的结果计算。所以剩下的就是“这对性能有影响吗?”

    我无法为您提供答案,但我怀疑这会产生重大影响。在非 warp-reduce 的情况下,在每次循环迭代中,都会对寄存器变量进行右移操作,然后是测试,然后是一组预测的共享内存指令。在 warp-reduce 情况下,有一些额外的共享内存加载/存储活动和添加算法,但没有移位算法或每个缩减步骤的测试。

    关于额外的加载/存储活动,唯一重要的部分是将达到“高于”扭曲范围(即 0-31)的部分。这里有额外的共享加载活动。额外的存储活动和额外的加法运算是无关紧要的,因为将这些操作限制为少于一个扭曲在性能方面并没有任何更好的表现(这一点在演示文稿本身中有所介绍,“我们不需要if (tid < s),因为它不需要不保存任何 工作”)。所以这里唯一需要考虑的是每步一次“额外”读取共享内存,一个额外的事务,基本上,每一步。与此相反,我们有移位、条件测试和预测。

    我不知道哪个更快,但我对“逻辑”的猜测是:

    1. 差异会很小。在此代码中,共享内存压力不太可能成为问题。

    2. 编写它的人要么根本没有考虑这一点,要么考虑了它并认为它可能是如此微不足道,以至于不值得将真正专注于其他事情的演示文稿弄得一团糟,并且会被许多人阅读人。

    编辑:基于 cmets,我声称行为差异不会影响 sdata[0] 的结果计算似乎仍然存在一些问题。

    • 首先,让我们承认最后我们唯一关心的项目是sdata[0]sdata[1] 或任何其他“结果”与此讨论无关。

    • 让我们观察每个步骤中哪些线程计算重要。我们可以观察到,在 final-warp 减少的给定步骤中,唯一重要的线程(即可能对sdata[0] 中的最终值产生影响)是那些小于偏移值的线程:

       sdata[tid] += sdata[tid + offset];   // where offset is 32, then 16, then 8, etc.
      
    • 这是为什么?为了理解这一点,我们需要了解两件事。首先,我们必须在这一点上理解 warp-synchronous 行为的期望。这已经在演示文稿(幻灯片 21)中确定为将循环缩减转换为展开的最终扭曲缩减的必要前提条件。我不会花很多时间在 warp 同步的定义上,但这本质上意味着我们依赖于 warp 来同步执行。一个 warp 是 32 个线程,这意味着当一个线程正在执行特定指令时,warp 中的每个线程都在执行该指令,即指令流中的那个点。其次,我们需要仔细分解上面的行来理解操作的顺序。上面这行 C++ 代码会分解成 GPU 实际执行的伪机器语言代码:

       LD   R0, sdata[tid]
       LD   R1, sdata[tid+offset]
       ADD  R3, R2, R1
       ST   sdata[tid], R3
      

      在英语中,在最终warp展开缩减的每一步中,每个线程将加载其sdata[tid]值,然后每个线程将加载其sdata[tid+offset]值,然后每个线程将这两个值相加,然后每个线程将存储结果。因为此时 warp 正在同步执行,所以当每个线程加载其 sdata[tid] 值时,这意味着每个线程都在该指令周期/时钟周期加载其各自的值,即在那个时刻。

    • 现在,让我们重新审视整体操作。在我们拥有的序列中的点:

      sdata[tid] += sdata[tid + 16]; 
      

      我们如何证明这里唯一重要的线程是那些tid 值小于偏移量的线程?每个线程做的第一件事就是加载sdata[tid]。然后每个线程加载sdata[tid+16]。所以此时,线程 0-15 已经加载了它们自己的值,以及来自位置 16-31 的值。线程 16-31 已经加载了它们自己的值,以及来自位置 32-47 的值。然后所有 32 个线程执行加法,然后所有 32 个线程执行存储操作。因此,同样从位置 32 获取值的线程 16 直到 位置 16 的前一个值被消耗(在本例中为线程 0)之前,才更新位置 16 的值。所以此时线程 16-31 的行为对线程 0 的计算值没有影响。

    • 我们可以重复上面的过程,证明对于每个偏移量,索引在偏移量或以上的线程对线程0的计算没有影响。

    【讨论】:

    • 非常感谢您的解释。我主要关心的是结果计算。为了获得最终结果,在非展开循环版本中,thread0 会将 sdata[16] 中的值添加到 sdata[0] 中的值。在这种情况下,sdata[16] 的值已由 sdata[64] 和 sdata[32] 相加。但在展开循环版本中,sdata[16] 的值将由 sdata[32]...sdata[1] 添加。因此添加 sdata[0]+sdata[16] 在两个版本中会产生不同的结果。
    • 我知道定义了线程执行顺序,但可能的情况之一是提到的场景。
    猜你喜欢
    • 1970-01-01
    • 2011-03-23
    • 1970-01-01
    • 1970-01-01
    • 2014-04-21
    • 1970-01-01
    • 2017-05-07
    • 2011-08-18
    • 2014-12-01
    相关资源
    最近更新 更多