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

How to auto-vectorise a loop which 1) modifies an array, 2) indicates whether the array changed or not at the end?

我有这个 C++ 函数:

#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;
}

本质上,它是位向量(lhs |= rhs)的按位或实现。我对编写支持 SIMD 的代码还很陌生,我不太清楚如何让编译器对其进行矢量化而不引入额外的开销(例如,使 changed 成为一个数组然后循环遍历它)。删除 changed = ... 行可以让一切都很好地向量化。

我试过使用 omp simd 和不使用。我不认为这是相关的,但我想保留它,因为 lhsrhs 从不重叠,我想最终添加 align 子句。

目前,我正在使用 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 的内存源的能力(因为它需要两者操作数两次,一次用于此,一次用于主 |).

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


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

使用 Word changed = 0; 可以相当不错地进行矢量化,使用 changed |= ...,有或没有 OpenMP pragmas(不同的是,还没有弄清楚哪个实际上是每个组合都更好)。编译器是愚蠢的(复杂的机器部件,不是人类的理解)并且通常不会自己弄清楚这样的事情 - 自动矢量化非常困难,他们有时需要一些帮助。

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


如果您使用 OpenMP,您需要告诉 OpenMP 矢量化器关于缩减,例如数组与 + 的总和,或者在本例中为 OR。在这种情况下,#pragma omp simd reduction(|:changed)。如果您希望将其矢量化为无分支 SIMD,您应该使用 changed |= stuff 而不是逻辑短路评估。 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;
}

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

对于没有 AVX 的 GCC 10.2,

changed |= (old != lhs[i]) ? -1ULL : 0; 在内循环中仍然只有 10 条指令(9 微指令),与 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 结束循环而不是洗牌、POR 和 MOVQ 会更有效率,但编译器对这样的事情非常愚蠢。 (并通常处理循环的结尾。 只是对奇数元素进行简单的归约和标量清理,而不是在数组末尾结束的可能重叠的最终向量。 |= 是幂等的,所以在最坏的情况下,如果您没有很好地安排负载,它会导致存储转发停顿。这是您可以通过手动矢量化做得更好的另一件事,但是使用内在函数会强制一个 SIMD 矢量宽度,而 auto-vec 允许编译器在编译 AVX2 CPU 时使用更宽的矢量,例如 -march=haswell-march=znver2.)


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

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

bool fun_v2(Word *lhs, const Word *rhs, 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)))。对于不同的 ISA,“Word”在 asm 中有不同的含义,比如在 x86 中是 16 位的,所以它不是你想要的类型的最佳名称,因为机器可以有效地使用它。 unsigned long 通常是这样,但在某些 64 位机器上是 32 位的。 unsigned long long 可能没问题。