如何自动矢量化循环,其中 1) 修改数组,2) 指示数组最后是否更改?

问题描述

我有这个 C++ 函数

select table_1.id,table_1.A,case    
    when table_1.A = data1,then show columns B and C
    when table_1.A = data2,then show columns D,E and F
    when table_1.A = data3,then show columns B,C,D,E and F
    when table_1.A = data4,then show no columns
from table_1,...,table_6
where
    table_1.id = table2.id and
    table_1.id = table3.id and
    table_1.id = table4.id and
    table_1.id = table5.id and
    table_1.id = table6.id and
    table1.A = <one of data1/data2/data3/data4>

本质上,它是位向量 (#include <stddef.h> typedef unsigned long long Word; bool fun(Word *lhs,const Word *rhs,size_t s) { bool changed = false; #pragma omp simd for (size_t i = 0; i < s; ++i) { const Word old = lhs[i]; lhs[i] |= rhs[i]; changed = changed || old != lhs[i]; } return changed; } ) 的按位或实现。我对编写具有 SIMD 意识的代码很陌生,我无法弄清楚如何让编译器在不引入额外开销的情况下对其进行矢量化(例如,使 lhs |= rhs 成为一个数组,然后对其进行循环)。删除 changed 行可以让一切都很好地进行矢量化。

我尝试过使用 changed = ... 和不使用。我认为这无关紧要,但我想保留它,因为 omp simdlhs 永远不会重叠,我想最终添加 rhs 子句。

目前,我正在使用 GCC,但我希望最终能够与 GCC 和 Clang 一起工作。

解决方法

TL:DR:使用 Word unchanged = -1ULL; 并用 unchanged &= (old == lhs[i]) ? -1ULL : 0; 更新它,因此这自然映射到 SIMD 比较相等和 SIMD AND。

或者更好的是,changed |= old ^ lhs[i]; 使用 GCC 和 clang 很好地矢量化,对于 Word changed = 0;。使用 clang,它提供了最佳的 asm。对于 GCC,第一种方法更好,因为 GCC 认为 changed |= (~old) & rhs[i]; // find RHS bits that weren't already set 会花费额外的 movdqa 寄存器副本,或者 AVX 移除了将未对齐的加载折叠到 vpor 的内存源的能力(因为它需要两者操作数两次,一次用于 this,一次用于主 |)。

比较不相等直到 AVX-512 才直接可用;这样做必须在组合成 changed 向量之前反转比较结果。


整个操作可以像编写的那样使用内在函数(或 asm)手动矢量化,无需任何重大转换,当然优化为按位 | OR 而不是实际的短路评估。所以这基本上是一个错过的优化。 但是在自然的 asm 实现中,您的 changed 元素向量将与数据具有相同的宽度,而不仅仅是 4 bool 秒。(对于 x86,这将需要一个额外的 vmovmskpd 来提供一个标量 or 而不仅仅是一个 SIMD vpor,而且大多数 ISA 没有移动掩码操作,所以也许通用向量化器甚至没有考虑使用它。有趣的事实:clang 对原始代码的自动矢量化非常糟糕,每次迭代都进行水平 OR 到标量 bool。)

使用 Word changed = 0; 可以相当体面地进行矢量化,使用 changed |= ...,使用或不使用 OpenMP pragma(不同的是,还没有确定哪个实际上更适合每个组合) .编译器是愚蠢的(复杂的机器部分,不是人类理解的)并且通常不会自己解决这样的事情 - 自动矢量化非常困难,他们有时需要一些手动操作。

所以诀窍是使 changed 与数组元素的宽度相同。


如果您使用 OpenMP,您需要告诉 OpenMP 向量化器有关缩减的信息,例如带有 + 的数组的总和,或者在这种情况下为 OR。在这种情况下,#pragma omp simd reduction(|:changed)。如果您希望将其矢量化为无分支 SIMD,您应该使用 changed |= stuff 而不是逻辑短路 eval。 reduction(|:changed) 实际上似乎在某种程度上覆盖了您的实际代码,因此请注意它是否匹配。

如果您只使用 #pragma omp simd https://godbolt.org/z/bG98Kz,ICC 甚至会破坏您的代码(不会在 SIMD 部分更新更改)。 (也许这允许它忽略串行依赖项,或者至少是减少,你没有告诉它?无论是那个还是 ICC 错误,我不太了解 OpenMP。)


使用原始的 bool changed 而不是 Word,GCC 根本不会自动矢量化,并且 clang 做了一个令人讨厌的工作(在内部循环中水平减少到标量 bool !)


自动矢量化的两个版本:

On Godbolt-O3 -march=nehalem -mtune=skylake -fopenmp(因此使用 SSE4.1 / 4.2,但不使用 AVX 或 BMI1/BMI2)。我还没有详细研究哪些清理代码不那么笨重。

#include <stddef.h>
typedef unsigned long long Word;

bool fun_v1(Word *lhs,const Word *rhs,size_t s)
{
    Word changed = 0;
    #pragma omp simd reduction(|:changed)  // optional,some asm differences with/without
    for (size_t i = 0; i < s; ++i) {
        const Word old = lhs[i];
        changed |= (~old) & rhs[i];   // find RHS bits that weren't already set. pure bitwise,no 64-bit-element SIMD == needed.  Do this before storing so compiler doesn't have to worry about lhs/rhs overlap.
        lhs[i] |= rhs[i];
        //changed |= (old != lhs[i]) ? -1ULL : 0;    // requires inverting the cmpeq result,but can fold a memory operand with AVX unlike the bitwise version

        //changed = changed || (old != lhs[i]);    // short circuit eval is weird for SIMD,compiles inefficiently.
    }

    return changed;
}

(update: changed |= old ^ lhs[i]; 看起来更好 在 not-equal 上得到一个非零值。它只使用交换操作,不需要 == / pcmpeqq。@chtz 在评论中建议了这一点,我没有重写其余的答案,以减少对更糟糕的 optoins 的讨论。clang 将使用它自动矢量化,并且使用 AVX 允许 rhs 的内存源操作数,因为它只需要一次. https://godbolt.org/z/ex5519。所以这似乎是两全其美的。)

对于没有 AVX 的 GCC 10.2,

changed |= (old != lhs[i]) ? -1ULL : 0; 在内循环中也仍然只有 10 条指令(9 uop),与 changed |= (~old) & rhs[i]; 相同。但是对于 clang,这会打败自动矢量化! Clang 将处理 changed |= (old != lhs[i]);(或使用显式 ? 1 : 0),所以这很奇怪。 -1ULL 避免了需要 set1_epi64x(1) 向量常量,所以我使用了它。

使用 ==!= 的版本将需要 SSE4.1 pcmpeqq 来对 == 进行 64 位比较的矢量化:编译器可能不够智能意识到任何整数元素大小都适合整体。模拟一个更窄的比较可能看起来不会有利可图。

~old & rhs[i] 方式仅适用于 SSE2。用 SSE4.1 ptest 而不是 shuffles 和 POR 和 MOVQ 来结束循环会更有效,但编译器对这样的东西非常愚蠢。 (并一般处理循环的结尾。 只是简单的减少,并对奇数元素进行标量清理,而不是在数组末尾结束的可能重叠的最终向量。 |= 是幂等的,所以在最坏的情况下,如果您没有很好地安排加载,它会导致存储转发停顿。这是您可以通过手动矢量化做得更好的另一件事,但是使用内在函数会强制使用一个 SIMD 向量宽度,而 auto-vec 允许编译器在您为 AVX2 CPU 编译时使用更宽的向量,例如 -march=haswell-march=znver2 .)


在 AVX-512 之前,只能比较 == (或 >),不能直接比较 !=。为了按照我们想要的方式减少这种情况,我们需要unchanged &= (old == updated);。这让 GCC 在循环中保存 1 条指令,将其减少到 9 条指令,8 uop。它可能每 2 个周期运行 1 次迭代。

但是由于某种原因,clang 根本不会自动矢量化它。显然,clang 不喜欢此处或其他版本中的 ? -1 : 0 三元,也许没有意识到 SIMD 比较产生的结果。

bool fun_v2(Word *lhs,size_t s)
{
    Word unchanged = -1ULL;
// clang fails to vectorize?!?  GCC works as expected with/without pragma
    #pragma omp simd reduction(&:unchanged)
    for (size_t i = 0; i < s; ++i) {
        const Word old = lhs[i];
        lhs[i] |= rhs[i];
        unchanged &= (old == lhs[i]) ? -1ULL : 0;
    }
    return !unchanged;
}

在 AVX 可用的情况下,如果编译器不使用愚蠢的索引寻址模式,则带有内存源操作数的 vpor 将是有效的,这会迫使它在 Intel Sandybridge 系列(但不是在 AMD 上)上取消层压。


请注意,如果您考虑使用 Word 作为宽类型以将其用于其他类型的任意数据,请注意严格别名规则和未定义行为。手动矢量化可能是一个不错的选择,因为 _mm_loadu_si128((const __m128*)int_ptr); 是完全严格别名安全的:矢量指针(和加载/存储内部函数)就像 char* 一样,因为它们可以为任何东西设置别名。对于便携式版本,请使用 memcpy 或 GNU C typedef unsigned long unaligned_aliasing_chunk __attribute__((may_alias,aligned(1)))。 “Word”在 asm 中对于不同的 ISA 具有不同的含义,例如在 x86 中为 16 位,因此对于您想要的类型而言,它不是最好的名称,因为机器可以有效地使用它。 unsigned long 通常是这样,但在某些 64 位机器上是 32 位的。 unsigned long long 可能没问题。