assembly 使用GCC计算x86-64前缀和的两种完全等效方法之间的速度差异显著

jk9hmnmh  于 7个月前  发布在  其他
关注(0)|答案(1)|浏览(59)

我尝试了两种几乎相同的计算前缀和的方法,编译后发现它们有明显的区别,编译选项是-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);
}


In Compiler Explorer
编译器中出现此异常的原因是什么?

p4rjhz4m

p4rjhz4m1#

在版本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中,类似的依赖通过edx而不是通过内存,这在每个CPU上都非常有效,让循环每次迭代执行接近1.5个周期。基于这种效果,预期大约4倍的性能差异。
在一些较新的CPU上,如果CPU可以在这种情况下使用内存重命名(零延迟存储来加载转发),它可能不会那么糟糕。然而,关于英特尔冰湖,Agner Fog写道:
根据我的测试,在以下情况下快进不起作用:
...

  • 读-改-写指令

我们这里有一个读-修改-写指令,所以这可能不好。
对于AMD风格的内存重命名,显然内存操作数必须是精确匹配的(不仅仅是计算的地址,还有它的指定方式),我们在这里没有,所以它可能也不好。
我不知道为什么GCC会以如此不同的方式编译这两个版本。也许是有原因的,我只是想不出来,但也许只是运气不好。

相关问题