为什么处理未排序数组与使用现代 x86-64 clang 处理排序数组的速度相同?

Dim*_*nNe 153 c++ performance cpu-architecture clang branch-prediction

我发现了这个流行的大约 9 岁的SO 问题,并决定仔细检查其结果。

所以,我有 AMD Ryzen 9 5950X、clang++ 10 和 Linux,我从问题中复制粘贴了代码,这是我得到的:

排序 - 0.549702s

~/d/so_sorting_faster$ cat main.cpp | grep "std::sort" && clang++ -O3 main.cpp && ./a.out
    std::sort(data, data + arraySize);
0.549702
sum = 314931600000
Run Code Online (Sandbox Code Playgroud)

未分类 - 0.546554s

~/d/so_sorting_faster $ cat main.cpp | grep "std::sort" && clang++ -O3 main.cpp && ./a.out
    // std::sort(data, data + arraySize);
0.546554
sum = 314931600000
Run Code Online (Sandbox Code Playgroud)

我很确定 unsorted 版本比 3ms 快的事实只是噪音,但它似乎不再慢了。

那么,CPU 的架构发生了什么变化(使其不再慢一个数量级)?

以下是多次运行的结果:

Unsorted: 0.543557 0.551147 0.541722 0.555599
Sorted:   0.542587 0.559719 0.53938  0.557909
Run Code Online (Sandbox Code Playgroud)

以防万一,这是我的 main.cpp:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    // std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

更新

具有更多元素(627680):

Unsorted
cat main.cpp | grep "std::sort" && clang++ -O3 main.cpp && ./a.out
    // std::sort(data, data + arraySize);
10.3814

Sorted:
cat main.cpp | grep "std::sort" && clang++ -O3 main.cpp && ./a.out
    std::sort(data, data + arraySize);
10.6885
Run Code Online (Sandbox Code Playgroud)

我认为这个问题仍然相关 - 几乎没有区别。

Nat*_*dge 156

您链接的问题中的几个答案谈到将代码重写为无分支,从而避免任何分支预测问题。这就是您更新的编译器正在做的事情。

具体来说,带有-O3 矢量化内部循环的clang++ 10 。 请参阅 Godbolt 上的代码,程序集的第 36-67 行。代码有点复杂,但是您绝对看不到的一件事是data[c] >= 128测试中的任何条件分支。相反,它使用向量比较指令 ( pcmpgtd),其输出是一个掩码,其中 1 表示匹配元素,0 表示不匹配。pand带有此掩码的后续元素将不匹配的元素替换为 0,因此当它们无条件地添加到总和时,它们不会做出任何贡献。

粗略的 C++ 等价物是

sum += data[c] & -(data[c] >= 128);
Run Code Online (Sandbox Code Playgroud)

代码实际上sum为数组的偶数和奇数元素保留了两个运行的 64 位,以便它们可以并行累加,然后在循环结束时相加。

一些额外的复杂性是将 32 位data元素符号扩展到 64 位;这就是序列喜欢pxor xmm5, xmm5 ; pcmpgtd xmm5, xmm4 ; punpckldq xmm4, xmm5完成的。打开-mavx2,你会看到一个更简单vpmovsxdq ymm5, xmm5的地方。

代码看起来也很长,因为循环已经展开,data每次迭代处理 8 个元素。

  • 另请注意,clang 默认情况下展开小循环(与 GCC 不同);如果您想查看矢量化方式的最简单版本,请使用“-fno-unroll-loops”。https://godbolt.org/z/z6WYG9。(我加入了 `-march=nehalem` 来启用 SSE4,包括 `pmovsxdq` 符号扩展,让它使 asm 比手动符号扩展更简单。奇怪的是,即使没有它,它仍然一次只处理 8 个字节,而不是使用 `punpckldq` + `punpckhdq` 来使用负载的低半部分和高半部分 + 比较结果。公平地说,有时 GCC 在必须使用较宽的负载时*不*使用较窄的负载,这会导致自己搬起石头砸自己的脚:/) (11认同)
  • 不管怎样,这个问题的答案是它自动矢量化。像往常一样,编译器不会选择完美的策略。(尽管 GCC 可能最适合 SSE2 或 SSE4。) (7认同)
  • 另外,对于 clang 的策略(使用来自 `-march=nehalem` 的 SSE4.2)来说,使用 `pmovsxdq xmm, [mem]` 加载并将比较范围扩大到 64 位可能会更好,而不是扩大比较 *结果*。正如我在第一条评论中提到的,GCC 执行 16 字节加载。使用 SSE4,需要进行 2 次洗牌才能对高两个掩码元素进行符号扩展(仍然可能值得),而如果没有 SSE4,则与 clang 相比,在初始数据上每个 pcmpgtd / pand 完成的工作量是原来的两倍,甚至符号扩展可以在两半之间分担一些工作。https://godbolt.org/z/nWhz3n (5认同)
  • 还相关:[gcc 优化标志 -O3 使代码比 -O2 慢](/sf/ask/2021272781/) 对于相同的代码,其中无分支(无向量化)对于排序来说无利可图,并且您需要 PGO (配置文件引导优化)让 GCC 做出最佳选择,如果您使用旧的 GCC,或者使用“-fno-tree-vectorize”进行编译,则不进行 if-conversion。 (3认同)
  • 所以......这些年来编译器已经变得更好了:) (3认同)