8

我发现一个有趣的现象:

#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 生成的程序集。两个汇编代码之间的唯一区别是,在更快的汇编代码之前还有两个操作:在此处输入图像描述
addl $1, -12(%rbp)

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

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


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

4

1 回答 1

23

TL:DR:如果重新加载不尝试“立即”发生,Sandybridge 系列存储转发具有较低的延迟。添加无用代码可以加速调试模式循环,因为-O0反优化代码中的循环延迟瓶颈几乎总是涉及存储/重新加载某些 C 变量
这种减速的其他例子:超线程调用空函数通过指针访问变量
显然也在低功率 Goldmont 上,除非有不同的原因需要额外的负载帮助。

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


您正在对调试版本进行基准测试,这基本上是无用的。它们具有与优化代码不同的瓶颈,而不是统一的减速。


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

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

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


瓶颈可能是循环携带的依赖k,带有存储/重新加载和add增量。大多数 CPU 上的存储转发延迟通常约为 5 个周期。因此,您的内部循环仅限于每 ~6 个周期运行一次,即 memory-destination 的延迟add

如果您使用的是 Intel CPU,当重新加载无法立即执行时,存储/重新加载延迟实际上可以更低(更好)。在依赖对之间有更多独立的加载/存储可能会在您的情况下解释它。请参阅函数调用比空循环更快的循环

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

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


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


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

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


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

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

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

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


存储转发延迟可能是实际(优化的)编译器输出中的一个问题,特别是如果您没有使用链接时间优化 (LTO) 让微小的函数内联,尤其是通过引用传递或返回任何内容的函数(所以它有通过内存而不是寄存器)。缓解这个问题可能需要一些技巧,比如volatile如果你真的想在英特尔 CPU 上解决它,并且可能会使其他一些 CPU 上的情况变得更糟。见评论中的讨论

于 2018-03-09T09:22:17.727 回答