为什么在for循环体中执行一个基本算术操作比执行两个算术操作要慢?

15

在我尝试测量算术运算执行时间的过程中,我遇到了非常奇怪的行为。一个代码块包含一个 for 循环,在循环体中只有一次算术运算,总是比一个完全相同的代码块慢执行,但是在 for 循环体中有两个算术运算。这是我测试的代码:

#include <iostream>
#include <chrono>

#define NUM_ITERATIONS 100000000

int main()
{
    // Block 1: one operation in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    // Block 2: two operations in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    return 0;
}

我使用不同的代码优化级别(-O0-O1-O2-O3)进行测试,并使用不同的在线编译器(例如onlinegdb.com),在我的工作机器、家用PC和笔记本电脑、树莓派和同事的计算机上进行测试。我重新排列了这两个代码块,重复了它们,改变了常量,改变了操作(+-<<=等),改变了整数类型。但是我始终得到类似的结果:循环中有一行的块比有两行的块

1.05681秒。x,y = 3100000000,0
0.90414秒。x,y = 1700000000,-3700000000

我在https://godbolt.org/上检查了汇编输出,但一切都像我预期的那样:第二个块在汇编输出中只有一个更多的操作。

三个操作总是表现如预期的那样:它们比一个操作慢,比四个操作快。那么为什么两个操作会产生这样的异常?

编辑:

让我重申一遍:我在所有未经优化的 Windows 和 Unix 机器上都有这样的行为。我查看了我执行的汇编(Visual Studio,Windows),并在那里看到要测试的指令。无论如何,如果循环被优化掉,剩下的代码中就没有我要询问的内容。我在问题中添加了优化通知,以避免出现“不要测量未经优化的代码”的答案,因为优化不是我所问的。实际上,问题是为什么我的计算机在执行两个操作时比一个操作更快,尤其是在这些操作没有被优化掉的代码中。在我的测试中,执行时间差异为5-25%(非常明显)。


1
无法在 Quickbench 上重现。链接 - François Andrieux
4
@Oliort,您能否交换循环的顺序并重新进行测量?即先执行具有两个变量的循环,然后执行具有一个变量的循环。 - KamilCuk
3
编写两个程序并测试它们。在同一个程序/线程中比较两个循环,不太可能产生预期的结果。你的代码没有预热,因此你未能考虑指令/数据预读/缓存效应。你还有两个循环引用同一个变量,这将涉及一些处理器流水线操作。 - jwdonahue
2
可能是双操作循环触发了单操作循环没有的流水线特性。增量大小的差异也可能是一个因素,但我没有理论可以解释为什么会有影响。 - jwdonahue
3
我们对为什么要对未经优化的代码进行基准测试的担忧可以通过跳过源代码和编译,并询问为什么在各种硬件上为简单实现循环添加一个汇编指令会产生结果来减少。 - aschepler
显示剩余38条评论
5个回答

10
这种情况只会在使用-O0(或使用volatile)时发生,是由于编译器将变量保留在内存中(而不是寄存器)。你可能会认为这只会在循环依赖链中通过ixy引入固定量的额外延迟,但现代CPU并不是那么简单。
在Intel Sandybridge系列CPU上,当加载uop在存储其重新加载的数据的存储器之后一段时间运行时,存储转发延迟会降低,而不是立即运行。因此,循环计数器在内存中的空循环是最坏的情况。我不明白CPU设计选择如何导致这种微架构怪异,但它是一个真实存在的东西。
对于Intel Sandybridge系列CPU来说,这基本上是添加冗余赋值可以加快不使用优化编译的代码的一个副本。
这是为什么不应该在-O0下进行基准测试的主要原因之一:瓶颈与实际上已经优化的代码不同。请参见为什么clang使用-O0(对于这个简单的浮点求和)生成效率低下的asm?,了解有关为什么编译器故意生成如此糟糕的asm的更多信息。
微基准测试很难;只有当您可以让编译器为您要测量的内容发出实际上已经优化的asm循环时,才能正确地测量某些内容。即使这样,您只测量吞吐量或延迟,而对于乱序流水线CPU上的单个操作,这些是相互独立的事物:预测现代超标量处理器上操作的延迟需要考虑哪些因素,我该如何手动计算? 请参见@rcgldr的答案,了解保持变量在寄存器中的循环将会发生什么的测量和解释。
使用clang编译器,benchmark::DoNotOptimize(x1 += 31)会使得x继续保留在内存中,而使用GCC则会保留在寄存器中。不幸的是@SashaKnorre's answer在QuickBench上使用的是clang编译器而非gcc,以获取类似于您的-O0汇编结果的效果。它展示了许多短NOP指令的成本被隐藏在内存瓶颈中,当这些NOP指令恰好延迟下一次迭代的重新加载足够长时,可以略微提高速度以命中较低延迟的存储转发好情况。(我认为QuickBench运行在Intel Xeon服务器CPU上,每个CPU核心内部与同一代桌面版具有相同的微架构。)
据推测,您测试的所有x86机器都配备了过去10年的英特尔CPU,否则在AMD上会有类似的影响。如果您的测量确实具有意义,则您的RPI所使用的任何ARM CPU可能存在类似的影响。否则,也许又是看到了您期望的结果(确认偏见),特别是如果您在启用优化的情况下进行测试。
我用不同级别的代码优化(-O0-O1-O2-O3)进行了测试[...] 但我总是得到类似的结果。我在问题中添加了优化通知,以避免“不要测量未经优化的代码”答案,因为优化不是我所问的内容。
(稍后从评论中)关于优化:是的,我使用不同的优化级别重现了这一点,但由于循环被优化掉了,执行时间太快了,无法确定。
实际上,您并没有为 -O1 或更高版本重现此效果,您只是看到了您想要看到的东西(确认偏见),并且大多数情况下编造了相同的说法。如果您准确地报告了数据( -O0 具有可测量的效果,在 -O1 及更高版本中为空的定时区域),那么我可以立即回答。
请参见Idiomatic way of performance evaluation?-如果您的时间不随重复次数的增加而线性增加,则您正在测量您认为正在测量的内容。此外,启动效果(如冷缓存,软页面故障,懒惰的动态链接和动态CPU频率)很容易导致第一个空定时区域比第二个慢。
我假设您只在测试 -O0 时交换了循环,否则您将使用该测试代码排除在 -O1 或更高版本中存在任何影响的可能性。

启用优化的循环:

正如您在Godbolt上看到的,启用优化后,GCC完全删除了循环。有时GCC会保留空循环,比如它认为延迟是有意的,但这里它根本不循环。时间与任何东西都没有比例关系,两个计时区域看起来都是这样:

orig_main:
   ...
        call    std::chrono::_V2::system_clock::now()       # demangled C++ symbol name
        mov     rbp, rax                                    # save the return value = start
        call    std::chrono::_V2::system_clock::now()
        # end in RAX

因此,定时区域中唯一的指令是将start保存到一个调用保留寄存器中。您测量的实际上与您的源代码无关。

使用Google基准测试,我们可以获得不会优化工作但不会存储/重新加载以引入新瓶颈的汇编代码

#include <benchmark/benchmark.h>

static void TargetFunc(benchmark::State& state) {
   uint64_t x2 = 0, y2 = 0;
  // Code inside this loop is measured repeatedly
  for (auto _ : state) {
    benchmark::DoNotOptimize(x2 += 31);
    benchmark::DoNotOptimize(y2 += 31);
  }
}
// Register the function as a benchmark
BENCHMARK(TargetFunc);

# just the main loop, from gcc10.1 -O3 
.L7:                         # do{
        add     rax, 31        # x2 += 31
        add     rdx, 31        # y2 += 31
        sub     rbx, 1
        jne     .L7          # }while(--count != 0)

我认为benchmark :: DoNotOptimize 类似于asm volatile(“”:“+ rm”(x)) GNU C inline asm ),以使编译器将 x 实例化为寄存器或内存,并假定lvalue已被该空asm语句修改。 (即忘记它所知道的关于值的任何内容,阻止常量传播,CSE和其他内容。)这就解释了为什么clang将存储/重新加载到内存,而GCC选择寄存器:这是clang内联asm支持的长期未优化错误。当给出选择时,它喜欢选择内存,您有时可以通过多个备选约束(如"+ r,m")来解决此问题。但不是在这里;我必须放弃内存备选项;我们也不希望编译器溢出/重新加载到内存中。

对于GNU C兼容的编译器,我们可以手动使用asm volatile,只使用"+r"寄存器约束,以使clang生成良好的标量汇编(Godbolt),就像GCC一样。 我们得到一个基本相同的内部循环,有3个加法指令,最后一个是add rbx,-1 / jnz,可以进行宏融合。

static void TargetFunc(benchmark::State& state) {
   uint64_t x2 = 0, y2 = 0;
  // Code inside this loop is measured repeatedly
  for (auto _ : state) {
      x2 += 16;
      y2 += 17;
    asm volatile("" : "+r"(x2), "+r"(y2));
  }
}

现代英特尔和AMD CPU上每次迭代都应该以1个时钟周期运行,详见@rcgldr的答案。
当然,这也禁用了与SIMD自动向量化,编译器在许多实际用例中会执行此操作。或者如果您在循环之外使用结果,它可能会将重复的增量优化为单个乘法。
无法测量C++中“+”运算符的成本-它可以根据上下文/周围代码的不同而编译得非常不同。即使不考虑提升工作的循环不变量。例如,对于x86,x +(y << 2)+ 4可以编译为单个LEA指令。
问题实际上是为什么我的计算机在执行两个操作时比一个操作更快,首先在没有优化这些操作的代码中。

TL:DR:不是操作本身,而是通过内存传递的循环依赖链,阻止CPU以每次迭代1个时钟周期运行循环,在单独的执行端口上并行执行全部3个加法。

请注意,循环计数器的增量与您对x(有时y)所做的操作一样重要。{{因此,计算机需要从内存中获取数据和指令,这会造成延迟,导致无法同时执行多个操作。}}


6
注: 这只是一个猜测,Peter Cordes提出了非常好的反驳论点。请为Peter的答案点赞。
我保留我的答案,因为有些人发现这些信息有用。虽然这并不能正确地解释OP中看到的行为,但它强调了一些问题,使得在现代处理器上尝试测量特定指令的速度变得不可行(且无意义)。
受过教育的猜测:
这是流水线、关闭核心部分电源以及动态频率缩放的综合效果。
现代处理器通过流水线使多个指令可以同时执行。这是可能的,因为处理器实际上是按微操作而不是我们通常认为的汇编级指令来工作的。处理器通过将微操作分派到芯片的不同部分并跟踪指令之间的依赖关系来进行“调度”。
假设运行代码的核心具有两个算术/逻辑单元(ALU)。重复一次算术指令只需要一个ALU。使用两个ALU是没有帮助的,因为下一个操作取决于当前操作的完成情况,所以第二个ALU只会等待。
但是,在您的两个表达式测试中,表达式是独立的。要计算y的下一个值,您不必等待x上的当前操作完成。现在,由于节能功能,那个第二个ALU可能会首先关闭电源。核心可能会运行几次迭代,然后才意识到它可以利用第二个ALU。此时,它可以启动第二个ALU,并且大多数两个表达式的循环将像一个表达式的循环一样快速运行。因此,您可能希望这两个示例需要大约相同的时间。
最后,许多现代处理器使用动态频率缩放。当处理器检测到它没有使用时,它实际上会稍微降低时钟速度以节省功耗。但是当它使用率高(且芯片的当前温度允许)时,它可能将实际时钟速度增加到其额定速度的高峰。
我认为这是通过试探法来完成的。在第二个ALU保持关闭状态的情况下,试探法可能会决定不值得提高时钟速度。在两个ALU都已开启并以最高速度运行的情况下,它可能会决定提高时钟速度。因此,两个表达式的情况应该已经几乎与一个表达式的情况一样快,实际上平均时钟频率更高,使其在稍短的时间内完成了两倍的工作量。
根据您提供的数字,差异约为14%。我的Windows机器空闲时的时钟速度约为3.75 GHz,如果我通过在Visual Studio中构建一个解决方案来略微推动它,时钟会上升到约4.25GHz(根据任务管理器中的性能选项卡的估计)。这是时钟速度的13%差异,所以我们在正确的范围内。

1
那么,当操作系统(或BIOS)禁用频率缩放时,它就可以被证明。因此,在测量中使用类似于echo performance | sudo tee /sys//devices/system/cpu/cpu*/cpufreq/scaling_governor的命令是否会产生影响? - KamilCuk
3
该案例可以通过固定频率重现,因此不是由于频率调节引起的。"因此您可能期望这两个示例大致花费相同的时间。" 但实际上并没有花费相同的时间,而是两个操作版本更快。 - geza
2
我可以在我的机器上以固定的频率复现它。但是,即使没有固定的频率,如果您的理论是正确的,那么更改测试的顺序应该会改变哪个版本更快。但实际上并没有改变。quick-bench也可以复现它:http://quick-bench.com/Qu1l1gOrIlfyd_z9BQcxrw97YSU - geza
仅在运行功耗密集型或高IPC工作负载时将Turbo提升到最大并没有意义;延迟绑定的工作负载同样受益,您可以更长时间地保持更高的增强时钟。损坏是在编译时发生的,而且CPU的工作是尽可能快地运行笨重的代码。除非代码实际上花费时间等待来自核外缓存未命中(在不同的时钟域中,如果核心较慢,则会以同样快的速度工作),否则使CPU减速也是无意义的,这将是一种伤口上撒盐的行为。 - Peter Cordes
关闭一些端口可能效果不佳:在缓存未命中后,OoO执行通常是突发的。而且并非所有ALU都相等。例如,在Sandybridge系列上,只有端口1可以运行整数uops,延迟为3(如imul,slow-LEA,popcntbsf等)。只有端口6可以运行taken branch uops。只有端口0可以运行movd r32,xmm。只有端口5可以运行SIMD shuffles,movd xmm,r32。整数移位仅在p0 / p6上运行。LEA仅在p1 / p5上运行。此外,在您的分析中,您将OP的循环计算为1或2个操作。但实际上是2或3个;不要忘记循环计数器。 - Peter Cordes
显示剩余15条评论

5

我把代码分成了C++和汇编两部分。我只是想测试循环,所以没有返回总和。我在Windows上运行,调用约定是rcx, rdx, r8, r9,循环计数在rcx中。该代码将立即值添加到栈上的64位整数中。

两个循环耗时相似,变化小于1%,其中一个或两个的速度比另一个快1%左右。

这里存在明显的依赖因素:每次内存加法操作必须等待先前对同一位置的内存加法操作完成,因此可以基本并行地执行两次内存加法操作。

将test2更改为执行3次内存加法操作,结果变慢了约6%;4次内存加法操作,结果变慢了约7.5%。

我的系统是Intel 3770K 3.5 GHz CPU,Intel DP67BG主板,DDR3 1600 9-9-9-27内存,Win 7专业版64位,Visual Studio 2015。

        .code
        public  test1
        align   16
test1   proc
        sub     rsp,16
        mov     qword ptr[rsp+0],0
        mov     qword ptr[rsp+8],0
tst10:  add     qword ptr[rsp+8],17
        dec     rcx
        jnz     tst10
        add     rsp,16
        ret     
test1   endp

        public  test2
        align 16
test2   proc
        sub     rsp,16
        mov     qword ptr[rsp+0],0
        mov     qword ptr[rsp+8],0
tst20:  add     qword ptr[rsp+0],17
        add     qword ptr[rsp+8],-37
        dec     rcx
        jnz     tst20
        add     rsp,16
        ret     
test2   endp

        end

我还测试了将立即数添加到寄存器、使用1或2个寄存器,两种情况的执行时间在Ivy Bridge上都能达到每个时钟周期1次迭代的预期值(它有3个整数ALU端口; 现代超标量处理器预测操作延迟考虑哪些因素?如何手动计算?)。保持4个uop(包括循环计数器宏融合的dec/jnz)从后端的3个ALU端口完美调度,理想情况下每1.333个周期/迭代需要1.5倍长。

当涉及到3个寄存器时,需要1.5倍长时间,比理想情况下每个循环需要4个uop(包括循环计数器宏融合的dec/jnz)从后端的3个ALU端口完美调度所需的1.333个周期/迭代差一些。

当涉及到4个寄存器时,需要2.0倍长时间,因为瓶颈在前端:执行uop数量不是处理器宽度倍数的循环会降低性能吗?Haswell及更高的微架构会更好地处理这个问题。

        .code
        public  test1
        align   16
test1   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst10:  add     rdx,17
        dec     rcx
        jnz     tst10
        ret     
test1   endp

        public  test2
        align 16
test2   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst20:  add     rdx,17
        add     r8,-37
        dec     rcx
        jnz     tst20
        ret     
test2   endp

        public  test3
        align 16
test3   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst30:  add     rdx,17
        add     r8,-37
        add     r9,47
        dec     rcx
        jnz     tst30
        ret     
test3   endp

        public  test4
        align 16
test4   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst40:  add     rdx,17
        add     r8,-37
        add     r9,47
        add     r10,-17
        dec     rcx
        jnz     tst40
        ret     
test4   endp

        end

1
这是模拟未经优化的代码,使用内存目标加法。将变量优化为像gcc -O1或更高版本的寄存器将消除存储转发瓶颈。-O0情况可能会重复在没有优化编译的情况下添加冗余赋值可以加速代码 - Peter Cordes
@PeterCordes - 我也测试了这个(将立即数添加到寄存器而不是内存),结果类似。我更新了我的答案以展示这些例子。 - rcgldr
你的 Ivy Bridge CPU 有 3 个可以运行整数 ALU uops 的端口。它应该以 1/clock 的速度运行 2x add 和一个宏融合的 dec/jnz。这就解释了两个循环的性能相同。我不知道为什么你在内存版本中没有看到差异。但是对于寄存器,增加第三个 add 应该会在后端瓶颈,平均每次迭代约为 1.33c。增加第四个 add(总共 5 个 uops)应该会在前端瓶颈,变慢至每次迭代 2c,与 HSW 不同:循环的 uop 数量不是处理器宽度的倍数,性能是否会降低? - Peter Cordes
@PeterCordes - 3个寄存器的情况实际上每次迭代效率为1.5 c,4个寄存器的情况为每次迭代2.0c。对于将数据加入内存的情况,我认为瓶颈会是缓存/内存写入时间。我有Ivy Bridge CPU,但是Sandy Bridge主板(DP67BG)。 - rcgldr

2

@PeterCordes在很多假设上证明了这个答案是错误的,但它仍然可以作为解决问题的一种盲目研究尝试。

我设置了一些快速基准测试,认为它可能与代码内存对齐有关,真是一个疯狂的想法。

但是似乎@Adrian McCarthy用动态频率缩放的方法得到了正确的答案。

无论如何,基准测试表明,在Block 1中x+=31后插入一些NOPs可以帮助解决问题,15个NOPs几乎可以达到与Block 2相同的性能。令人惊讶的是,在单指令循环体中增加15个NOPs如何提高性能。

http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA

我还尝试了-OFast,认为编译器可能足够聪明,可以丢弃一些代码内存,插入这样的NOPs,但事实似乎并非如此。 http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX0

编辑:感谢@PeterCordes,清楚地表明了上面基准测试中优化从未像预期的那样工作(因为全局变量需要添加指令来访问内存),新的基准测试http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE清楚地显示Block 1和Block 2的性能对于堆栈变量是相等的。但是在循环访问全局变量的单线程应用程序中,NOPs仍然可以帮助,这种情况下你可能不应该使用全局变量,在循环后只需将全局变量分配给本地变量即可。

编辑2:实际上,由于快速基准测试宏使变量访问易失性,阻止了重要的优化,所以优化从未起作用。由于我们只在循环中修改变量,因此逻辑上只需要加载变量一次,因此易失性或禁用优化成为瓶颈。因此,这个答案基本上是错误的,但至少它展示了NOPs如何加速未经优化的代码执行,如果在现实世界中有任何意义(有更好的方法,如桶计数器)。


1
通常在循环之前插入NOP,而不是在其中插入,以对齐开始。您应该使用1或2个长NOP,每个NOP最多15字节,而不是多个短NOP,每个NOP都需要单独解码;这会测试前端和uop高速缓存。(或者,在具有Intel JCC错误的微代码解决方法的CPU上,使循环的结尾对齐,如果宏融合JCC接触到32字节边界,则会导致减速:32字节对齐例程不符合uops高速缓存)。顺便说一下,GCC/clang的“-Ofast”只是“-O3 -ffast-math”。 - Peter Cordes
1
使用 benchmark::DoNotOptimize(x1 += 31) 强制 x 即使在优化的情况下也要从内存中存储/重新加载。 (https://godbolt.org/z/ajs_7M 是从您的 QuickBench 链接简化而来的)。这就解释了为什么许多 NOPs 没有太大的差异:它们可以乱序执行,被存储转发的延迟所隐藏。您的版本是 在没有优化编译的情况下添加冗余赋值可以加速代码 的副本 - Intel Sandybridge 家族 CPU 具有可变延迟存储转发,如果您不尝试过早重新加载,则速度更快。 - Peter Cordes
1
我在QuickBench上使用你提供的链接时,从“记录反汇编”中得到了“错误或超时”的信息;Godbolt是唯一的选择。你在哪里看到内部循环中除了add qword ptr [rip + x2], 31之外的其他内容? - Peter Cordes
1
我通过删除一些函数(http://quick-bench.com/PyBaTT7vfcdKZRFHT8kEzzeh1oE)获得了QB汇编。它与Godbolt完全相同,但使用AT&T语法。请注意,在`nop`之前的`addq $0x1f,0x396b8(%rip)# 249850 <x1>指令是内存目标(因为您将它们设置为全局变量,所以是全局变量)。循环底部的add $0xffffffffffffffff,%rbx/jne`是循环计数器。这就是你之前看的东西吗? - Peter Cordes
1
@PeterCordes 我已经更新了答案以防止误导,非常好的评论先生! - Sasha Knorre
显示剩余5条评论

1
处理器如今变得非常复杂,我们只能猜测。

你的编译器发出的汇编代码并不是真正执行的代码。你的CPU的微码/固件/其他将对其进行解释,并将其转换为执行引擎的指令,就像C#或Java等JIT语言一样。

需要考虑的一件事是,对于每个循环,不仅有1或2个指令,而是n + 2个指令,因为您还要将i递增并将其与迭代次数进行比较。在绝大多数情况下这并不重要,但在这里很重要,因为循环体非常简单。

让我们看看汇编代码:

一些定义:

#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31

C/C++ :

for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM :

    mov     QWORD PTR [rbp-32], 0
.L13:
    cmp     QWORD PTR [rbp-32], 999999999
    jg      .L12
    add     QWORD PTR [rbp-24], 17
    add     QWORD PTR [rbp-32], 1
    jmp     .L13
.L12:

C/C++ :
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}

ASM:

    mov     QWORD PTR [rbp-80], 0
.L21:
    cmp     QWORD PTR [rbp-80], 999999999
    jg      .L20
    add     QWORD PTR [rbp-64], 17
    sub     QWORD PTR [rbp-72], 31
    add     QWORD PTR [rbp-80], 1
    jmp     .L21
.L20:

两个循环看起来非常相似。但是现代CPU的ALU可以处理比它们的寄存器大小更宽的值。因此,在第一种情况下,x和i的操作可能在同一个计算单元上执行。但是你必须再次读取i,因为你对这个操作的结果设置了条件。而阅读意味着等待。
因此,在第一种情况下,要迭代x,CPU可能必须与i的迭代同步。
在第二种情况下,也许x和y被处理在不同的单元中,而不是处理i的那个单元。因此,实际上,你的循环体是与驱动它的条件并行运行的。然后你的CPU就会一直计算,直到有人告诉它停止。如果它走得太远也没关系,回到几个循环还是可以接受的,相比刚刚节省的时间。
因此,为了比较我们想要比较的内容(一个操作与两个操作),我们应该设法将i排除在外。
一个解决方案是完全摆脱它,使用while循环: C/C++:
while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }

ASM:

.L15:
    movabs  rax, 16999999999
    cmp     QWORD PTR [rbp-40], rax
    jg      .L14
    add     QWORD PTR [rbp-40], 17
    jmp     .L15
.L14:

另外一种方法是使用过时的 "register" C 关键字: C/C++:
register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     ebx, 0
.L17:
    cmp     rbx, 999999999
    jg      .L16
    add     QWORD PTR [rbp-48], 17
    add     rbx, 1
    jmp     .L17
.L16:

这是我的结果:
x1 for:10.2985秒。 x,y = 17000000000,0
x1 while:8.00049秒。 x,y = 17000000000,0
x1 register-for:7.31426秒。 x,y = 17000000000,0
x2 for:9.30073秒。 x,y = 17000000000,-31000000000
x2 while:8.88801秒。 x,y = 17000000000,-31000000000
x2 register-for:8.70302秒。 x,y = 17000000000,-31000000000
代码在这里:https://onlinegdb.com/S1lAANEhI

现代CPU有APU(你的意思是ALU),它们可以处理比寄存器大小更宽的值。是的,但你必须手动使用SIMD,通过运行像PADDQ xmm0,xmm1这样的指令。CPU硬件不会为您融合和自动矢量化标量add指令。 https://stackoverflow.com/tags/sse/info - Peter Cordes
有关从汇编代码中猜测性能的更多背景信息,请参阅:现代超标量处理器上操作延迟预测需要考虑哪些因素,如何手动计算? - Peter Cordes
2
@PeterCordes,您用了一些强烈的措辞来表达技术观点。为了避免引起不必要的负面关注,您是否愿意重新表述一下? - Yunnosch
1
@PeterCordes,关于废话和now():是的,可能是这样。请查看我对您在我的问题上发表评论的回答。随意编辑。 - Oliort UA
1
@Yunnosch:错误地做出了错误的声明并不会使某个人成为坏人。正如 OP 所确认的,该声明确实是胡说八道。 或者用更中立的语言来表达,在 -O1 或更高版本的 GCC 中,它完全删除了循环,导致计时区域为空。 任何基于启动时间开销/噪音的结论都没有意义,并且与在 -O0 下在 Sandybridge 家族 CPU 上可见的真正效果(具有存储/重新加载瓶颈)完全无关。 - Peter Cordes
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接