17

我使用 C++ 和 CUDA/C 并想为特定问题编写代码,但遇到了一个非常棘手的缩减问题。

我在并行编程方面的经验并非微不足道,但非常有限,我不能完全预见这个问题的特殊性。我怀疑是否有一种方便甚至“简单”的方法来处理我面临的问题,但也许我错了。如果有任何资源(即文章、书籍、网络链接……)或涵盖此问题或类似问题的关键字,请告诉我。

我试图尽可能地概括整个案例并保持抽象而不是发布太多代码。

布局 ...

我有一个由 N 个初始元素和 N 个结果元素组成的系统。(例如,我将使用 N=8,但 N 可以是任何大于三的整数值。)

static size_t const N = 8;
double init_values[N], result[N];

我需要计算几乎所有(恐怕不是全部)初始值的唯一排列,而不会产生自干扰。

这意味着计算f(init_values[0],init_values[1]), f(init_values[0],init_values[2]), ..., f(init_values[0],init_values[N-1]), f(init_values[1],init_values[2]), ..., f(init_values[1],init_values[N-1]), ... 等等。

这实际上是一个虚拟三角矩阵,其形状如下图所示。

 P     0    1    2    3    4    5    6    7
   |---------------------------------------
  0|   x 
   |
  1|   0    x
   |
  2|   1    2    x 
   |
  3|   3    4    5    x
   |
  4|   6    7    8    9    x
   |
  5|  10   11   12   13   14    x
   |
  6|  15   16   17   18   19   20    x
   |
  7|  21   22   23   24   25   26   27    x

每个元素都是 中相应列和行元素的函数init_values

P[i] (= P[row(i)][col(i]) = f(init_values[col(i)], init_values[row(i)])

IE

P[11] (= P[5][1]) = f(init_values[1], init_values[5])

使用示例有(N*N-N)/2 = 28可能的独特组合(注意:P[1][5]==P[5][1],所以我们只有一个下(或上)三角矩阵)N = 8

基本问题

结果数组从 P 计算为行元素的总和减去相应列元素的总和。例如,位置 3 的结果将计算为第 3 行的总和减去第 3 列的总和。

result[3] = (P[3]+P[4]+P[5]) - (P[9]+P[13]+P[18]+P[24])
result[3] = sum_elements_row(3) - sum_elements_column(3)

我试图用 N = 4 的图片来说明它。

所需的三角归约方案

因此,以下情况属实:

  • N-1操作(潜在的并发写入)将在每个result[i]
  • result[i]将从减法和加法中N-(i+1)写入i
  • 每次输出P[i][j]都会有一个减法r[j]和一个加法r[i]

这是主要问题出现的地方:

  • 使用一个线程计算每个 P 并直接更新结果将导致多个内核尝试写入相同的结果位置(每个 N-1 个线程)。
  • 另一方面,存储整个矩阵 P 用于后续的归约步骤在内存消耗方面非常昂贵,因此对于非常大的系统来说是不可能的。

为每个线程块拥有一个独特的、共享的结果向量的想法也是不可能的。(50k 的 N 产生 25 亿个 P 元素,因此 [假设每个块最多有 1024 个线程] 如果每个块都有自己的结果数组和 50k 双元素,则最少 240 万个块消耗超过 900GiB 的内存。)

我认为我可以处理更静态行为的减少,但就潜在的并发内存写访问而言,这个问题是相当动态的。(或者是否可以通过一些“基本”类型的减少来处理它?)

增加一些并发症...

不幸的是,根据与初始值无关的(任意用户)输入,需要跳过 P 的某些元素。假设我们需要跳过排列 P[6]、P[14] 和 P[18]。因此,我们还剩下 24 个组合,需要计算。

如何告诉内核需要跳过哪些值?我提出了三种方法,如果 N 非常大(如数万个元素),每种方法都有明显的缺点。

1.存储所有组合...

...具有各自的行和列索引struct combo { size_t row,col; };,需要在 a 中计算vector<combo>并对该向量进行操作。(由当前实现使用)

std::vector<combo> elements;
// somehow fill
size_t const M = elements.size();
for (size_t i=0; i<M; ++i)
{
    // do the necessary computations using elements[i].row and elements[i].col  
}

该解决方案消耗大量内存,因为只有“几个”(甚至可能是一万个元素,但与总共数十亿个元素相比并不多),但它避免了

  • 索引计算
  • 找到移除的元素

对于 P 的每个元素,这是第二种方法的缺点。

2.对P的所有元素进行操作,找到移除的元素

如果我想对 P 的每个元素进行操作并避免嵌套循环(我无法在 cuda 中很好地重现),我需要执行以下操作:

size_t M = (N*N-N)/2;
for (size_t i=0; i<M; ++i)
{
   // calculate row indices from `i`
   double tmp = sqrt(8.0*double(i+1))/2.0 + 0.5;
   double row_d = floor(tmp);
   size_t current_row = size_t(row_d);
   size_t current_col = size_t(floor(row_d*(ict-row_d)-0.5));
   // check whether the current combo of row and col is not to be removed
   if (!removes[current_row].exists(current_col))
   {
     // do the necessary computations using current_row and current_col
   }
}

与第一个示例中的向量相比,该向量removes非常小,elements但是获得 和 if 分支的额外计算current_row效率current_col非常低。(请记住,我们仍在谈论数十亿次评估。)

3.对P的所有元素进行操作,然后删除元素

我的另一个想法是独立计算所有有效和无效的组合。但不幸的是,由于求和错误,以下陈述是正确的:

calc_non_skipped() != calc_all() - calc_skipped()

是否有一种方便的、已知的、高性能的方法可以从初始值中获得所需的结果?

我知道这个问题相当复杂,而且相关性可能有限。尽管如此,我希望一些启发性的答案能帮助我解决我的问题。


目前的实施

目前,这通过 OpenMP 实现为 CPU 代码。我首先设置了一个上述combos 的向量,存储需要计算的每个 P 并将其传递给并行 for 循环。每个线程都有一个私有结果向量,并行区域末尾的临界区用于适当的求和。

4

2 回答 2

6

首先,我很困惑为什么(N**2 - N)/2N=7 产生 27 ...但是对于索引 0-7,N=8,并且 P 中有28个元素。不应该在这么晚的时候尝试回答这样的问题天。:-)

但是关于一个潜在的解决方案:您是否需要保留数组 P 用于任何其他目的?如果不是,我认为您只需两个中间数组即可获得所需的结果,每个数组的长度为 N:一个用于行的总和,一个用于列的总和。

这是我认为您正在尝试做的事情(subroutine direct_approach())以及如何使用中间数组(subroutine refined_approach())实现相同结果的快速而肮脏的示例:

#include <cstdlib>
#include <cstdio>

const int N = 7;
const float input_values[N] = { 3.0F, 5.0F, 7.0F, 11.0F, 13.0F, 17.0F, 23.0F };
float P[N][N];      // Yes, I'm wasting half the array.  This way I don't have to fuss with mapping the indices.
float result1[N] = { 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F };
float result2[N] = { 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F };

float f(float arg1, float arg2)
{
    // Arbitrary computation
    return (arg1 * arg2);
}

float compute_result(int index)
{
    float row_sum = 0.0F;
    float col_sum = 0.0F;
    int row;
    int col;

    // Compute the row sum
    for (col = (index + 1); col < N; col++)
    {
        row_sum += P[index][col];
    }

    // Compute the column sum
    for (row = 0; row < index; row++)
    {
        col_sum += P[row][index];
    }

    return (row_sum - col_sum);
}

void direct_approach()
{
    int row;
    int col;

    for (row = 0; row < N; row++)
    {
        for (col = (row + 1); col < N; col++)
        {
            P[row][col] = f(input_values[row], input_values[col]);
        }
    }

    int index;

    for (index = 0; index < N; index++)
    {
        result1[index] = compute_result(index);
    }
}

void refined_approach()
{
    float row_sums[N];
    float col_sums[N];
    int index;

    // Initialize intermediate arrays
    for (index = 0; index < N; index++)
    {
        row_sums[index] = 0.0F;
        col_sums[index] = 0.0F;
    }

    // Compute the row and column sums
    // This can be parallelized by computing row and column sums
    //  independently, instead of in nested loops.
    int row;
    int col;

    for (row = 0; row < N; row++)
    {
        for (col = (row + 1); col < N; col++)
        {
            float computed = f(input_values[row], input_values[col]);
            row_sums[row] += computed;
            col_sums[col] += computed;
        }
    }

    // Compute the result
    for (index = 0; index < N; index++)
    {
        result2[index] = row_sums[index] - col_sums[index];
    }
}

void print_result(int n, float * result)
{
    int index;

    for (index = 0; index < n; index++)
    {
        printf("  [%d]=%f\n", index, result[index]);
    }
}

int main(int argc, char * * argv)
{
    printf("Data reduction test\n");

    direct_approach();

    printf("Result 1:\n");
    print_result(N, result1);

    refined_approach();

    printf("Result 2:\n");
    print_result(N, result2);

    return (0);
}

并行计算并不是那么容易,因为每个中间值都是大多数输入的函数。您可以单独计算总和,但这意味着执行 f(...) 多次。对于非常大的 N 值,我能想到的最佳建议是使用更多中间数组,计算结果的子集,然后对部分数组求和以产生最终总和。当我不那么累的时候,我不得不考虑那个。

处理跳过问题:如果只是“不使用输入值 x、y 和 z”的简单问题,您可以将 x、y 和 z 存储在 do_not_use 数组中,并在循环计算时检查这些值总和。如果要跳过的值是行和列的某些函数,则可以将它们存储为对并检查对。

希望这能为您提供解决方案的想法!

更新,现在我醒了: 处理“跳过”很大程度上取决于需要跳过哪些数据。第一种情况的另一种可能性——“不要使用输入值 x、y 和 z”——对于大型数据集,一个更快的解决方案是添加一个间接级别:创建另一个数组,这个数组是整数索引,并仅存储良好输入的索引。例如,如果输入 2 和 5 中有无效数据,则有效数组将是:

int valid_indices[] = { 0, 1, 3, 4, 6 };

对数组进行交互valid_indices,并使用这些索引从输入数组中检索数据以计算结果。另一方面,如果要跳过的值取决于 P 数组的两个索引,我看不出如何避免某种查找。

回到并行化——无论如何,您将处理 f() 的 (N**2 - N)/2 次计算。一种可能性是只接受求和数组的争用,如果计算 f() 比两次加法花费的时间长得多,这不会是一个大问题。当您获得大量并行路径时,争用将再次成为一个问题,但应该有一个“最佳点”来平衡并行路径的数量与计算 f() 所需的时间。

如果争用仍然是一个问题,您可以通过多种方式对问题进行分区。一种方法是一次计算一行或一列:对于一次一行,可以独立计算每列总和,并且可以为每一行总和保留一个运行总计。

另一种方法是将数据空间和计算划分为子集,其中每个子集都有自己的行和列总和数组。在计算每个块之后,可以对独立数组求和以产生计算结果所需的值。

于 2013-06-27T03:49:36.980 回答
3

这可能是那些幼稚无用的答案之一,但它也可能有所帮助。随意告诉我,我完全错了,我误解了整个事件。

所以……我们走吧!

基本问题

在我看来,您可以稍微不同地定义结果函数,它至少会消除中间值的一些争论。假设您的P矩阵是下三角形的。如果您(实际上)用较低值的负数(以及全零的主对角线)填充上三角形,那么您可以将结果的每个元素重新定义为单行的总和:(此处显示为 N=4 , where表示标记为)-i的单元格中的值的负数i

 P     0    1    2    3 
   |--------------------
  0|   x   -0   -1   -3
   |
  1|   0    x   -2   -4
   |
  2|   1    2    x   -5
   |
  3|   3    4    5    x

如果您启动独立线程(执行相同的内核)来计算该矩阵每一行的总和,则每个线程将写入一个结果元素。看来您的问题规模大到足以使您的硬件线程饱和并使它们保持忙碌。

当然,需要注意的是,您将分别计算f(x, y)两次。我不知道那是多么昂贵,或者内存争用让你付出了多少代价,所以我无法判断这是否值得权衡。但除非f真的很贵,否则我认为可能是。

跳过值

您提到您可能需要在计算中忽略数以万计的P矩阵元素(实际上跳过它们。)

要使用我上面提出的方案,我相信您应该将跳过的元素存储为(row, col)对,并且您还必须添加每个坐标对的转置(因此您将获得两倍的跳过值。)所以你的例如跳过P[6], P[14] and P[18]成为P(4,0), P(5,4), P(6,3)然后成为的列表P(4,0), P(5,4), P(6,3), P(0,4), P(4,5), P(3,6)​​。

然后你对这个列表进行排序,首先基于行,然后是列。这使我们的列表成为P(0,4), P(3,6), P(4,0), P(4,5), P(5,4), P(6,3).

如果虚拟矩阵的每一行P都由一个线程(或内核的单个实例或其他)处理,您可以将它需要跳过的值传递给它。就个人而言,我会将所有这些存储在一个大的一维数组中,然后传入每个线程需要查看的第一个和最后一个索引(我也不会将行索引存储在我传入的最终数组中,因为它可以可以隐式推断,但我认为这是显而易见的。)在上面的示例中,对于 N = 8,传递给每个线程的开始和结束对将是:(请注意,结束是需要处理的最终值的一个后,只是像 STL,所以一个空列表用 begin == end 表示)

Thread 0: 0..1
Thread 1: 1..1 (or 0..0 or whatever)
Thread 2: 1..1
Thread 3: 1..2
Thread 4: 2..4
Thread 5: 4..5
Thread 6: 5..6
Thread 7: 6..6

现在,每个线程继续计算和求和一行中的所有中间值。在遍历列索引的同时,它也在遍历这个跳过值列表并跳过列表中出现的任何列号。这显然是一个高效而简单的操作(因为列表也是按列排序的。就像合并一样。)

伪实现

我不知道 CUDA,但我有一些使用 OpenCL 的经验,我想这些接口是相似的(因为它们所针对的硬件是相同的。)这是一个内核的实现,它对一行进行处理(即result在伪 C++ 中计算 ) 的一项:

double calc_one_result (
    unsigned my_id, unsigned N, double const init_values [],
    unsigned skip_indices [], unsigned skip_begin, unsigned skip_end
)
{
    double res = 0;
    for (unsigned col = 0; col < my_id; ++col)
        // "f" seems to take init_values[column] as its first arg
        res += f (init_values[col], init_values[my_id]);
    for (unsigned row = my_id + 1; row < N; ++row)
        res -= f (init_values[my_id], init_values[row]);
    // At this point, "res" is holding "result[my_id]",
    // including the values that should have been skipped
    
    unsigned i = skip_begin;
    // The second condition is to check whether we have reached the
    // middle of the virtual matrix or not
    for (; i < skip_end && skip_indices[i] < my_id; ++i)
    {
        unsigned col = skip_indices[i];
        res -= f (init_values[col], init_values[my_id]);
    }
    for (; i < skip_end; ++i)
    {
        unsigned row = skip_indices[i];
        res += f (init_values[my_id], init_values[row]);
    }
    
    return res;
}

请注意以下事项:

  1. 的语义init_values和功能f如问题所述。

  2. 该函数计算result数组中的一项;具体来说,它会计算result[my_id],因此您应该启动它N的实例。

  3. 它写入的唯一共享变量是result[my_id]. 好吧,上面的函数不写任何东西,但是如果你把它翻译成 CUDA,我想你必须在最后写。但是,没有其他人写入 的特定元素result,因此该写入不会导致任何数据争用。

  4. 两个输入数组,init_valuesskipped_indices在此函数的所有运行实例之间共享。

  5. 所有对数据的访问都是线性和顺序的,除了跳过的值,我认为这是不可避免的。

  6. skipped_indices包含应在每一行中跳过的索引列表。它的内容和结构如前所述,稍作优化。由于没有必要,我删除了行号,只留下了列。无论如何,行号将被传递到函数中,并且每次调用应使用my_id的数组切片使用and确定。skipped_indicesskip_beginskip_end

    对于上面的示例,传递给 的所有调用的数组calc_one_result将如下所示[4, 6, 0, 5, 4, 3]

  7. 如您所见,除了循环之外,此代码中唯一的条件分支位于skip_indices[i] < my_id第三个 for 循环中。尽管我相信这是无害且完全可以预测的,但即使是这个分支也可以在代码中轻松避免。我们只需要传入另一个名为的参数skip_middle,它告诉我们跳过的项目在哪里穿过主对角线(即对于第 # 行my_id,索引 atskipped_indices[skip_middle]是第一个大于 的my_id。)

综上所述

我绝不是 CUDA 和 HPC 方面的专家。但是,如果我正确理解了您的问题,我认为这种方法可能会消除所有内存争用。另外,我认为这不会导致任何(更多)数值稳定性问题。

实现这一点的成本是:

  • 总共调用f两倍(并跟踪调用它的时间,row < col以便您可以将结果乘以-1。)
  • 在跳过的值列表中存储两倍的项目。由于此列表的大小为数千(而不是数十亿!),因此应该不是什么大问题。
  • 对跳过的值列表进行排序;这又是由于它的大小,应该没问题。

更新:添加了伪实现部分。)

于 2013-07-01T07:37:23.030 回答