使用 GCC 计算 x86-64 的前缀总和的两种看似等效的方法之间的显着速度差异

Significant speed difference between two seemingly-equivalent methods of calculating prefix sums with GCC for x86-64

提问人:platelet 提问时间:11/7/2023 最后编辑:platelet 更新时间:11/7/2023 访问量:142

问:

我尝试了两种几乎相同的前缀总和计算方法,发现它们在编译后有显着差异。编译选项为 。-O2

不同的编译结果导致它们的运行时间相差 4 倍。

第一个:

#include <numeric>
#include <algorithm>

int main() {
    unsigned a[5000];
    std::iota(a, a + 5000, 0);
    for (int k = 0; k < 1'000'000; k++)
        for (int i = 1; i < 5000; i++)
            a[i] += a[i - 1];
    return *std::min_element(a, a + 5000);
}

第二种:

#include <numeric>
#include <algorithm>

int main() {
    unsigned a[5000];
    std::iota(a, a + 5000, 0);
    for (int k = 0; k < 1'000'000; k++)
        for (int i = 0; i + 1 < 5000; i++)
            a[i + 1] += a[i];
    return *std::min_element(a, a + 5000);
}

在编译器资源管理器中

编译器中出现此异常的原因可能是什么?

C++ 性能 程序集 GCC CPU 体系结构

评论

1赞 Thomas Weller 11/7/2023
stackoverflow.com/questions/31816095/......
2赞 Pepijn Kramer 11/7/2023
不要使用“#include < bits/stdc++.h>”,它不是标准的C++,并尽可能使用 std::array。因此,您不必在整个代码中重复 5000,并且可以使用 begin()、end() 和 size()
0赞 Thomas Weller 11/7/2023
为什么不始终如一地使用千位分隔符,比如 1'000'000?
1赞 Simon Goater 11/7/2023
值得一提的是,我尝试在 c (gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0) 中重新创建它并验证了您的观察结果。我使用我的旧笔记本电脑 9.5k 与每次迭代 28k 周期。如果我使用 -O3 进行编译,它们都在 9.4k 周期左右。
1赞 NathanOliver 11/7/2023
FWIW,较新版本的 GCC 似乎已修复此问题。GCC12 和 13 都生产相同的组件

答:

4赞 harold 11/7/2023 #1

在版本 2 中,我们看到 GCC 已经做到了这一点,

.L4:
        add     edx, DWORD PTR [rax]
        add     rax, 4
        mov     DWORD PTR [rax-4], edx
        cmp     rcx, rax
        jne     .L4

因此,在 中累积值,在其中添加一个新元素并将累积的总和存储到内存中。这很好,这里没有问题。edx

在版本 1 中,我们看到 GCC 已经做到了这一点,

.L4:
        mov     edx, DWORD PTR [rax-4]
        add     DWORD PTR [rax], edx
        add     rax, 4
        cmp     rax, rcx
        jne     .L4

在这里,再次加载先前存储的部分总和,然后将其添加到当前元素中。

这不好,内存存在循环携带的依赖关系,将存储+重新加载的延迟放在关键路径上。在各种 CPU 上,每次迭代可能是 5 或 6 个周期。在版本 2 中,类似的依赖关系通过而不是通过内存,这在每个 CPU 上都非常有效,让循环以每次迭代接近 1.5 个周期的速度执行。基于这种影响,预计性能差异约为 4 倍。edx

在一些较新的 CPU 上,如果 CPU 在这种情况下可以使用内存重命名(零延迟存储来加载转发),它可能不会那么糟糕。然而,关于英特尔 Ice Lake,Agner Fog 写道:

快进在 以下情况,根据我的测试:
......

  • 读-修改-写指令

我们这里确实有一个读取-修改-写入指令,所以这可能不好。

对于AMD风格的内存重命名,显然内存操作数必须完全匹配(不仅仅是计算地址,还有指定地址的方式),我们这里没有,所以它可能也不好。

我不知道为什么 GCC 会以如此不同的方式编译这两个版本。也许有一个原因,我只是想不出来,但也许它只是运气不好。

评论

3赞 NathanOliver 11/7/2023
FWIW,较新版本的 GCC 似乎已修复此问题。GCC12 和 13 都产生相同的组件。