关联性使我们具有可并行性。但是,交换性给了什么?

时间:2016-02-16 21:32:43

标签: math parallel-processing cpu compiler-optimization cpu-architecture

亚历山大·斯捷潘诺夫在他的一个brilliant lectures at A9(强烈推荐,顺便说一句)中注意到关联属性为我们提供了可并行性 - 这些编译器现在是一个非常有用和重要的特性, CPU和程序员自己可以利用:

// expressions in parentheses can be done in parallel
// because matrix multiplication is associative
Matrix X = (A * B) * (C * D);

可交换属性给我们带来了什么?重新排序?乱序执行?

2 个答案:

答案 0 :(得分:9)

一些架构,x86是一个主要的例子,有一些指令,其中一个源也是目的地。如果在操作后仍需要目标的原始值,则需要额外的指令将其复制到另一个寄存器。

交换操作使您(或编译器)可以选择将哪个操作数替换为结果。例如,compiling (with gcc 5.3 -O3 for x86-64 Linux calling convention):

// FP: a,b,c in xmm0,1,2.  return value goes in xmm0
// Intel syntax ASM is  op  dest, src
// sd means Scalar Double (as opposed to packed vector, or to single-precision)
double comm(double a, double b, double c) { return (c+a) * (c+b); }
    addsd   xmm0, xmm2
    addsd   xmm1, xmm2
    mulsd   xmm0, xmm1
    ret
double hard(double a, double b, double c) { return (c-a) * (c-b); }
    movapd  xmm3, xmm2    ; reg-reg copy: move Aligned Packed Double
    subsd   xmm2, xmm1
    subsd   xmm3, xmm0
    movapd  xmm0, xmm3
    mulsd   xmm0, xmm2
    ret
double easy(double a, double b, double c) { return (a-c) * (b-c); }
    subsd   xmm0, xmm2
    subsd   xmm1, xmm2
    mulsd   xmm0, xmm1
    ret

x86还允许使用内存操作数作为源,因此您可以将加载折叠到ALU操作中,例如addsd xmm0, [my_constant]。 (使用具有内存目标的ALU操作很糟糕:它必须执行读 - 修改 - 写操作。)交换操作为执行此操作提供了更多的空间。

x86' s 扩展(在Sandybridge,2011年1月)添加了使用向量寄存器的每个现有指令的非破坏性版本(相同的操作码但具有多字节VEX前缀替换所有先前的前缀和转义字节)。其他指令集扩展(如BMI/BMI2)也使用VEX编码方案引入3操作数非破坏性整数指令,如PEXT r32a, r32b, r/m32Parallel extract of bits from r32b using mask in r/m32. Result is written to r32a

AVX还将向量扩展到256b并添加了一些新指令。不幸的是,它无处不在,甚至Skylake Pentium / Celeron CPU也不支持它。在发布假定AVX支持的二进制文件之前,它将是一段很长的时间。 :(

-march=native添加到上面godbolt链接中的编译选项,以查看AVX允许编译器仅使用3条指令,即使hard()也是如此。 (godbolt在Haswell服务器上运行,因此包括AVX2和BMI2):

double hard(double a, double b, double c) { return (c-a) * (c-b); }
        vsubsd  xmm0, xmm2, xmm0
        vsubsd  xmm1, xmm2, xmm1
        vmulsd  xmm0, xmm0, xmm1
        ret

答案 1 :(得分:9)

这是一个更抽象的答案,较少强调指令级并行性,更多关注线程级并行性。

并行性的一个共同目标是减少信息。一个简单的例子是两个数组的点积

for(int i=0; i<N; i++) sum += x[i]*[y];

如果操作是关联的,那么我们可以让每个线程计算一个部分和。然后,最后的总和是每个部分和的总和。

如果操作是可交换的,则可以按任何顺序完成最终总和。否则,部分总和必须按顺序求和。

一个问题是我们不能让多个线程同时写入最终总和,否则会产生竞争条件。因此,当一个线程写入最终总和时,其他线程必须等待。因此,以任何顺序求和都可以更有效,因为通常很难让每个线程按顺序完成。

让我们选择一个例子。假设有两个线程,因此有两个部分和。

如果操作是可交换的,我们就可以有这种情况

thread2 finishes its partial sum
sum += thread2's partial sum
thread2 finishes writing to sum   
thread1 finishes its partial sum
sum += thread1's partial sum

但是如果操作没有通勤,我们就必须这样做

thread2 finishes its partial sum
thread2 waits for thread1 to write to sum
thread1 finishes its partial sum
sum += thread1's partial sum
thread2 waits for thread1 to finish writing to sum    
thread1 finishes writing to sum   
sum += thread2's partial sum

以下是使用OpenMP

的点积的示例
#pragma omp parallel for reduction(+: sum)
for(int i=0; i<N; i++) sum += x[i]*[y];

reduction子句假定操作(在这种情况下为+)是可交换的。大多数人认为这是理所当然的。

如果操作不是可交换的,我们就必须做这样的事情

float sum = 0;
#pragma omp parallel
{
    float sum_partial = 0 
    #pragma omp for schedule(static) nowait
    for(int i=0; i<N; i++) sum_partial += x[i]*[y];
    #pragma omp for schedule(static) ordered
    for(int i=0; i<omp_get_num_threads(); i++) {
        #pragma omp ordered
        sum += sum_partial;
    }
}

nowait子句告诉OpenMP不要等待每个部分和完成。 ordered子句告诉OpenMP只按增加的线程数顺序写入sum

此方法线性地进行最终求和。但是,可以在log2(omp_get_num_threads())步骤中完成。

例如,如果我们有四个线程,我们可以在三个连续步骤中进行减少

  1. 并行计算四个部分和:s1, s2, s3, s4
  2. 并行计算:s5 = s1 + s2使用thread1,s6 = s3 + s4使用thread2
  3. 使用thread1
  4. 计算sum = s5 + s6

    这是使用reduction子句的一个优点,因为它是一个黑盒子,它可以减少log2(omp_get_num_threads())步骤。 OpenMP 4.0允许定义自定义缩减。但是,它仍然假设操作是可交换的。所以这对于例如链矩阵乘法。我不知道OpenMP在操作不通勤时减少log2(omp_get_num_threads())步骤的简单方法。