在Cuda中实现最大值约简

11

我一直在学习Cuda,目前还在逐步掌握并行处理。我现在遇到的问题是如何在值数组上实现最大化约简操作。以下是我的内核:

__global__ void max_reduce(const float* const d_array,
                     float* d_max,
                     const size_t elements)
{
    extern __shared__ float shared[];

    int tid = threadIdx.x;
    int gid = (blockDim.x * blockIdx.x) + tid;

    if (gid < elements)
        shared[tid] = d_array[gid];
    __syncthreads();

    for (unsigned int s=blockDim.x/2; s>0; s>>=1) 
    {
        if (tid < s && gid < elements)
            shared[tid] = max(shared[tid], shared[tid + s]);
        __syncthreads();
    }

    if (gid == 0)
        *d_max = shared[tid];
}

我使用相同的方法(将最大值函数替换为最小值函数)实现了最小值约简,并且运行良好。

为了测试内核,我使用串行for循环找到了最小值和最大值。在内核中,最小值和最大值始终相同,但只有最小值约简匹配。

我是否漏掉了什么明显的问题/操作不当?


8
对于最大值应该将共享内存初始化为 -FLOAT_MAX ,对于最小值应该将共享内存初始化为 FLOAT_MAX。 - Pavan Yalamanchili
@PavanYalamanchili 全局数组正在填充共享内存,是否需要在其中放置-FLOAT_MAX?此外,由于某种原因,并行函数返回的最大值比串行最大值要小。 - CurtisJC
1
在最后一个块中,会有一些未设置的共享内存元素(当gid >= elements时)。这将导致问题。 - Pavan Yalamanchili
@PavanYalamanchili(关于我删除的回答)- 所以我需要在内核之后同步设备,然后启动另一个内核来合并结果? - CurtisJC
1
您不必同步设备,内核按顺序排队。 - Pavan Yalamanchili
2个回答

21

你在删除的答案中得出的主要结论是正确的:你发布的内核没有意识到在内核执行结束时,你已经完成了整体减少的大部分工作,但结果并不完全。每个块的结果必须被组合起来(以某种方式)。正如评论中指出的那样,你的代码还有一些其他问题。让我们看看修改后的版本:

__device__ float atomicMaxf(float* address, float val)
{
    int *address_as_int =(int*)address;
    int old = *address_as_int, assumed;
    while (val > __int_as_float(old)) {
        assumed = old;
        old = atomicCAS(address_as_int, assumed,
                        __float_as_int(val));
        }
    return __int_as_float(old);
}


__global__ void max_reduce(const float* const d_array, float* d_max, 
                                              const size_t elements)
{
    extern __shared__ float shared[];

    int tid = threadIdx.x;
    int gid = (blockDim.x * blockIdx.x) + tid;
    shared[tid] = -FLOAT_MAX;  // 1

    if (gid < elements)
        shared[tid] = d_array[gid];
    __syncthreads();

    for (unsigned int s=blockDim.x/2; s>0; s>>=1) 
    {
        if (tid < s && gid < elements)
            shared[tid] = max(shared[tid], shared[tid + s]);  // 2
        __syncthreads();
    }
    // what to do now?
    // option 1: save block result and launch another kernel
    if (tid == 0)        
        d_max[blockIdx.x] = shared[tid]; // 3
    // option 2: use atomics
    if (tid == 0)
      atomicMaxf(d_max, shared[0]);
}
  1. 正如Pavan所述,您需要初始化共享内存数组。 如果gridDim.x * blockDim.x大于elements,则最后启动的块可能不是“完整”块。
  2. 请注意,在此行中,尽管我们正在检查操作线程(gid)是否小于elements,但当我们将添加到gid用于索引进入共享内存,我们仍然可以在最后一个块中超出合法值复制到共享内存之外进行索引。 因此,我们需要注意1中指示的共享内存初始化。
  3. 正如您已经发现的那样,您的最后一行是不正确的。 每个块都会产生自己的结果,我们必须以某种方式将它们组合起来。 如果启动的块数较小(稍后会详细说明),您可以考虑使用原子操作之一。 通常我们不建议人们使用原子操作,因为它们在执行时间方面是“昂贵”的。 但是,我们面临的另一个选择是将块结果保存在全局内存中,完成内核,然后可能启动另一个内核来合并各个块结果。 如果我最初启动了大量块(比如超过1024个),如果我按照这种方法进行操作,我可能最终会启动两个附加内核。 因此考虑原子操作。 如上所示,对于浮点数没有本地atomicMax函数,但是正如文档中所述,您可以使用atomicCAS生成任何任意的原子函数,并且我已经在atomicMaxf中提供了一个示例,该示例为float提供原子最大值。

但是运行1024个或更多个原子函数(每个块一个)是否是最好的方法? 可能不是。

在启动线程块的内核时,我们实际上只需要启动足够的线程块来使机器保持繁忙状态。 作为经验法则,我们希望每个SM上至少有4-8个warp正在操作,稍微多一些可能是一个好主意。 但是从机器利用率的角度来看,最初启动数千个线程块并没有特别的好处。 如果我们选择像每个SM 8个线程块这样的数字,而我们的GPU最多只有14-16个SM,这给我们了一个相对较小的线程块数8 * 14 = 112个。 让我们选择128(8 * 16)作为一个漂亮的圆整数字。 这并没有什么神奇之处,只是足以使GPU保持繁忙状态。 如果我们让这128个线程块做额外的工作来解决整个问题,那么我们就可以利用原子操作而不必支付太高的代价,并避免多次内核启动。 那么这会是什么样子呢?

__device__ float atomicMaxf(float* address, float val)
{
    int *address_as_int =(int*)address;
    int old = *address_as_int, assumed;
    while (val > __int_as_float(old)) {
        assumed = old;
        old = atomicCAS(address_as_int, assumed,
                        __float_as_int(val));
        }
    return __int_as_float(old);
}


__global__ void max_reduce(const float* const d_array, float* d_max, 
                                              const size_t elements)
{
    extern __shared__ float shared[];

    int tid = threadIdx.x;
    int gid = (blockDim.x * blockIdx.x) + tid;
    shared[tid] = -FLOAT_MAX; 

    while (gid < elements) {
        shared[tid] = max(shared[tid], d_array[gid]);
        gid += gridDim.x*blockDim.x;
        }
    __syncthreads();
    gid = (blockDim.x * blockIdx.x) + tid;  // 1
    for (unsigned int s=blockDim.x/2; s>0; s>>=1) 
    {
        if (tid < s && gid < elements)
            shared[tid] = max(shared[tid], shared[tid + s]);
        __syncthreads();
    }

    if (tid == 0)
      atomicMaxf(d_max, shared[0]);
}

使用这个修改过的内核,在创建内核启动时,我们不是根据整个数据大小(elements)来决定启动多少个线程块,而是启动一定数量的块(比如说128个,您可以修改此数字以找出运行最快的),并让每个线程块(因此整个网格)在共享内存中循环,对每个元素进行部分最大操作。然后,在标有注释1的行中,我们必须将gid变量重新设置为其初始值。这实际上是不必要的,如果我们保证网格的大小(gridDim.x*blockDim.x)小于elements,则可以进一步简化块约简循环代码,在内核启动时很容易做到这一点。

请注意,使用这种原子方法时,有必要将结果(在这种情况下是*d_max)初始化为适当的值,如-FLOAT_MAX

再次说明,通常我们不建议使用原子操作,但在这种情况下,如果我们仔细管理它,并且它允许我们节省额外的内核启动开销,那么考虑使用它是值得的。

要了解如何进行快速并行约简的忍者级别分析,请查看Mark Harris的优秀白皮书,该白皮书可与相关CUDA示例一起使用。


非常全面的回答,我可以看出你在重新编写我的代码方面付出了很多努力!我现在开始更好地理解并行处理了。 - CurtisJC
其实我认为你做得差不多了。我回答中的大部分代码都是你写的。 - Robert Crovella
1
在第一个代码片段中,将 (gid < elements) 的检查放在 for 循环外面,这样不更有用吗?我只是想确保我理解得正确。 - Roxana Istrate
也许你应该提出一个新问题。只有这样的更改,你就可能遇到非法使用__syncthreads()的可能性。 - Robert Crovella

-1

这个看起来很天真,但实际上并不是。这个方法不能推广到其他函数,比如sum(),但对于min()max()非常有效。

__device__ const float float_min = -3.402e+38;

__global__ void maxKernel(float* d_data)
{ 
    // compute max over all threads, store max in d_data[0]
    int i = threadIdx.x;
    __shared__ float max_value;

    if (i == 0) max_value = float_min;
    float v = d_data[i];
    __syncthreads();

    while (max_value < v) max_value = v;

    __syncthreads();
    if (i == 0) d_data[0] = max_value;
}

没错,只在初始化后同步一次,在写入结果前再同步一次。该死的竞争条件!全速前进!

在告诉我它行不通之前,请先试一试。我已经进行了彻底的测试,并且它每次都可以在各种任意内核大小上正常工作。事实证明,在这种情况下,竞争条件并不重要,因为 while 循环可以解决它。

它比传统的约简方法快得多。另一个惊喜是,对于内核大小为 32 的情况,平均通过次数为 4。没错,就是 (log(n)-1),这似乎是违反直觉的。这是因为竞争条件给了好运的机会。这个奖励除了消除传统约简的开销外,还额外增加了。

对于更大的 n,每个 warp 至少需要一次迭代,但该迭代仅涉及一个比较操作,当 max_value 处于分布的高端时,通常立即为 false。您可以修改它以使用多个 SM,但这将大大增加总工作量并增加通信成本,因此不太可能有所帮助。

为了简洁起见,我省略了大小和输出参数。大小只是线程数(可以是 137 或任何您喜欢的数字)。输出在 d_data[0] 中返回。

我已将工作文件上传到这里:https://github.com/kenseehart/YAMR


如果 max(d_data) < 0 会发生什么?好的注意点,这不在我的用例中,所以忽略了。我已经更新了 YAMR(请参见链接),使用 const float float_min = -3.402e+38; 而不是 0.0f。 - Ken Seehart
如果要减少的输入值超过1024个会发生什么? 我会进行迭代。使用通常的reduce模式会导致效率不佳,但由于max偏向序列化,所以我认为迭代是好的选择。这个想法是,如果你从1024个值中开始,第二次迭代只会有一个命中,然后迅速减少。 - Ken Seehart
但我的用例不是IO绑定的,因为我计算最大值的数据在同一内核中早先就已经计算出来了。如果您的情况是IO绑定的,并且您没有其他SM的工作要做,那么您可能需要进行某种网格妥协。只需记住,max偏向于某些串行化,因此您希望尽可能使比较返回false。这就是为什么传统的网格缩减可能是次优的原因。 - Ken Seehart
我应该提到竞态条件的考虑比我所表明的要更加微妙。有关详细信息,请参阅我的答案中的github链接。 - Ken Seehart
不确定您是如何遇到404错误的。对我来说,它可以正常工作:https://github.com/kenseehart/YAMR - Ken Seehart
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接