基准测试,代码重新排序,易失性

时间:2013-02-23 14:21:05

标签: c++ benchmarking compiler-optimization volatile

我决定要对某个特定函数进行基准测试,所以我天真地编写这样的代码:

#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毫秒。除了不同的寄存器选择之外,两个版本的反汇编列表几乎没有差别。这让我想知道是否有另一种方法可以避免没有这种压倒性副作用的代码重新排序。

我的问题是:

  

在这样的基准测试代码中阻止代码重新排序的最佳方法是什么?

我的问题类似于this one(关于使用volatile来避免省略而不是重新排序),this one(没有回答如何防止重新排序),以及this one (辩论该问题是代码重新排序还是死代码消除)。虽然这三个都在这个确切的主题上,但实际上没有人回答我的问题。

更新:答案似乎是我的同事错了,而且这样的重新排序与标准不一致。我赞成所有这样说的人,并且正在向Maxim发放奖金。

我见过一个案例(基于this question中的代码),其中Visual Studio 2010重新排序时钟调用(如图所示)(仅限64位版本)。我正在尝试制作一个最小的案例来说明这一点,以便我可以在Microsoft Connect上提交错误。

对于那些认为volatile应该慢得多的因为它强制读取和写入内存的人来说,这与发出的代码并不完全一致。在我对this question的回答中,我展示了包含和不包含volatile的代码的反汇编。在循环内部,一切都保存在寄存器中。唯一显着的差异似乎是寄存器选择。我不太了解x86组装,以便知道为什么非易失性版本的性能始终快速,而易失性版本不一致(有时显着)慢。< / p>

8 个答案:

答案 0 :(得分:17)

  

一位同事指出,我应该将start和stop变量声明为volatile,以避免代码重新排序。

很抱歉,但你的同事错了。

编译器不会重新排序对编译时定义不可用的函数的调用。简单地想象一下,如果编译器重新排序forkexec这样的调用或者围绕这些调用移动代码,那么随之而来的欢闹就会发生。

换句话说,任何没有定义的函数都是编译时内存屏障,也就是说,编译器在调用之前或调用之后的语句之前不会移动后续语句。

在您的代码中调用std::clock最终调用其定义不可用的函数。

我不能建议观看atomic Weapons: The C++ Memory Model and Modern Hardware,因为它讨论了关于(编译时)内存障碍的误解和volatile以及许多其他有用的事情。

  

尽管如此,我添加了volatile,发现不仅基准测试需要更长时间,而且从运行到运行也非常不一致。没有易失性(并且很幸运,以确保代码没有重新排序),基准测试一直需要600-700毫秒。对于易失性,它通常需要1200毫秒,有时超过5000毫秒

不确定volatile是否应该归咎于此。

报告的运行时间取决于基准测试的运行方式。确保禁用CPU频率缩放,以便它不会打开turbo模式或在运行过程中切换频率。此外,微基准测试应作为实时优先级过程运行,以避免调度噪声。可能是在另一次运行期间,一些后台文件索引器开始与您的CPU时间基准竞争。有关详细信息,请参阅this

一个好的做法是测量多次执行该功能所需的时间并报告最小值/平均值/中值/最大值/标准值/总时间值。高标准偏差可能表明不进行上述准备。第一次运行通常是最长的,因为CPU缓存可能很冷,并且可能需要许多缓存未命中和页面错误,并且还在第一次调用时解析来自共享库的动态符号(惰性符号解析是Linux上的默认运行时链接模式) ,例如),而后续调用将以更少的开销执行。

答案 1 :(得分:2)

防止重新排序的常用方法是编译屏障,即asm volatile ("":::"memory");(使用gcc)。这是一个什么都不做的asm指令,但我们告诉编译器它会破坏内存,因此不允许对它进行重新排序。这样做的成本只是删除重新排序的实际成本,显然不是像其他地方那样改变优化级别等的情况。

我相信_ReadWriteBarrier等同于微软的东西。

根据Maxim Yegorushkin的回答,重新订购不太可能是您的问题的原因。

答案 2 :(得分:1)

您可以制作两个C文件,SlowCalculation使用g++ -O3编译(高级优化),基准编译使用g++ -O1(较低级别,仍然优化 - 可能是足够的基准部分)。

根据手册页,在-O2-O3优化级别期间重新排序代码。

由于优化在编译期间发生,而不是链接,因此基准面不应受代码重新排序的影响。

假设您正在使用g++ - 但在另一个编译器中应该有相同的东西。

答案 3 :(得分:1)

在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);
}

请注意,我实际上并不相信您的编译器会像这样重新排序。

答案 4 :(得分:1)

Volatile确保一件事,只有一件事:每次都会从内存中读取一个volatile变量的读取 - 编译器不会认为该值可以缓存在寄存器中。同样,写入将写入内存。编译器不会将它保留在寄存器中“暂时将其写入存储器”。

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

_ReadWriteBarrier() - 完整围栏

_ReadBarrier() - 负载的双面栅栏

_WriteBarrier() - 商店的双面围栏

ICC包括__memory_barrier()全围栏。

完全围栏通常是最佳选择,因为在此级别上不需要更精细的粒度(编译器围栏在运行时基本上是无成本的)。

规则重新排序(大多数编译器在启用优化时都会执行此操作),这也是某些程序在使用编译器优化进行编译时无法进行操作操作的主要原因。

建议阅读http://preshing.com/20120625/memory-ordering-at-compile-time以查看编译器重新编译等可能遇到的潜在问题。

答案 5 :(得分:1)

我可以想到几种方法。这个想法是创建编译时障碍,以便编译器不会重新排序一组指令。

避免重新排序的一种可能方法是强制执行编译器无法解析的指令之间的依赖关系(例如,将指针传递给函数并在后面的指令中使用该指针)。我不确定这会如何影响您对基准测试感兴趣的实际代码的性能。

另一种可能性是使函数SlowCalculation(42);成为extern函数(在单独的.c / .cpp文件中定义此函数并将文件链接到主程序)并声明{{1} }和start作为全局变量。我不知道编译器的链接时/进程间优化器提供了什么优化。

另外,如果你在O1或O0编译,很可能编译器不会打扰重新排序指令。 您的问题与(Compile time barriers - compiler code reordering - gcc and pthreads

有些相关

答案 6 :(得分:1)

相关问题:如何阻止编译器将微小的重复计算从循环中取消

我在任何地方都找不到它-所以在问了问题11年后再添加我自己的答案;)。

在变量上使用volatile不是您想要的。这将导致编译器每次都将这些变量从RAM中加载和存储到RAM中(假设有一个副作用必须保留:又名-适用于I / O寄存器)。当您进行基准测试时,您不需要测量从内存中获取或写入内存所花费的时间。通常,您只希望变量位于CPU寄存器中。

如果将

volatile分配给没有优化的循环(例如对数组求和)之外的一次,则可以使用

asm作为打印结果的替代方法。 (就像问题中长期运行的功能一样)。但不是内部一个小循环;将会介绍存储/重新加载指令和存储转发延迟。


我认为,将编译器提交为不优化基准测试代码的唯一方法是使用m & -m。这样一来,您就可以愚弄编译器,以为编译器对变量的内容或用法一无所知,因此它必须每次执行一次,只要您的循环需要这样做。

例如,如果我想对uint64_t进行基准测试,其中m是一些uint64_t const m = 0x0000080e70100000UL; for (int i = 0; i < loopsize; ++i) { uint64_t result = m & -m; } ,我可以尝试:

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

编译器显然会说:我什至不打算计算它。 因为您没有使用结果。 aka,实际上可以做到:

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

然后您可以尝试:

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));
}

除了请求的带有寄存器的计算之外,还从内存中读取两次并写入一次。

因此,正确的方法是

 # 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     

这将产生汇编代码(from gcc8.2 on the Godbolt compiler explorer):

asm volatile

完全在循环内执行三个请求的汇编指令,外加一个sub和jne来增加循环开销。

这里的技巧是使用"r" 1 并告诉编译器

  1. result输入操作数:它使用"+r"的值作为输入,因此编译器必须将其具体化在寄存器中。
  2. m输入/输出操作数:volatile保留在同一寄存器中,但(可能)被修改。
  3. volatile:它具有一些神秘的副作用,并且/或者不是纯粹的输入功能;编译器必须执行与源代码一样多次。这迫使编译器将您的测试代码片段放在循环内。请参阅gcc manual's Extended Asm#Volatile部分。

脚注1:此处需要asm volatile ("" : "=r" (m) : "r" (result));,否则编译器会将其变成空循环。非易失性asm(具有任何输出操作数)被视为其输入的纯函数,如果未使用结果,则可以对其进行优化。否则CSEd如果在相同的输入中多次使用,则只能运行一次。


下面的所有内容都不属于我-我不一定同意。 -卡洛·伍德

如果您使用"=r"具有m只写输出),则编译器可能会为result和{{1 }},创建一个循环承载的依赖链,以测试计算的延迟而不是吞吐量。

由此,您将获得此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>

这将以每2或3个周期1次迭代运行(取决于您的CPU是否具有消除运动的功能。)不具有循环依赖项的版本可以在Haswell及更高版本以及Ryzen上以每个时钟周期1次运行。这些CPU的ALU吞吐量可以在每个时钟周期至少运行4 oups。

此asm对应于如下所示的C ++:

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

通过用只写输出约束误导编译器,我们创建了看起来不像源的asm(看起来像是每次迭代都从常量中计算新结果,而不是将结果用作输入)到下一个迭代。.

您可能会想要进行微基准测试延迟,因此您可以更轻松地检测使用-mbmi-march=haswell进行编译的好处,以使编译器使用blsi %rax, %rax和在一条指令中计算m &= -m;。但是,如果C ++源代码具有与asm相同的依赖关系,则更容易跟踪自己在做什么,而不是使编译器引入新的依赖关系。

答案 7 :(得分:0)

你的同事描述的重新排序只是打破1.9 / 13

  

之前排序的是由单个执行的评估之间的不对称,传递,成对关系   线程(1.10),它引起这些评估中的部分顺序。给出任何两个评估A和B,如果   A在B之前被排序,然后A的执行应该在B的执行之前。如果A之前没有排序   B和B在A之前未被测序,然后A和B未被测序。 [注:执行无序   评估可以重叠。 -end note]当A或A时,评估A和B是不确定的   在A或B之前对B或B进行测序之前对其进行测序,但未指定哪一种。 [注意:不确定   有序的评估不能重叠,但可以先执行。 - 后注]

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