为什么 for 循环体中的一个基本算术运算执行得比两个算术运算慢?

Oli*_*ort 15 c++ performance assembly cpu-architecture google-benchmark

当我尝试测量算术运算的执行时间时,我遇到了非常奇怪的行为。包含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;
}
Run Code Online (Sandbox Code Playgroud)

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

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

我在https://godbolt.org/上检查了汇编输出,但一切看起来都符合我的预期:第二个块在汇编输出中又多了一个操作。

三个操作总是按预期运行:它们比1慢,比4快。那么为什么两个操作会产生这样的异常呢?

编辑:

让我再说一遍:我在所有未优化代码的 Windows 和 Unix 机器上都有这种行为。我查看了我执行的程序集(Visual Studio、Windows),我看到了我想在那里测试的说明。无论如何,如果循环被优化掉了,我在剩下的代码中就没有什么可问的了。我在问题中添加了优化通知,以避免“不测量未优化的代码”的答案,因为优化不是我要问的。问题实际上是为什么我的计算机执行两个操作比一个快,首先是在这些操作没有被优化掉的代码中。在我的测试中,执行时间的差异是 5-25%(非常明显)。

Pet*_*des 10

这种效果只发生在-O0(或与volatile),并且是编译器将变量保存在内存中(而不是寄存器)的结果。 你会想到,仅仅引入额外的延迟固定量为通过循环搬运依存链ixy,但现代的CPU并不那么简单。

在 Intel Sandybridge 系列 CPU 上,当加载 uop 在重新加载其数据的存储之后运行一段时间时,存储转发延迟会降低,而不是立即。 因此,循环计数器在内存中的空循环是最坏的情况。我不明白哪些 CPU 设计选择会导致这种微架构怪癖,但这是真实存在的。

这基本上是在没有优化的情况下编译时添加冗余分配加速代码的重复,至少对于英特尔 Sandybridge 系列 CPU。

这是您不应该在 处进行基准测试的-O0主要原因之一:瓶颈实际优化的代码中的瓶颈不同。请参阅为什么 clang 使用 -O0 产生效率低下的 asm(对于这个简单的浮点和)?有关为什么编译器故意制作如此糟糕的 asm 的更多信息。

微基准测试很难;如果您可以让编译器为您要测量的对象发出实际优化的 asm 循环,则您只能正确地测量某些内容。(即使这样,您也只是测量吞吐量延迟,而不是两者;对于乱序流水线 CPU 上的单个操作,这些是单独的事情:预测现代超标量处理器上的操作的延迟需要考虑哪些因素以及我如何计算它们用手?

有关将变量保存在寄存器中的循环会发生什么的测量和解释,请参阅@rcgldr 的答案

使用 clang,benchmark::DoNotOptimize(x1 += 31)也可以取消优化以保留x在内存中,但使用 GCC,它确实只保留在寄存器中。不幸的是,@SashaKnorre 的回答在 QuickBench 上使用了 clang,而不是 gcc,以获得与您的-O0asm类似的结果。它确实显示了通过内存被瓶颈隐藏的大量短 NOP 的成本,并且当这些 NOP 延迟重新加载下一次迭代的时间刚好足以让存储转发达到较低延迟的好情况时,速度会略有提高。(我认为 QuickBench 在 Intel Xeon 服务器 CPU 上运行,每个 CPU 内核内部的微架构与同代桌面版本相同。)


大概你测试过的所有 x86 机器都使用了过去 10 年的 Intel CPU,否则对 AMD 也有类似的影响。如果您的测量在那里确实有意义,那么您的 RPi 使用的任何 ARM CPU 都会产生类似的影响。否则,可能是另一种情况,看到您的预期(确认偏差),特别是如果您在那里启用优化进行测试。


我用不同级别的代码优化 ( -O0, -O1, -O2, -O3)对此进行了测试[...] 但我总是得到类似的结果

我在问题中添加了优化通知,以避免“不测量未优化的代码”的答案,因为优化不是我要问的。

(稍后来自评论)关于优化:是的,我用不同的优化级别复制了它,但是随着循环被优化掉,执行时间太快了,无法确定。

所以实际上你并没有重现这种效果-O1或更高,你只是看到了你想看到的(确认偏差),并且主要是声称效果是相同的。如果您准确地报告了您的数据(可测量的效果在-O0,空的时间区域在-O1和更高),我可以立即回答。

请参阅绩效评估的惯用方式?- 如果您的时间没有随着重复次数的增加而线性增加,那么您没有测量您认为您正在测量的内容。此外,启动效应(如冷缓存、软页面错误、惰性动态链接和动态 CPU 频率)很容易导致第一个空的定时区域比第二个慢。

我假设您只在 at 测试时交换了循环-O0,否则您将排除-O1该测试代码对 at或更高的任何影响。


启用优化的循环:

正如您在 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
Run Code Online (Sandbox Code Playgroud)

因此,定时区域中唯一的指令是保存start到调用保留寄存器。你几乎没有测量你的源代码。

使用 Google Benchmark,我们可以获得不会优化工作但不会存储/重新加载以引入新瓶颈的 asm

#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);
Run Code Online (Sandbox Code Playgroud)
# 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)
Run Code Online (Sandbox Code Playgroud)

我假设benchmark::DoNotOptimize类似于asm volatile("" : "+rm"(x) )GNU C inline asm)使编译器x在寄存器或内存中具体化,并假设左值已被该空的 asm 语句修改。(即忘记它对值的任何了解,阻止常量传播,CSE 等等。)这将解释为什么在 GCC 选择寄存器时 clang 存储/重新加载到内存:这是一个长期存在的错过优化错误,具有 clang 的内联 asm 支持. 它喜欢在有选择的情况下选择内存,有时您可以使用"+r,m". 但不是在这里;我不得不放弃内存替代方案;无论如何,我们不希望编译器溢出/重新加载到内存中。

对于 GNU C 兼容编译器,我们可以asm volatile手动使用仅具有"+r"寄存器约束的 clang 来制作良好的标量 asm ( 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));
  }
}
Run Code Online (Sandbox Code Playgroud)

所有这些都应该在现代 Intel 和 AMD CPU 上以每次迭代 1 个时钟周期运行,再次参见 @rcgldr 的答案。

当然,这也禁用了 SIMD 的自动矢量化,编译器在许多实际用例中都会这样做。或者,如果您在循环之外完全使用结果,它可能会将重复增量优化为单个乘法。

您无法+在 C++ 中衡量运算符的成本- 它可以根据上下文/周围代码进行非常不同的编译。即使不考虑提升机工作的循环不变的东西。例如,x + (y<<2) + 4可以编译为 x86 的单个 LEA 指令。


问题实际上是为什么我的计算机执行两个操作比一个快,首先是在这些操作没有被优化掉的代码中

TL:DR:这不是操作,而是通过内存的循环携带依赖链,它阻止 CPU 以每次迭代 1 个时钟周期运行循环,在单独的执行端口上并行执行所有 3 个添加。

请注意,循环计数器增量与您正在执行的操作x(有时y)一样多。


Adr*_*thy 6

ETA: 这是一个猜测,Peter Cordes 就为什么不正确提出了很好的论据。去投票彼得的回答。

我在这里留下我的答案,因为有些人发现这些信息很有用。虽然这不能正确解释 OP 中看到的行为,但它突出了一些问题,这些问题使得尝试在现代处理器上测量特定指令的速度变得不可行(且毫无意义)。


有根据的猜测:

这是流水线、降低内核部分电源和动态频率缩放的综合效果。

现代处理器流水线,以便可以同时执行多条指令。这是可能的,因为处理器实际上是在微操作上工作,而不是我们通常认为是机器语言的汇编级指令。处理器通过将微操作分派到芯片的不同部分来“调度”微操作,同时跟踪指令之间的依赖关系。

假设运行您的代码的核心有两个算术/逻辑单元 (ALU)。一遍遍重复的单个算术指令只需要一个 ALU。使用两个 ALU 没有帮助,因为下一个操作取决于当前操作的完成,所以第二个 ALU 只会等待。

但是在您的双表达式测试中,表达式是独立的。要计算 的下一个值y,您不必等待当前操作x完成。现在,由于具有省电功能,第二个 ALU 可能会首先断电。在意识到它可以使用第二个 ALU 之前,内核可能会运行几次迭代。此时,它可以启动第二个 ALU,并且大部分双表达式循环的运行速度将与单表达式循环一样快。因此,您可能希望这两个示例花费的时间大致相同。

最后,许多现代处理器使用动态频率缩放。当处理器检测到它没有在努力运行时,它实际上会稍微减慢其时钟以节省电量。但是当它被大量使用时(并且芯片的当前温度允许),它可能会将实际时钟速度提高到其额定速度。

我认为这是通过启发式方法完成的。在第二个 ALU 保持断电的情况下,启发式可能会决定不值得提高时钟。在两个 ALU 上电并以最高速度运行的情况下,它可能决定提高时钟。因此,双表达式情况本应与单表达式情况一样快,但实际上以更高的平均时钟频率运行,使其能够在略短的时间内完成两倍的工作。

鉴于您的数字,差异约为 14%。我的 Windows 机器在大约 3.75 GHz 空闲,如果我通过在 Visual Studio 中构建解决方案稍微推动它,时钟会攀升到大约 4.25GHz(在任务管理器中查看性能选项卡)。这是时钟速度的 13% 差异,所以我们在正确的范围内。

  • 该情况可以用固定频率再现,因此不是由频率缩放引起的。“因此,您可能认为这两个示例花费的时间大致相同。”。它不需要相同的时间,但是两个操作版本**更快**。 (3认同)
  • 我可以在我的机器上以固定频率重现它。但即使没有固定的频率,如果你的理论是正确的,那么改变测试的顺序应该会改变哪个版本更快。但它并没有改变。快速工作台也可以重现它:http://quick-bench.com/Qu1l1gOrIlfyd_z9BQcxrw97YSU (2认同)

rcg*_*ldr 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 Pro 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
Run Code Online (Sandbox Code Playgroud)

我还测试了将立即添加到寄存器,1% 或 2 个寄存器在 1% 内(两者都可以更快,但我们希望它们都在 Ivy Bridge 上以 1 次迭代/时钟执行,因为它有 3 个整数 ALU 端口;考虑什么预测现代超标量处理器上的操作的延迟以及如何手动计算它们?)。

3 个寄存器长 1.5 倍,比理想的 1.333 个周期/迭代从 4 个 uops(包括循环计数器宏融合 dec/jnz)差一些,用于具有完美调度的 3 个后端 ALU 端口。

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
Run Code Online (Sandbox Code Playgroud)


归档时间:

查看次数:

863 次

最近记录:

5 年,12 月 前