【问题标题】:What do you do without fast gather and scatter in AVX2 instructions?如果没有 AVX2 指令中的快速聚集和分散,你会怎么做?
【发布时间】:2018-12-10 04:47:13
【问题描述】:

我正在编写一个程序来检测素数。一方面是筛选出可能的候选人。我写了一个相当快的程序,但我想我会看看是否有人有更好的想法。我的程序可以使用一些快速收集和分散指令,但我仅限于用于 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% 的时间都在这个循环中。希望这不会太接近其他已发布的问题。

【问题讨论】:

  • 此外,即使使用 AVX512 scatter,可能的重叠(如 cur[0] == cur[1])意味着您必须检查冲突,以便收集 / SIMD OR / scatter 具有与一次执行一个相同的语义。如果您可以在某个点之后排除这种情况,您可能会使用标量或 vpconflictq / 重试直到那时,然后使用不检查冲突的循环。
  • 更新了 gcc 的代码(选项“-mavx2 -O3”),即使我使用的是 MSVC 17。请记住,我“不能”访问 AVX-512 编译器和机器。所以使用 AVX-512 对我没有帮助。我希望能够使用 _mm256_conflict_epi64 和其他高级聚集/分散命令。
  • 代码审查:使用alignas(32) uint64_t *indx[4],这样您就不必强制转换了。 (并且不要用数组大小​​ 0 声明它,除非这是某种 hack,它可以通过跳过堆栈对齐来获得更有效的编译器输出,因为编译器实际上并没有存储/重新加载。uint64_t indx[0] 是错字吗?)我不得不为__m256i sum3; 添加一个声明以使其编译(godbolt.org/g/zrSCDa),但是是的,我的回答中的 SIMD 地址计算想法对于 Haswell/Skylake 来说看起来不错。方便(或者不是巧合:P)您已经在向量中拥有正确的掩码。
  • 是的,错别字和我删除了所需的变量声明。我只编译了它 - 没有运行它。
  • 这太糟糕了标量内存目标bts 不快。 bts [rdi], rax 将在位串中设置该位,即使它在[rdi] 选择的双字之外。 (这种疯狂的 CISC 行为是为什么它并不快!就像 Skylake 上的 10 微秒一样。)

标签: algorithm performance optimization simd avx2


【解决方案1】:

IDK 为什么你使用同一 cur[8] 数组的不同部分作为索引和值;它使源更难理解,以找出只有一个真正的数组。另一个只是将向量反弹到标量。

看起来您只是在使用向量 -> 标量,而不是将标量插入回向量中。而且循环内的任何内容都取决于sieveX[]中的任何数据;我不熟悉您的筛选算法,但我想这样做的目的是在内存中创建数据以供以后使用。


AVX2 有聚集(不是分散),但它们只在 Skylake 和更新版本上运行得很快。他们在 Broadwell 上没问题,在 Haswell 上慢,在 AMD 上慢。 (就像 Ryzen 的 vpgatherqq 每 12 个时钟一个)。请参阅http://agner.org/optimize/the x86 tag wiki 中的其他性能链接。

英特尔的优化手册有一小部分关于手动收集/分散(使用插入/提取或movhps)与硬件说明,可能值得一读。在这种情况下,索引是运行时变量(不是恒定步幅或其他东西),我认为 Skylake 可以从此处的 AVX2 收集指令中受益。

请参阅 Intel 的内在函数指南以查找 asm 指令的内在函数,例如 movhps。我只是在谈论你想让你的编译器发出什么,因为这很重要,而且 asm 助记符的类型更短并且不需要强制转换。你必须知道 asm 助记符才能在 Agner Fog 的指令表中查找它们,或者从自动矢量化读取编译器输出,所以我通常在 asm 中思考,然后将其转换为内在函数。


使用 AVX,您有 3 个主要选项:

  • 以标量方式执行所有操作。注册压力可能是一个问题,但根据需要生成索引(而不是一次执行所有 4 个添加或子项来生成curr[4..7])可能会有所帮助。除非那些 mask 向量在不同元素中具有不同的值。

(但是,如果它们不适合 32 位立即数,并且如果每个时钟不存在 2 个内存操作的瓶颈,则将内存源用于标量常量可能还不错。内存目标 or 指令将使用索引寻址模式,因此无法使用 Haswell 上端口 7 上的专用 store-AGU 及更高版本。因此 AGU 吞吐量可能成为瓶颈。)

将向量的所有 4 个元素提取为标量比 4x 标量 add 或移位指令更昂贵,但您要做的工作不止于此。尽管如此,使用 BMI2 进行 1 微秒的可变计数转换(而不是英特尔上的 3),它可能并不可怕。不过,我认为我们可以使用 SIMD 做得更好,尤其是仔细调整。

  • 像现在这样将索引和值提取为标量,因此sieveX[] 的 OR 是纯标量。即使两个或多个索引相同也可以工作。

    每个 ymm 向量大约需要 7 微指令 -> 使用提取 ALU 指令的 4x 标量寄存器,或使用存储/重新加载的 5 微指令(值得考虑编译器,可能用于 4 个向量提取中的一个或两个,因为这段代码可能不会成为加载/存储端口吞吐量的瓶颈。)但是,如果编译器将 C 源代码中的存储/重新加载转换为 shuffle/extract 指令,则无法轻松覆盖其策略,除非使用 volatile。顺便说一句,您可能想使用alignas(32) cur[8] 来确保实际的向量存储不会跨越缓存线边界。

or [rdi + rax*8], rdx (with an indexed addressing mode preventing full micro-fusion) 在现代 Intel CPU(Haswell 及更高版本)上是 3 微指令。 我们可以通过使用 SIMD 缩放 + 添加到数组基址来避免索引寻址模式(使前端为 2 微指令):例如srli 用 3 代替 6,屏蔽低 3 位 (vpand),vpaddq 使用 set1_epi64(sieveX)。因此,每个索引向量需要 2 条额外的 SIMD 指令才能在 SnB 系列上节省 4 微指令。 (您将提取uint64_t* 指针元素而不是uint64_t 索引。或者如果sieveX 可以是32 位绝对地址1,您可以跳过vpaddq 并已经提取-相同增益的缩放指数。)

它还将使存储地址 uops 在端口 7(Haswell 及更高版本)上运行;端口 7 上的简单 AGU 只能处理非索引寻址模式。 (这使得使用 store+reload 将值提取为标量更具吸引力。您希望提取索引的延迟更低,因为在 memory-dst or 的加载部分完成之前不需要这些值。)这确实意味着更多未融合- 用于调度程序/执行单元的域微指令,但值得权衡。

这不是其他 AVX2 CPU(Excavator / Ryzen 或 Xeon Phi)的胜利;只有 SnB 系列对索引寻址模式有前端成本和执行端口限制。

  • 提取索引,使用 vmovq / vmovhps 手动收集到一个向量中以获得 SIMD vpor,然后使用 vmovq / vmovhps 分散回去。

    就像硬件收集/分散一样,正确性要求所有索引都是唯一的,因此您需要使用上述选项之一,直到您的算法达到这一点。 (矢量冲突检测 + 回退与总是提取到标量相比不值得付出代价:Fallback implementation for conflict detection in AVX2)。

    有关内部函数版本,请参阅 selectively xor-ing elements of a list with AVX2 instructions。 (我知道我最近写了一个手动收集/分散的答案,但我花了一段时间才找到它!)在那种情况下,我只使用了 128 位向量,因为没有任何额外的 SIMD 工作来证明额外的vinserti128 / vextracti128.

实际上,我认为在这里您希望提取_mm256_sllv_epi64 结果的高半部分,以便在两个单独的__m128i 变量中拥有(可能的数据)cur[4..5]cur[6..7]。你会有vextracti128 / 2x vpor xmm 而不是vinserti128 / vpor ymm / vextracti128

前者的 port5 压力较小,指令级并行性更好:两个 128 位半是独立的依赖链,不会相互耦合,因此存在存储/重载瓶颈(和缓存未命中)影响较少的依赖微指令,允许乱序执行以在等待时继续处理更多内容。

在 256b 向量中进行地址计算并提取指针而不是索引将使 vmovhps 在 Intel 上的负载更便宜(索引负载不能保持微融合到 vmovhps2)。请参阅上一个要点。但是vmovq 加载/存储始终是单个微指令,vmovhps 索引存储可以在 Haswell 及更高版本上保持微融合,因此它在前端吞吐量方面是收支平衡的,而在 AMD 或 KNL 上则更糟。这也意味着调度程序/执行单元有更多的未融合域微指令,这看起来比 port2/3 AGU 压力更像是一个潜在的瓶颈。唯一的好处是store-address uops可以在端口7上运行,减轻一些压力。

AVX2 为我们提供了一种新的选择:

  • AVX2 vpgatherqq 用于聚集 (_mm256_i64gather_epi64(sieveX, srli_result, 8)),然后提取索引并手动分散。因此它与手动聚集/手动分散完全一样,只是您将手动聚集替换为 AVX2 硬件聚集。 (两个 128 位聚集的成本高于一个 256 位聚集,因此您可能希望将指令级并行性命中并聚集到一个 256 位寄存器中)。

可能在 Skylake 上获胜(其中vpgatherqq ymm 是 4 uops / 4c 吞吐量,加上 1 uop 的设置),但甚至不是 Broadwell(9 uops,每 6c 吞吐量一个),绝对不是 Haswell(22 uops / 9c 吞吐量)。无论如何,您确实需要标量寄存器中的索引,因此您保存了手动收集部分的工作。这很便宜。


Skylake 上每个策略的总成本

看起来这在任何一个端口上都不会严重成为瓶颈。 GP reg->xmm 需要端口 5,但 xmm->int 在 SnB 系列 CPU 上需要端口 0,因此当与提取所需的 shuffle 混合时,它不太可能在端口 5 上出现瓶颈。 (例如,vpextrq rax, xmm0, 1 是一条 2 uop 指令,一个端口 5 shuffle uop 以获取高位 qword,一个端口 0 uop 将数据从 SIMD 发送到整数域。)

所以你的 SIMD 计算需要经常提取一个向量来标量 比您需要经常将标量计算结果插入向量中要好得多。另请参阅 Loading an xmm from GP regs,但这是在讨论从 GP regs 开始的数据,而不是内存。

  • 同时提取/标量 OR:总计 = 24 微指令 = 6 个前端吞吐量周期。

  • vpaddq + vpand 地址计算(Skylake 上的端口 0/1/5 为 2 微指令)

  • 2x vextracti128(端口 5 为 2 微指令)

  • 4x vmovq (4 p0)

  • 4x vpextrq (8: 4p0 4p5)

  • 4x or [r], r(4x2 = 8 个前端微指令。后端:4p0156 4p23(加载)4p237(存储地址)4p4(存储数据))。非索引寻址模式。

p5 的总计 = 6 微秒,勉强适合。如果您可以让编译器执行此操作,则数据提取的存储/重新加载看起来很明智。 (但编译器通常不会对管道进行足够详细的建模,无法在同一循环中使用多种策略来平衡端口压力。)

  • 手动收集/分散:20 uops,5 个前端吞吐量周期(Haswell / BDW / Skylake)。在 Ryzen 上也不错。

  • (可选,可能不值得):vpaddq + vpand 地址计算(Skylake 上的端口 0/1/5 为 2 微指令)如果您可以使用非 VEX movhps 进行 1 微指令微融合,请跳过这些索引负载。 (但随后 p237 商店变成了 p23)。

  • vextracti128 指针(端口 5 为 1 uop)

  • 2x vmovq 提取 (2p0)

  • 2x vpextrq (4 = 2p0 2p5)

  • 2x vmovq 负载 (2p23)

  • 2x vmovhps xmm, xmm, [r] 非索引负载(2 个前端微融合:2p23 + 2p5)

  • vextracti128 拆分数据(p5)

  • 2x vpor xmm (2p015)

  • 2x vmovq 存储(2x 1 微融合 uop,2p237 + 2p4)

  • 2x vmovhps 存储(2x 1 微融合 uop,2p237 + 2p4)

端口瓶颈:4 p0 和 4 p5 可轻松适应 5 个周期,尤其是当您将其与可以在端口 1 上运行多个微指令的循环混合时。在 Haswell paddq 上只有 p15(不是 p015),并且班次只有 p0(不是 p01)。 AVX2 _mm256_sllv_epi64 在 Skylake 上为 1 uop (p01),但在 Haswell 上为 3 uop = 2p0 + p5。因此,Haswell 可能更接近此循环的 p0 或 p5 瓶颈,在这种情况下,您可能需要查看一个索引向量的存储/重新加载提取策略。

跳过 SIMD 地址计算可能很好,因为除非您使用存储/重新加载提取,否则 AGU 压力看起来不是问题。这意味着更少的指令/更小的代码大小和 uop 缓存中的 uop 更少。 (直到解码器 / uop 缓存之后才会发生非分层,因此您仍然可以从前端早期部分的微融合中受益,而不是问题瓶颈。)

  • Skylake AVX2 收集/手动分散:总计 = 18 微指令,4.5 个前端吞吐量周期。(在任何早期的 uarch 或 AMD 上都更糟)。

  • vextracti128 索引(端口 5 为 1 uop)

  • 2x vmovq 提取 (2p0)

  • 2x vpextrq (4 = 2p0 2p5)

  • vpcmpeqd ymm0,ymm0,ymm0vpgatherqq 创建一个全为掩码 (p015)

  • vpgatherqq ymm1, [rdi + ymm2*8], ymm0 某些端口为 4 微指令。

  • vpor ymm (p015)

  • 关于 OR 结果的 vextracti128 (p5)

  • 2x vmovq 存储(2x 1 微融合 uop,2p23 + 2p4)。注意没有 port7,我们使用的是索引存储。

  • 2x vmovhps 存储(2x 1 微融合 uop,2p23 + 2p4)。

因此,即使选择了最佳吞吐量选择,我们仍然每 4.5 个周期只管理 4 次加载/4 次存储,而且这还没有考虑到循环中的 SIMD 工作会消耗一些前端吞吐量。 所以我们并没有接近 AGU 吞吐量的瓶颈,也不必担心使用端口 7。

我们或许可以考虑存储/重新加载其中一个提取(如果我们是编译器),用 5 微指令存储/4 倍加载替换 7 微指令 5 指令 vextracti128 / 2x vmovq / 2x vpextrq 序列。


总体:一个循环直到我们完成冲突,然后是一个 SIMD 收集循环

您说在某个点之后,您不会在cur[0] == cur[2] 之类的索引之间发生冲突(重叠)。

您肯定想要一个完全不检查冲突的单独循环来利用这一点。即使你有 AVX512,Skylake 的vpconflictq 也是微码,速度不快。 (KNL 有单微指令 vpconflictq 但完全避免它仍然更快)。

我会留给您(或一个单独的问题)如何确定您何时完成冲突并可以离开导致这种可能性的循环。

您可能需要提取索引 + 数据策略,但可能存在冲突。 SIMD 冲突检查是可能的,但它并不便宜,32 位元素的 11 微指令:Fallback implementation for conflict detection in AVX2。 qword 版本显然比 dword 便宜得多(更少的随机播放和比较以获得所有对所有),但您可能仍然只想在您的提取循环的每 10 次左右迭代中执行一次。

从最好的标量或版本到最好的收集版本并没有很大的加速(6 个周期与 4.5 没有考虑循环中的其他工作,所以这个比率甚至更小) .尽快离开稍慢的版本不值得让它慢很多。

因此,如果您能够可靠地检测到冲突何时结束,请使用类似

int conflictcheck = 10;

do {

    if (--conflictcheck == 0) {
       vector stuff to check for conflicts
       if (no conflicts now or in the future)
           break;

       conflictcheck = 10;  // reset the down-counter
    }

    main loop body,  extract -> scalar OR strategy

} while(blah);


// then fall into the gather/scatter loop.
do {
    main loop body, gather + manual scatter strategy
} while();

这应该编译成dec / je,在未采用的情况下只需要 1 uop。

在稍慢的循环中总共进行 9 次额外迭代比进行数千次额外昂贵的冲突检查要好得多。


脚注 1

如果sieveX 是静态的并且您正在Linux(不是MacOS)上构建非PIC 代码,那么它的地址将适合disp32 作为[reg+disp32] 寻址模式的一部分。在这种情况下,您可以省略vpaddq。但是让编译器将uint64_t 视为已经缩放的数组索引(清除其低位)会很丑陋。可能必须将sieveX 转换为uintptr_t 并添加,然后再转换回来。

这在 PIE 可执行文件或共享库(不允许使用 32 位绝对地址)或 OS X 上(静态地址始终高于 2^32)是不可能的。我不确定 Windows 允许什么。请注意,[disp32 + reg*8] 只有 1 个寄存器,但仍然是索引寻址模式,因此适用所有 SnB 系列惩罚。但如果你不需要缩放,reg + disp32 只是 base + disp32。

脚注 2:有趣的事实:非 VEX movhps 负载可以在 Haswell 上保持微融合。它不会导致 Skylake 上的 SSE/AVX 停顿,但您不会让编译器在 AVX2 函数中间发出非 VEX 版本

但是,IACA(英特尔的静态分析工具)弄错了。 :(What is IACA and how do I use it?.

这基本上是对 -mtune=skylake 的优化缺失,但它在 Haswell 上停滞不前:Why is this SSE code 6 times slower without VZEROUPPER on Skylake?

Skylake 上的“惩罚 A”(使用脏上层执行 SSE)只是对那个寄存器的错误依赖。 (还有一个合并 uop,用于原本只写的指令,但 movhps 已经是其目的地的读-修改-写。)我在 Skylake 上使用 Linux perf 对此进行了测试,以计算 uops,使用以下循环:

    mov     r15d, 100000000

.loop:
    vpaddq  ymm0, ymm1, ymm2      ; dirty the upper part
    vpaddq  ymm3, ymm1, ymm2      ; dirty another register for good measure

    vmovq  xmm0, [rdi+rbx*8]       ; zero the full register, breaking dependencies
    movhps xmm0, [rdi+rbx*8+8]     ; RMW the low 128 bits
                          ; fast on Skylake, will stall on Haswell

    dec r15d
    jnz .loop

循环在 Skylake (i7-6700k) 上以每次迭代约 1.25 个周期运行,最大限度地提高了每个时钟 4 uop 的前端吞吐量。总共 5 个融合域微指令 (uops_issued.any),6 个非融合域微指令 (uops_executed.thread)。所以movhps 肯定发生了微融合,没有任何 SSE/AVX 问题。

将其更改为 vmovhps xmm0, xmm0, [rdi+rbx*8+8] 将其减慢到每次迭代 1.50 个周期,现在是 6 个融合域,但仍然是相同的 6 个未融合域微指令。

如果ymm0 的上半部分在movhps xmm0, [mem] 运行时是脏的,则没有额外的uop。我通过注释掉vmovq 进行了测试。但是将 vmovq 更改为 movq 确实 会导致额外的 uop:movq 变成了一个微融合加载+合并,它替换了低 64 位(并且仍然将 xmm0 的高 64 位归零所以不完全是movlps)。


还要注意pinsrq xmm0, [mem], 1 即使没有 VEX 也不能微熔。但是对于 VEX,出于代码大小的原因,您应该更喜欢 vmovhps

您的编译器可能希望将整数数据上的 movhps 的内在函数“优化”为 vpinsrq,不过,我没有检查。

【讨论】:

  • 你是对的。代码需要一些清理(我在某种程度上做了)。我添加了 C 代码以及参考。这个函数主要是从一个更大的函数中提取出来的代码。感谢您花费这么多时间。一旦我有机会经历这一切,我可能会有更多的 cmets。
  • 更新了代码以使用融合 uop 方法。注意:sieveX 是一个 64 位地址。
  • @ChipK:是的,我认为您不可能利用它作为 32 位绝对地址(即非 PIC 代码中的静态存储)。这就是为什么我把它放在脚注中,并在没有假设的情况下写下其余的答案。 :P
  • 哇!我想我的下一个任务是将循环的冲突部分与非冲突部分分开,然后尝试您描述的一些方法。它让我希望能够获得一些表现(通过“艰苦”的工作)。再次感谢!
  • @ChipK:请注意,将两个单独的收集/分散到两个 128 位向量中,您可以对其进行排列,以便即使 idx[0]idx[1] 也可以使用手动收集/分散循环冲突。即将idx[0]idx[2] 聚集在一起,1,3 聚集在一起。在收集下两个结果之前分散两个结果。 (安排您的 256 位向量按该顺序交错元素。)如果该步幅有帮助,这可以让您更快地进入更快的循环。 (并将冲突检测的成本降低到可能是一次 shuffle / compare / movmsk / test。) ...
【解决方案2】:

我刚刚查看了您在此处所做的确切操作:对于 mod1 = mod3 = _mm256_set1_epi64x(1); 的情况,您只需在位图中设置单个位,并将 ans 的元素作为索引。

它由两个展开,ans 和 ans2 并行运行,使用 mod1 &lt;&lt; ansmod3 &lt;&lt; ans2。评论您的代码并使用英文文本解释大局中发生的事情!这只是普通埃拉托色尼筛的位设置循环的一个非常复杂的实现。 (所以如果问题一开始就这么说就好了。)

并行展开多个开始/跨步是一个非常好的优化,因此您通常在缓存行中设置多个位,同时它在 L1d 中仍然很热。 同时针对较少的不同因素进行缓存阻止具有相似的优势。在继续下一个之前,针对多个因素(步幅)反复迭代相同的 8kiB 或 16kiB 内存块。为 2 个不同步幅中的每一个展开 4 个偏移量可能是创建更多 ILP 的好方法。

不过,并行运行的步幅越多,第一次接触新缓存行时的速度就越慢。 (提供缓存/TLB 预取空间以避免初始停顿)。所以缓存阻塞并没有消除多步的所有好处。


步幅的可能特殊情况

单个 256 位向量加载/VPOR/存储可以设置多个位。诀窍是创建一个向量常数或一组向量常数,位在正确的位置。不过,重复模式有点像LCM(256, bit_stride) 位长。例如 stride=3 将以 3 个向量长的模式重复。除非有更聪明的东西,否则这很快就无法用于奇数/主要步幅:(

64 位标量很有趣,因为按位循环可用于创建一系列模式,但 SnB 系列 CPU 上的可变计数循环需要 2 微秒。

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

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


通过更窄的加载/存储减少冲突

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

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

32 位元素将是一个显而易见的选择。 x86 可以有效地将双字加载/存储到 SIMD 寄存器以及标量。 (标量字节操作也很有效,但是来自 SIMD regs 的字节存储总是需要多个带有pextrb 的微指令。)

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

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

如果您使用 32 位 SIMD 元素而不将 sieveX 限制为 32 位点指针,您将不得不放弃使用 SIMD 指针计算而只提取位索引,或者仍然在 SIMD 中拆分为 @ 987654339@/bit 并提取两者。

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

如果您手动收集到 32 位元素,则不能再使用 movhps。您必须使用pinsrd / pextrd 来处理高 3 个元素,而那些从不微型熔断器/总是需要 SnB 系列上的 port5 uop。 (不像movhps 是一个纯粹的商店)。但这意味着vpinsrd 在索引寻址模式下仍然是 2 微指令。您仍然可以将vmovhps 用于元素2(然后用vpinsrd 覆盖向量的顶部双字);未对齐的负载很便宜,可以重叠下一个元素。但是你不能做movhps 商店,这就是它真正好的地方。


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

Apparently 你有时会使用 mod1mod3 的某些元素作为 0,导致完全无用的浪费工作,为这些进步做 [mem] |= 0

我认为一旦ansans2 中的一个元素到达total,你就会掉出内部循环并执行ans -= sum 1 每次通过内部循环。您不一定要将其重置回ans = sum(对于该元素)以重做 ORing(设置已设置的位),因为该内存在缓存中将是冷的。我们真正想要的是将剩余的仍在使用的元素打包到已知位置,然后输入其他版本的循环,它们只执行 7 个、6 个、5 个总元素。然后我们只剩下 1 个向量。

这看起来真的很笨重。一个元素到达末尾的更好策略可能是用标量完成该向量中的剩余三个,一次一个,然后运行剩余的单个 __m256i 向量。如果步幅都在附近,您可能会获得良好的缓存局部性。


更便宜的标量,或者可能仍然是 SIMD,但只提取一个位索引

使用 SIMD 将位索引拆分为一个 qword 索引和一个位掩码,然后分别提取两者,这会在标量 OR 情况下花费大量微指令:如此之多,以至于您不会成为每时钟 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 &lt;&lt; (cur1 &amp; 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 微秒。请注意,加载和存储都使用mov,因此索引寻址模式对 Haswell 及更高版本没有任何惩罚。

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

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

也许您甚至可以让编译器发出类似的东西,但即使是每个展开的 RMW 一个额外的 uop 也很重要。

【讨论】:

  • 谢谢,我会看看你的研究。我的整个系统设置为使用 64 位条目,更改它需要 IMO 做太多工作。每个位数组(或部分)仅限于适合 L2 缓存(尚未完全实现)。正如您总结的那样,它是一个分段的素数筛。我的程序从最初的实现(大约 20-30X 加速)已经走了很长一段路。但我喜欢/觉得还有更多的东西要生——正如你所展示的。目前,我的目标平台是 Windows,但我希望最终有一个 Linux 版本可以运行(应该不是大问题)。
  • 如果速度更快,我不介意切换到标量方法。我主要使用矢量方法来计算初始起始位置(需要几个 64 位模数)并防止段溢出(正如我之前所说,分支预测器在 4 个不同的停止条件下变得疯狂)。我不认为 JIT 是可行的——我使用了一种类似的方法来筛选小的(小于 64 个)因子。抱歉缺少 cmets 和奇怪的变量名 - 为了清楚起见,我需要清理/重写一些代码。
  • 只有零次 ORing 出现在因子(加上初始起始对齐)超过结束值的段中。所以这不会发生,除非它使用一些小段。因此,除了一些非常罕见的情况外,RMW 无用元素并不是真正浪费时间。
  • 我尝试了一种用于索引和位位置的标量方法,但似乎我遇到了寄存器压力,编译器无法将所有数据保存在寄存器中(我确信手动组装可能会解决这个问题) .
  • 我实现了标量版本,它确实更快 - 大约 10%
猜你喜欢
  • 1970-01-01
  • 2010-11-08
  • 1970-01-01
  • 1970-01-01
  • 2017-02-28
  • 1970-01-01
  • 2012-08-29
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多