基准测试,代码重新排序,挥发性(benchmarking, code reordering, vol

2019-07-20 09:53发布

我决定我想基准特定的功能,所以我天真地写这样的代码:

#include <ctime>
#include <iostream>

int SlowCalculation(int input) { ... }

int main() {
    std::cout << "Benchmark running..." << std::endl;
    std::clock_t start = std::clock();
    int answer = SlowCalculation(42);
    std::clock_t stop = std::clock();
    double delta = (stop - start) * 1.0 / CLOCKS_PER_SEC;
    std::cout << "Benchmark took " << delta << " seconds, and the answer was "
              << answer << '.' << std::endl;
    return 0;
}

一位同事指出,我要宣布startstop变量volatile避免代码重新排序。 他建议,优化可以,例如,有效地重新安排这样的代码:

    std::clock_t start = std::clock();
    std::clock_t stop = std::clock();
    int answer = SlowCalculation(42);

起初我怀疑这种极端的重排是允许的,但一些研究和实验后,我才知道,那是。

但挥发性不觉得合适的解决方案; 不挥发真的只是内存映射I / O?

尽管如此,我加入volatile ,发现不仅没有基准显著需要更长的时间,这也是不一致似地从试验运行。 无挥发物(和幸运得到保证代码没有重新排序),基准持续了600-700毫秒。 挥发性,它经常带着1200毫秒,有时甚至超过5000毫秒。 这两个版本的反汇编代码列表显示,几乎比一个不同的选择寄存器其他没有什么区别。 这使我不知道是否有另一种方式来避免代码重新排序不具备这样的压倒性的副作用。

我的问题是:

什么是防止代码重新排序基准测试这样的代码的最佳方式?

我的问题是类似于这一个 (这是关于使用挥发性避免省音,而不是重新排序), 这一个 (其中没有回答如何防止重新排序),和这一个 (其中讨论该问题是否代码重新排序或死码消除)。 虽然三者都是在这个确切的话题,没有真正回答我的问题。

更新 :答案似乎是,我的同事弄错了,并像这样的重新排序是不符合标准一致。 我upvoted大家谁这么说的和我颁发赏金给马克西姆。

我见过一个情况下(基于代码这个问题 ),其中Visual Studio 2010中重新排序的时钟要求我说明(仅适用于64位版本)。 我试图做一个最小的案例来说明,这样我可以提交有关Microsoft连接的错误。

对于那些谁说,挥发性要慢得多,因为它迫使读取和写入内存,这是不是与所发出的代码相当一致。 在我的回答这个问题 ,我显示了有和无挥发性的代码中的拆卸。 在循环中,所有事情都保持在寄存器中。 唯一的显著差异似乎寄存器选择。 而挥发的版本是不一致的 (有时显着)慢我不明白x86汇编不够好,知道为什么的非易失性版本的性能一直快速前进。

Answer 1:

一位同事指出,我要声明的启动和停止变量挥发性避免代码重新排序。

很抱歉,但你的同事是错误的。

编译器不会重新排序到其定义不可用在编译时函数的调用。 只要想象一下,如果编译器重新排序,这样的电话,因为这将随之而来的欢闹forkexec或移动围绕这些代码。

换句话说,没有定义的任何功能是一个编译时间内存屏障,即,编译器不移动该呼叫后,呼叫或先前语句之前后续语句。

在代码中调用std::clock最终调用它的定义是不具备的功能。

我不能建议不够看原子武器:C ++内存模型和现代化的硬件 ,因为它论述了误解(编译时)内存壁垒和volatile其中包括许多其他有用的东西。

尽管如此,我加入挥发性,发现不仅没有基准显著需要更长的时间,这也是不一致似地从试验运行。 无挥发物(和幸运得到保证代码没有重新排序),基准持续了600-700毫秒。 挥发性,它经常带着1200毫秒,有时甚至超过5000毫秒

不知道如果volatile到这里怪。

报告的运行时间取决于基准是如何运行的。 请确保您禁用CPU频率调节,使其无法开启超频模式或开关频率运行的中间。 此外,微基准测试应运行实时优先级的进程,以避免调度噪声。 这可能是另一个运行过程中的一些背景文件索引开始与你的标杆竞争CPU时间。 请参阅此了解更多详情。

一个好的做法是衡量它需要执行功能的次数,并报告最小/平均/平均/最大/ STDEV /总时间数倍。 高标准偏差可以指示上述制剂不被执行。 第一次运行通常是最长的,因为CPU缓存可能是冷的,它可能需要许多高速缓存未命中和页面错误,并从第一次调用共享库解析动态符号(延迟符号分辨率为默认的运行时间在Linux上链接方式,例如),而后续调用会用少得多的开销来执行。



Answer 2:

通常的方法,以防止重新排序是编译屏障即asm volatile ("":::"memory"); (用gcc)。 这是一个汇编指令,什么也不做,但我们告诉编译器会揍的记忆,因此不允许通过它重新排序的代码。 这样做的成本仅取出重新排序,这显然不是改变优化级别等与其他地方一样建议的情况下的实际成本。

我相信_ReadWriteBarrier相当于微软的东西。

每马克西姆Yegorushkin的答案,重新排序是不太可能的您的问题的原因虽然。



Answer 3:

你可以做两个C文件, SlowCalculation编译g++ -O3 (优化的高级别),并与编制的基准1个g++ -O1 (较低的水平,还是优化-这可能足以为基准的部分)。

根据该男子页 ,代码重新排序过程中会发生-O2-O3优化级别。

由于优化的编译过程中发生的,不联动,基准方不应由代码重新排序的影响。

假设你使用g++ -但应该是等值的其他编译器。



Answer 4:

在C ++中做到这一点,正确的方法是使用一 ,比如像

class Timer
{
    std::clock_t startTime;
    std::clock_t* targetTime;

public:
    Timer(std::clock_t* target) : targetTime(target) { startTime = std::clock(); }
    ~Timer() { *target = std::clock() - startTime; }
};

并使用它像这样:

std::clock_t slowTime;
{
    Timer timer(&slowTime);
    int answer = SlowCalculation(42);
}

你要知道,我真的不相信你的编译器永远不会重新排序是这样的。



Answer 5:

挥发性保证一两件事,只有一两件事:从volatile变量读取将被从内存中每次读 - 编译器不会假定值可以从寄存器中缓存。 同样地,写入将通过内存写入。 编译器不会把它绕在寄存器“了一回,写出来给内存之前”。

为了防止编译器重新排序你可以使用所谓的编译器围栏。 MSVC包括3个编译器围栏:

_ReadWriteBarrier() - 全栅栏

_ReadBarrier() - 对负载双面围栏

_WriteBarrier() - 用于存储双面围栏

ICC包括__memory_barrier()全栅栏。

全栅栏通常是最好的选择,因为在这个层面上更精细的粒度没有必要(编译栅栏基本上都在运行时无成本)。

Statment重新排序(启用优化时,大多数编译器一样),那也将主要的原因,当编译器编译optimzation某些程序无法运行操作。

将建议阅读http://preshing.com/20120625/memory-ordering-at-compile-time看,我们可以用编译器reoredering等面临的潜在问题



Answer 6:

有一对夫妇的,我能想到的办法。 我们的想法是创建编译时的障碍,使编译器不会重新排列一组指令。

一种可能的方法,以避免重新排序。将执行不能由编译器来解决(例如指针传递给函数,并使用在以后的指令指针)指令之间的依赖性。 我不知道怎么会影响到实际的代码,你有兴趣的基准的表现。

另一种可能性是使函数SlowCalculation(42); 一个extern功能(在不同的.c / .cpp文件中定义此函数并将该文件链接到你的主程序),并宣布startstop为全局变量。 我不知道什么是你的编译器的链接时/过程间的优化提供了优化。

另外,如果你在编译或O1 O0,最有可能的编译器不会理会重新排序的指令。 你的问题具有一定的相关( 编译时的障碍-编译代码重新排序- gcc和并行线程 )



Answer 7:

相关的问题:如何从提升一个微小的重复计算了一个循环的停止编译

我找不到任何地方这样 - 所以加入我自己的答案有人问后11年)。

对变量使用volatile是不是你想要的东西了点。 这将导致编译器加载和那些可变从存储和向RAM每一次(假设的一个副作用是必须被保留:又名 - 好为I / O寄存器)。 当你替补标志着你不感兴趣的测量需要多长时间从内存中得到的东西,或者把它写在那里。 通常你只是想你的变量是CPU寄存器。

volatile是可用的,如果你不得到优化(如求和的阵列)的循环以外分配给它一次 ,以替代打印结果。 (如你问题的长期运行的功能)。 但不是一个小环道内侧 , 将引入存储/重新加载指令和存储转发时延。


我认为,提交你的编译器不将优化基准代码到地狱的唯一方法是通过使用asm 。 这可以让你的编译器误以为它不知道你的变量的内容,或使用任何东西,所以它做的一切每一次,经常为你的循环要求它。

例如,如果我想基准m & -m其中m为一些uint64_t ,我可以尝试:

uint64_t const m = 0x0000080e70100000UL;
for (int i = 0; i < loopsize; ++i)
{
  uint64_t result = m & -m;
}

编译器显然会说:我还没有打算来计算; 因为你不使用的结果。 阿卡,它实际上做的:

for (int i = 0; i < loopsize; ++i)
{
}

然后,你可以试试:

uint64_t const m = 0x0000080e70100000UL;
static uint64_t volatile result;
for (int i = 0; i < loopsize; ++i)
{
  result = m & -m;
}

和编译器说,OK - 所以你要我写,导致每一次做

uint64_t const m = 0x0000080e70100000UL;
uint64_t tmp = m & -m;
static uint64_t volatile result;
for (int i = 0; i < loopsize; ++i)
{
  result = tmp;
}

花了很多时间来写作到的内存地址result loopsize倍,就像你问。

最后,你也可以让m波动,但结果是这样的组装:

507b:   ba e8 03 00 00          mov    $0x3e8,%edx
  # top of loop
5080:   48 8b 05 89 ef 20 00    mov    0x20ef89(%rip),%rax        # 214010 <m_test>
5087:   48 8b 0d 82 ef 20 00    mov    0x20ef82(%rip),%rcx        # 214010 <m_test>
508e:   48 f7 d8                neg    %rax
5091:   48 21 c8                and    %rcx,%rax
5094:   48 89 44 24 28          mov    %rax,0x28(%rsp)
5099:   83 ea 01                sub    $0x1,%edx
509c:   75 e2                   jne    5080 <main+0x120>

从内存中读取两次,一次写入到它,除了与注册要求的计算。

因此,要做到这一点,正确的做法是

for (int i = 0; i < loopsize; ++i)
{
  uint64_t result = m & -m;
  asm volatile ("" : "+r" (m) : "r" (result));
}

这导致汇编代码( 从上Godbolt编译探险gcc8.2 ):

 # gcc8.2 -O3 -fverbose-asm
    movabsq $8858102661120, %rax      #, m
    movl    $1000, %ecx     #, ivtmp_9     # induction variable tmp_9
.L2:
    mov     %rax, %rdx      # m, tmp91
    neg     %rdx            # tmp91
    and     %rax, %rdx      # m, result
       # asm statement here,  m=%rax   result=%rdx
    subl    $1, %ecx        #, ivtmp_9
    jne     .L2
    ret     

这样的循环中正好三个要求的汇编指令,加上循环的开销子和JNE。

这里的技巧是,通过使用asm volatile 1,告诉编译器

  1. "r"输入操作数:它采用的值result作为输入,以便编译器必须兑现它在寄存器中。
  2. "+r"输入/输出操作数: m停留在相同的寄存器,但是(潜在的)改性。
  3. volatile :它有一些神秘副作用和/或不是所输入的纯函数; 作为源确实编译器必须执行它多次。 这迫使编译器独自在循环中留下您的测试片段。 请参阅GCC手册的扩展ASM#挥发性部分。

脚注1: volatile这里需要或编译器将它变成一个空的回路。 非易失性ASM(具有任何输出操作数)被认为是其输入可被优化掉如果结果是未使用的纯函数。 或者,如果以相同的输入多次使用CSED只运行一次。


下面一切都不是mine--和我不一定同意。 --Carlo木

如果曾使用asm volatile ("" : "=r" (m) : "r" (result));"=r"只写输出 ),编译器可能选择相同的寄存器mresult ,创建测试的等待时间循环携带依赖关系链,不吞吐量的计算。

从这一点,你会得到这样ASM:

5077:   ba e8 03 00 00          mov    $0x3e8,%edx
507c:   0f 1f 40 00             nopl   0x0(%rax)    # alignment padding
  # top of loop
5080:   48 89 e8                mov    %rbp,%rax    # copy m
5083:   48 f7 d8                neg    %rax         # -m
5086:   48 21 c5                and    %rax,%rbp    # m &= -m   instead of using the tmp as the destination.
5089:   83 ea 01                sub    $0x1,%edx
508c:   75 f2                   jne    5080 <main+0x120>

这将在1次迭代每2或3个循环运行(取决于CPU是否具有MOV消去或没有。)的版本,而无需循环携带依赖性可以在1上的Haswell后来和Ryzen运行每个时钟周期。 这些CPU都具有ALU吞吐量运行每时钟周期至少4个微指令。

该ASM对应于C ++,看起来像这样:

for (int i = 0; i < loopsize; ++i)
{
  m = m & -m;
}

通过与一个只写输出的制约误导编译器,我们创建ASM这看起来并不像源(这看上去就像是从计算恒定一个新的结果每次迭代中,不使用结果作为输入到下迭代..)

您可能延迟微基准,这样你就可以更方便地检测与编译的利益-mbmi-march=haswell让编译器使用blsi %rax, %rax和计算m &= -m; 在一个指令。 但它更容易跟踪你在做什么,如果C ++源具有相同的依赖作为asm,而不是愚弄编译成引入新的依赖。



Answer 8:

重新排序由你的同事描述刚刚突破1.9 / 13

之前测序是不对称的,传递,成对由单个线程(1.10),其诱导这些评估中的局部顺序执行评价之间的关系。 鉴于任何两个评估A和B,如果A是B之前测序,那么A的执行应先B.如果A的执行B之前没有测序和B不是测序之前A,那么A和B是未测序。 [注意:未测序评估的执行可以重叠。 末端音符]评价A和B当任一A是B或B A之前进行测序之前测序是不定测序,但它是未指定的哪个。 [注:不定测序评估不能重叠,但无论哪种,可以先执行。 末端注]

所以基本上你不应该考虑重新排序,而你不使用线程。



文章来源: benchmarking, code reordering, volatile