【问题标题】:Adding a redundant assignment speeds up code when compiled without optimization在没有优化的情况下编译时添加冗余分配会加速代码
【发布时间】:2020-03-14 02:04:00
【问题描述】:

我发现一个有趣的现象:

#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 次的平均运行时间: 还有其他人在其他英特尔平台上测试该案例并得到相同的结果。
我发布了由 GCC here 生成的程序集。两个汇编代码之间的唯一区别是在addl $1, -12(%rbp) 之前,更快的一个还有两个操作:

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

那么为什么程序在这样的分配下运行得更快?


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

【问题讨论】:

  • 您在构建时是否启用了优化?任何没有优化的基准测试都是毫无价值的。
  • 您可以指示gcc 仅生成程序集,这通常比您提供的反汇编程序更具可读性(恕我直言,“反编译”一词是错误的)。
  • 您正在对调试版本进行基准测试,which is basically useless 但是,如果您想确切知道原因,瓶颈将是所有存储/重新加载,可能是一个循环-对k 进行依赖。如果你在 Skylake,store/reload latency can actually be lower (better) when there's more in between the dependent pair (including other stores/loads)..
  • 所以根本没有优化。如前所述,这不足以进行基准测试。至少使用-O2
  • @TobySpeight - 我不同意。未经优化的编译对于性能分析没有用处,但归根结底,无论编译器设置如何,人们可能会问为什么编译器发出的一个 sn-p 程序集比另一个慢,尽管第一个程序集具有严格的更少的陈述。正如彼得的回答所示,仅此一点就很有趣。

标签: performance assembly x86 cpu-architecture micro-architecture


【解决方案1】:

TL:DR:如果重新加载不尝试“立即”发生,Sandybridge 系列存储转发具有较低的延迟。添加无用代码可以加速调试模式循环,因为在-O0 反优化代码中的循环携带延迟瓶颈几乎总是涉及store/reload of some C variables
这种放缓的其他例子:hyperthreadingcalling an empty functionaccessing vars through pointers

这些都与优化代码无关。存储转发延迟的瓶颈偶尔会发生,但在代码中添加无用的复杂性不会加快速度。


您正在对调试版本进行基准测试,which is basically useless。它们有与优化代码不同的瓶颈,而不是统一的减速。


但很明显,一个版本的调试版本比另一个版本的调试版本运行速度慢是有真正原因的。 (假设您测量正确,并且导致挂钟时间差异的不仅仅是 CPU 频率变化(加速/省电)。)

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

你没有展示整个内部循环,只是循环体的一部分,但gcc -O0pretty predictable。每个 C 语句都与所有其他语句分开编译,所有 C 变量在每个语句的块之间溢出/重新加载。这使您可以在单步执行时使用调试器更改变量,甚至可以跳转到函数中的不同行,并且代码仍然可以工作。以这种方式编译的性能成本是灾难性的。例如,您的循环没有副作用(没有使用任何结果),因此整个三重嵌套循环可以并且将在实际构建中编译为零指令,运行速度无限快。或者更现实地说,每次迭代运行 1 个周期而不是大约 6 个周期,即使没有优化或进行重大转换。


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

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

因此,随着循环中的工作越来越多,addl $1, -12(%rbp) 在背靠背运行时可以维持每 6 个周期一个吞吐量,但可能只会产生每 4 或 5 个周期一个迭代的瓶颈。强>

根据from a 2013 blog post 的测量结果,这种效应显然发生在 Sandybridge 和 Haswell(不仅仅是 Skylake)上,所以是的,这也是您的 Broadwell i5-5257U 上最可能的解释。似乎这种影响发生在所有 Intel Sandybridge 系列 CPU 上


如果没有关于您的测试硬件、编译器版本(或内部循环的 asm 源代码)、以及两个版本的绝对和/或相对性能数字的更多信息,此是我对解释的最佳省力猜测。在我的 Skylake 系统上进行基准测试/分析 gcc -O0 并不够有趣,无法亲自尝试。下一次,包括计时数字。


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

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


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

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

gcc -O0 调整源代码是没有用的。 使用 -O3 或项目使用的默认构建脚本的任何选项进行测量。

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


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

【讨论】:

  • @PeterCordes 顺便说一句,实际上,我在 Broadwell i5-5257U 而不是 skylake 上做所有事情。这是否意味着 Broadwell 可能具有相同的机制?
  • @helloqiu - 我不认为这个问题没用。您从不进行优化的编译开始处于一个很大的劣势,这已经是“为什么 Y 的性能表现得像 Z”的一个巨大的危险信号 - 但是由于编译器只为您的较慢情况发出额外的指令,事实证明这是一个有趣的大会级别的问题。即,您几乎可以删除问题的 C 起源,以及您在没有优化的情况下编译的事实,并询问程序集的行为,并可能避免投票雪崩。
  • @BeeOnRope:注意call/ret不会创建循环携带的依赖,因为call推送的地址来自推测执行+分支预测。当存储不依赖于加载时,对同一地址的多次存储/重新加载可以维持每个时钟一次。执行ret 指令每个时钟可以执行一个,比call 指令晚5 个周期。 (好吧,当然 call/ret 都是分支,因此它们相互竞争执行资源,因此甚至不会成为内存瓶颈。)可能的问题是push/pop rbp ,或x=foo(x) 参考。
  • @helloqiu:这不是性能的运作方式。乱序流水线 CPU 意味着总运行时间不仅仅是每条指令自己花费多长时间的总和。有关吞吐量、延迟和执行端口瓶颈的更多信息,请参阅stackoverflow.com/questions/45113527/…。此外,perf 使用的硬件计数器精度有限,请参阅stackoverflow.com/questions/48369347/…
  • 在大多数新硬件上cycles:ppp 应该具有很高的准确性。
猜你喜欢
  • 1970-01-01
  • 2015-04-26
  • 1970-01-01
  • 2020-11-11
  • 1970-01-01
  • 1970-01-01
  • 2013-07-16
  • 2018-04-06
相关资源
最近更新 更多