添加冗余分配可在编译时加速代码而无需优化

时间:2018-03-09 08:41:41

标签: performance assembly x86

我发现了一个有趣的现象:

#include<stdio.h>
#include<time.h>

int main() {
    int p, q;
    clock_t s,e;
    s=clock();
    for(int i = 1; i < 1000; i++){
        for(int j = 1; j < 1000; j++){
            for(int k = 1; k < 1000; k++){
                p = i + j * k;
                q = p;  //Removing this line can increase running time.
            }
        }
    }
    e = clock();
    double t = (double)(e - s) / CLOCKS_PER_SEC;
    printf("%lf\n", t);
    return 0;
}

我在 i5-5257U Mac OS 上使用 GCC 7.3.0 来编译代码而不进行任何优化。这是平均运行时间超过10次: enter image description here 还有其他人在其他英特尔平台上测试该案例并获得相同的结果 我发布了由GCC here生成的程序集。两个汇编代码之间的唯一区别是在addl $1, -12(%rbp)之前,更快的一个有两个以上的操作:

movl    -44(%rbp), %eax
movl    %eax, -48(%rbp)

那么为什么程序运行得更快?

Peter's answer非常有帮助。对 AMD Phenom II X4 810 ARMv7处理器(BCM2835)的测试显示相反的结果,支持存储转发加速特定于某些Intel CPU。 /> 而BeeOnRope's comment and advice让我改写了这个问题。 :)
这个问题的核心是与处理器架构和组装相关的有趣现象。所以我认为值得讨论。

1 个答案:

答案 0 :(得分:11)

您正在对调试版本进行基准测试,which is basically useless

但显然有一个真正的原因是一个版本的调试版本比另一个版本的调试版本运行得慢。(假设你测得正确并且它不仅仅是CPU频率变化(涡轮/节能)导致挂钟时间的差异。)

如果你想深入了解x86性能分析的细节,我们可以尝试解释为什么asm首先执行它的方式,以及为什么asm来自额外的C语句(带有{{1}编译到额外的asm指令)可以使整体更快。 这将告诉我们关于asm性能效果的一些信息,但对优化C没什么用。

你没有显示整个内部循环,只显示了一些循环体,但是-O0是非常可预测的。每个C语句都与其他语句分开编译,所有C变量在每个语句的块之间溢出/重新加载。这使您可以在单步执行时使用调试器更改变量,甚至跳转到函数中的不同行,并使代码仍然有效。编译这种方式的性能成本是灾难性的。例如,你的循环没有副作用(没有使用结果)所以整个三重嵌套循环可以并且将在真实构建中编译为零指令,运行速度更快。

瓶颈可能是gcc -O0上的循环依赖,存储/重新加载和k递增。存储转发延迟通常为around 5 cycles on most CPUs。因此,你的内部循环仅限于每6个周期运行一次,即内存目标add的延迟。

如果您使用的是英特尔CPU,那么当重新加载无法立即执行时,存储/重新加载延迟实际上会更低(更好)。在依赖对之间具有更多独立的加载/存储可以在您的情况下解释它。请参阅Loop with function call faster than an empty loop

因此,如果循环中有更多工作,那么add可以在连续运行时每6个周期维持一个吞吐量,而不是每4或5个周期创建一次迭代的瓶颈。

更新:根据测量from a 2013 blog post,这种影响显然发生在Sandybridge和Haswell上,所以是的,这也是您Broadwell i5-5257U最可能的解释。似乎这种影响发生在所有英特尔Sandybridge系列CPU上

如果没有关于测试硬件,编译器版本(或内循环的asm源),以及两个版本的绝对和/或相对性能数字的更多信息,是我最好的低调猜测解释。在我的Skylake系统上进行基准测试/分析addl $1, -12(%rbp)并不足以让我自己尝试。下一次,包括时间数字。

不属于循环承载依赖关系链的所有工作的存储/重新加载的延迟并不重要,只有吞吐量。现代无序CPU中的存储队列确实有效地提供了内存重命名,从而消除了write-after-write and write-after-read hazards重用相同的堆栈内存以便gcc -O0被写入,然后在其他地方读取和写入。 (有关内存危险的详细信息,请参阅https://en.wikipedia.org/wiki/Memory_disambiguation#Avoiding_WAR_and_WAW_dependencies;有关延迟与吞吐量的更多信息,请参阅this Q&A,并重复使用相同的寄存器/寄存器重命名)

内部循环的多次迭代可以同时进行,因为内存顺序缓冲区跟踪每个负载需要从哪个存储中获取数据,而不需要先前存储到同一位置以提交到L1D并获取离开商店队列。 (有关CPU微体系结构内部的更多信息,请参阅英特尔的优化手册和Agner Fog的microarch PDF。)

这是否意味着添加无用的语句会加速实际程序? (已启用优化)

一般来说,不,它没有。编译器将循环变量保存在最内层循环的寄存器中。无用的语句实际上会在启用优化的情况下优化掉。

调整p的来源无用。使用gcc -O0进行衡量,或者使用项目默认构建脚本的任何选项。

此外,此商店转发加速特定于英特尔Sandybridge系列,您不会在Ryzen等其他微架构上看到它,除非它们也具有类似的存储转发延迟效果。

存储转发延迟可能是实际(优化)编译器输出中的问题,特别是如果您没有使用链接时优化(LTO)让微小函数内联,尤其是函数通过引用传递或返回任何内容(因此它必须通过内存而不是寄存器)。如果您真的想在Intel CPU上解决问题并且可能在某些其他CPU上做得更糟,那么缓解问题可能需要像-O3这样的黑客攻击。见discussion in comments