在AVX2指令中没有快速聚集和分散的情况下该怎么办?

时间:2018-07-02 00:41:26

标签: algorithm performance optimization simd avx2

我正在编写一个程序来检测素数。一部分是筛选可能的候选人。我写了一个相当快的程序,但我想我想看看是否有人有更好的主意。我的程序可以使用一些快速的收集和分散指令,但是我仅限于用于x86架构的AVX2硬件(我不确定AVX-512具有这些,尽管我不确定它们的速度如何)。

#include <stdint.h>
#include <immintrin.h>

#define USE_AVX2

// Sieve the bits in array sieveX for later use
void sieveFactors(uint64_t *sieveX)
{
    const uint64_t totalX = 5000000;
#ifdef USE_AVX2
    uint64_t indx[4], bits[4];

    const __m256i sieveX2 = _mm256_set1_epi64x((uint64_t)(sieveX));
    const __m256i total = _mm256_set1_epi64x(totalX - 1);
    const __m256i mask = _mm256_set1_epi64x(0x3f);

    // Just filling with some typical values (not really constant)
    __m256i ans = _mm256_set_epi64x(58, 52, 154, 1);
    __m256i ans2 = _mm256_set_epi64x(142, 70, 136, 100);

    __m256i sum = _mm256_set_epi64x(201, 213, 219, 237);    // 3x primes
    __m256i sum2 = _mm256_set_epi64x(201, 213, 219, 237);   // This aren't always the same

    // Actually algorithm can changes these
    __m256i mod1 = _mm256_set1_epi64x(1);
    __m256i mod3 = _mm256_set1_epi64x(1);

    __m256i mod2, mod4, sum3;

    // Sieve until all factors (start under 32-bit threshold) exceed the limit
    do {
        // Sieve until one of the factors exceeds the limit
        do {
            // Compiler does a nice job converting these into extracts
            *(__m256i *)(&indx[0]) = _mm256_add_epi64(_mm256_srli_epi64(_mm256_andnot_si256(mask, ans), 3), sieveX2);
            *(__m256i *)(&bits[0]) = _mm256_sllv_epi64(mod1, _mm256_and_si256(mask, ans));

            ans = _mm256_add_epi64(ans, sum);

            // Early on these locations can overlap
            *(uint64_t *)(indx[0]) |= bits[0];
            *(uint64_t *)(indx[1]) |= bits[1];
            *(uint64_t *)(indx[2]) |= bits[2];
            *(uint64_t *)(indx[3]) |= bits[3];

            mod2 = _mm256_sub_epi64(total, ans);

            *(__m256i *)(&indx[0]) = _mm256_add_epi64(_mm256_srli_epi64(_mm256_andnot_si256(mask, ans2), 3), sieveX2);
            *(__m256i *)(&bits[0]) = _mm256_sllv_epi64(mod3, _mm256_and_si256(mask, ans2));

            ans2 = _mm256_add_epi64(ans2, sum2);

            // Two types of candidates are being performed at once
            *(uint64_t *)(indx[0]) |= bits[0];
            *(uint64_t *)(indx[1]) |= bits[1];
            *(uint64_t *)(indx[2]) |= bits[2];
            *(uint64_t *)(indx[3]) |= bits[3];

            mod4 = _mm256_sub_epi64(total, ans2);
        } while (!_mm256_movemask_pd(_mm256_castsi256_pd(_mm256_or_si256(mod2, mod4))));

        // Remove one factor
        mod2 = _mm256_castpd_si256(_mm256_blendv_pd(_mm256_setzero_pd(), _mm256_castsi256_pd(sum), _mm256_castsi256_pd(mod2)));
        mod4 = _mm256_castpd_si256(_mm256_blendv_pd(_mm256_setzero_pd(), _mm256_castsi256_pd(sum2), _mm256_castsi256_pd(mod4)));
        ans = _mm256_sub_epi64(ans, mod2);
        ans2 = _mm256_sub_epi64(ans2, mod4);
        sum = _mm256_sub_epi64(sum, mod2);
        sum2 = _mm256_sub_epi64(sum2, mod4);
        sum3 = _mm256_or_si256(sum, sum2);
     } while (!_mm256_testz_si256(sum3, sum3));
#else
     // Just some example values (not really constant - compiler will optimize away code incorrectly)
     uint64_t cur = 58;
     uint64_t cur2 = 142;
     uint64_t factor = 67;

     if (cur < cur2) {
        std::swap(cur, cur2);
    }
    while (cur < totalX) {
        sieveX[cur >> 6] |= (1ULL << (cur & 0x3f));
        sieveX[cur2 >> 6] |= (1ULL << (cur2 & 0x3f));
        cur += factor;
        cur2 += factor;
    }
    while (cur2 < totalX) {
        sieveX[cur2 >> 6] |= (1ULL << (cur2 & 0x3f));
        cur2 += factor;
    }
#endif
}

请注意,这些位置起初可能会重叠。在循环中短暂停留后,情况并非如此。如果可能的话,我很乐意使用其他方法。在算法的这一部分中,大约有82%的时间在此循环中。希望这与其他发布的问题不太接近。

1 个答案:

答案 0 :(得分:4)

我只是看了您在这里所做的事情:对于mod1 = mod3 = _mm256_set1_epi64x(1);,您只是在位图中将ans的元素作为索引来设置单个位。

使用mod1 << ansmod3 << ans2将ans和ans2并行运行,然后将其展开两个。注释您的代码,并使用英文文本解释大图中发生的事情!这只是Eratosthenes常规筛网的位设置循环的非常复杂的实现。 (因此,如果问题首先是这样说,那就太好了。)

并行展开具有多个起始/跨步的展开是一个非常好的优化,因此通常在L1d中仍处于高温状态的高速缓存行中设置多个位。 一次阻止较少不同因素的缓存阻塞具有相似的优势。在移至下一个之前,重复遍历相同的8kiB或16kiB内存块多个因素(跨度)。为2个不同的步幅分别展开4个偏移量可能是创建更多ILP的好方法。

但是,并行运行的步幅越多,第一次触摸新的缓存行时就越慢。 (提供高速缓存/ TLB预取空间可避免最初的停顿)。因此,缓存阻止并不能消除多步的所有好处。


迈步<256的特殊情况

单个256位向量加载/ VPOR /存储可以设置多个位。诀窍是创建一个矢量常量或一组矢量常量,并将位放在正确的位置。不过,重复模式的长度约为LCM(256, bit_stride)位。例如,stride = 3将以3个向量长的模式重复。除非有更聪明的方法,否则这很快就变得无法用于奇数/素数的大步走了:(

64位标量很有趣,因为可以使用按位旋转来创建一系列模式,但是在SnB系列CPU上进行可变计数旋转需要2 uop。

您可能会做更多的事情;也许未对齐的负载可能会有所帮助。

即使对于大步幅情况,例如,重复的位掩码图案也可能有用。每次旋转stride % 8。但这会更有用,如果您正在JIT循环一个将模式硬编码为or byte [mem], imm8的循环,并且将展开因子选择为与重复长度一致。


通过更小的负载/存储减少冲突

仅设置一个位时,您不必加载/修改/存储64位块。您的RMW操作范围越窄,位索引就可以越接近而不会发生冲突。

(但是您在同一位置没有长循环的dep链;您将继续前进,直到OoO exec停顿,等待长链末端的重新加载。因此,如果冲突不是正确的话问题,不太可能在这里产生很大的性能差异。与位图直方图或其他可能容易在附近位上产生长串重复命中的事物不同。

32位元素将是显而易见的选择。 x86可以有效地将dword加载/存储到SIMD寄存器以及从标量加载/存储。 (标量字节操作也很有效,但是来自SIMD reg的字节存储总是需要使用pextrb进行多次操作。)

如果您不收集SIMD寄存器,则ans / ans2的SIMD元素宽度不必与RMW宽度匹配。如果您想使用位移或bts隐式地将移位计数屏蔽为32位(对于64位,则使用位移或shlx),则32位RMW与8位相比具有优势,如果您想将位索引按标量划分为地址/位偏移。 64位移位)。但是8位btssieveX不存在。

使用64位SIMD元素的主要优点是,如果您要计算的是指针而不是索引。如果您可以将mmap(..., MAP_32BIT|MAP_ANONYMOUS, ...)限制为32位,则仍然可以执行此操作。例如在Linux上使用idx进行分配。 这是假设您不需要2 ^ 32位(512MiB)的筛网空间,因此您的位索引始终适合32位元素。如果不是这种情况,您仍然可以使用32-比特向量到该点为止,然后将当前循环用于较高部分。

如果您使用32位SIMD元素而不将sieveX限制为32位点指针,则必须放弃使用SIMD指针计算,而只是提取位索引,或者仍然将SIMD拆分为{ {1}} / bit并提取两者。

(对于32位元素,基于存储/重载的SIMD->标量策略看起来更有吸引力,但是在C语言中,这主要取决于编译器。)

如果您手动收集到32位元素中,则无法再使用movhps 。您必须对高3个元素使用pinsrd / pextrd,而那些从来没有微熔丝的/总是需要SnB系列上的port5 uop。 (不同于movhps,这是一个纯存储)。但这意味着vpinsrd的索引寻址模式仍然为2 oups。您仍然可以对元素2使用vmovhps(然后用vpinsrd覆盖向量的顶部双字);未对齐的载荷很便宜,可以覆盖下一个元素。但是您不能做movhps商店,那真的很不错。


您当前的策略存在两个性能问题

Apparently,有时您将mod1mod3的某些元素与0一起使用,从而导致完全无用的工作,[mem] |= 0这些大步前进。

我认为一旦ansans2中的一个元素到达total,您就会掉入内循环并执行ans -= sum 1 < 遍历内循环。您不一定要将其重置回ans = sum(针对该元素)以重做ORing(设置已设置的位),因为该内存在缓存中会很冷。我们真正想要的是将其余仍在使用中的元素打包到已知位置,并输入循环的其他版本,这些版本仅执行7、6、5个元素。然后我们只有1个向量。

这似乎很笨拙。一个元素碰到最后的更好策略可能是用标量完成该向量中的其余三个,一次一个,然后运行剩余的单个__m256i向量。如果所有步幅都在附近,则可能会获得良好的缓存位置。


更便宜的标量,或者也许仍然是SIMD,但仅提取位索引

使用SIMD将位索引拆分为qword索引和位掩码,然后分别提取它们,这对于标量或运算的情况会产生大量的运算量:如此之多,您就不会遇到每1时钟存储吞吐量的瓶颈,即使我在分散/收集答案中进行了所有优化。 (有时,高速缓存未命中可能会减慢此速度,但是较少的前端uops意味着较大的乱序窗口可查找并行性并保持运行中的更多内存操作。)

如果我们可以让编译器制作好的标量代码来拆分位索引,则可以考虑使用纯标量。或者至少仅提取位索引并跳过SIMD移位/掩码内容。

太糟了,标量存储目标bts不够快。 bts [rdi], rax将在位字符串中设置该位,即使该位不在[rdi]选择的双字之外。 (但是,这种疯狂的CISC行为为什么并不快,就像Skylake上的10微秒。)

不过,纯标量可能并不理想。我在玩with this on Godbolt

#include <immintrin.h>
#include <stdint.h>
#include <algorithm>

// Sieve the bits in array sieveX for later use
void sieveFactors(uint64_t *sieveX64, unsigned cur1, unsigned cur2, unsigned factor1, unsigned factor2)
{
    const uint64_t totalX = 5000000;
#ifdef USE_AVX2
//...
#else
     //uint64_t cur = 58;
     //uint64_t cur2 = 142;
     //uint64_t factor = 67;
     uint32_t *sieveX = (uint32_t*)sieveX64;

    if (cur1 > cur2) {
        // TODO: if factors can be different, properly check which will end first
        std::swap(cur1, cur2);
        std::swap(factor1, factor2);
    }
    // factor1 = factor2;  // is this always true?

    while (cur2 < totalX) {
         sieveX[cur1 >> 5] |= (1U << (cur1 & 0x1f));
         sieveX[cur2 >> 5] |= (1U << (cur2 & 0x1f));
         cur1 += factor1;
         cur2 += factor2;
    }
    while (cur1 < totalX) {
         sieveX[cur1 >> 5] |= (1U << (cur1 & 0x1f));
         cur1 += factor1;
    }
#endif
}

请注意,我是如何替换外部的if()以便通过对cur1,cur2进行排序的。

GCC和clang将1放入循环外的寄存器中,并在循环内使用shlx r9d, ecx, esi在单个uop中执行1U << (cur1 & 0x1f),而不会破坏1。 (MSVC使用load / BTS / store,但是它带有很多mov指令,比较笨拙。我不知道如何告诉MSVC它允许使用BMI2。)

如果or [mem], reg的索引寻址模式不花费额外的成本,那就太好了。

问题在于您需要在某处放置shr reg, 5,这是破坏性的。将5放入寄存器中,并使用它来复制+移位位索引对于加载/ BTS /存储来说是理想的设置,但是编译器似乎并不知道这种优化。

最佳(?)标量分割和使用位索引

   mov   ecx, 5    ; outside the loop

.loop:
    ; ESI is the bit-index.
    ; Could be pure scalar, or could come from an extract of ans directly

    shrx  edx, esi, ecx           ; EDX = ESI>>5 = dword index
    mov   eax, [rdi + rdx*4]
    bts   eax, esi                ; set esi % 32 in EAX
    mov   [rdi + rdx*4]


    more unrolled iterations

    ; add   esi, r10d               ; ans += factor if we're doing scalar

    ...
    cmp/jb .loop

因此,给定GP寄存器中的位索引,设置内存中的位为4 ups。请注意,加载和存储都使用mov,因此索引寻址模式对Haswell和更高版本没有影响。

但是,我认为使用shlx / shr / or [mem], reg可以使编译器做到最好。 (在索引寻址模式下,or为3而不是2。)

我认为,如果您愿意使用手写的asm,则可以使用此标量和完全放弃SIMD更快。冲突绝不是正确的问题。

也许您甚至可以让编译器发出可比的结果,但是每个展开的RMW甚至一个额外的uop都是很大的事情。