ChatGPT解决这个技术问题 Extra ChatGPT

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

我发现了这个受欢迎的 ~ 9 岁SO question,并决定仔细检查它的结果。

所以,我有 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

未排序 - 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

我很确定未排序的版本比原来快 3 毫秒的事实只是噪音,但它似乎不再慢了。

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

以下是多次运行的结果:

Unsorted: 0.543557 0.551147 0.541722 0.555599
Sorted:   0.542587 0.559719 0.53938  0.557909

以防万一,这是我的 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;
}

更新

使用大量元素(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

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

您将其作为新问题发布是正确的。这不是重复的,这是一个后续问题,绝对不应该在那里作为答案发布。如果您已经知道现代工具为什么会出现这种效果,您可以将其写成可以作为对那个旧问题的答案的形式。但@rsjaffe 的任何建议都不适用于这种特定情况。
仅作记录这不是 Why is processing a sorted array faster than processing an unsorted array? 的重复,它是一个后续。此问题中使用的编译器与该原始问题(或 gcc optimization flag -O3 makes code slower than -O2)中的选择不同,解释编译器的不同之处(无分支 SIMD 矢量化)是此问题的答案。让我知道这是否关闭;我可以重新打开。 (但其中 3 个标签中的金徽章仍然只有一票:P)@Mukyuu
@jpaugh:使用 -O2:已排序:10.4747,未排序:10.4589。使用 -O1:已排序:27.6086,未排序:26.7066。使用 -O0:已排序:118.997,未排序:316.762。
哇!我想甚至 -O1 都包含矢量化优化。那很有意思!
@jpaugh:clang 至少需要 -O2 来自动矢量化,但 even at -O1 it generates branchless scalar code:请参阅第 40 行的条件移动 cmovle,其中 edx 包含 data[c] 并且 r15d 为零。

N
Nate Eldredge

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

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

粗略的 C++ 等价物是

sum += data[c] & -(data[c] >= 128);

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

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

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


另请注意,默认情况下,clang 会展开小循环(与 GCC 不同);如果您想查看其矢量化方式的最简单版本,请使用 -fno-unroll-loopsgodbolt.org/z/z6WYG9。 (我投入了 -march=nehalem 以启用包括 pmovsxdq 符号扩展的 SSE4,使其比手动符号扩展更简单。奇怪的是,即使没有它,它仍然一次只执行 8 个字节,而不使用 {5 } + punpckhdq 使用负载的低半部分和高半部分 + 比较结果。公平地说,有时 GCC 在必须加宽时使用较窄的负载 而不是 将自己踢到脚上 :/)
此外,clang 的策略(使用来自 -march=nehalem 的 SSE4.2)可能会更好地使用 pmovsxdq xmm, [mem] 加载并将比较扩大到 64 位,而不是扩大比较结果。 GCC 执行 16 字节加载,就像我在第一条评论中提到的那样。使用 SSE4 需要 2 次 shuffle 来对高两个被屏蔽元素进行符号扩展(仍然可能值得),而没有 SSE4,对于每个 pcmpgtd / pand 在初始数据上完成两倍的工作,这是纯粹的胜利与 clang,甚至符号扩展可以在两半之间共享一些工作。 godbolt.org/z/nWhz3n
无论如何,是的,这个问题的答案是它自动矢量化。像往常一样,编译器不会选择完美的策略。 (尽管 GCC 可能最适合 SSE2 或 SSE4。)
也相关:gcc optimization flag -O3 makes code slower than -O2 对于无分支(无矢量化)对排序无利可图的相同代码,您需要 PGO(配置文件引导优化)让 GCC 做出不进行 if 转换的最佳选择,如果您“重新使用旧的 GCC,或使用 -fno-tree-vectorize 编译。
所以...编译器多年来变得更好:)