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



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

#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 毫秒。 两个版本的拆卸清单除了寄存器选择不同外,几乎没有任何区别。 这让我想知道是否有另一种方法可以避免没有如此压倒性副作用的代码重新排序。

我的问题是:

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

我的问题类似于这个(关于使用易失性来避免省略而不是重新排序(,这个(没有回答如何防止重新排序(和这个(争论问题是代码重新排序还是死代码消除(。 虽然这三个人都在讨论这个确切的话题,但实际上没有人回答我的问题。

更新:答案似乎是我的同事弄错了,像这样重新排序不符合标准。 我已经对每个这么说的人投了赞成票,并将赏金授予马克西姆。

我见过一种情况(基于此问题中的代码(,其中Visual Studio 2010按照我的说明对时钟调用进行了重新排序(仅在64位版本中(。 我正在尝试做一个最小的案例来说明这一点,以便我可以在Microsoft Connect上提交错误。

对于那些说易失性应该慢得多的人,因为它强制读取和写入内存,这与发出的代码不太一致。 在我对这个问题的回答中,我展示了有和没有易失性的代码的反汇编。 在循环中,所有内容都保存在寄存器中。 唯一显著的区别似乎是寄存器选择。 我对 x86 汇编的了解不够,无法知道为什么非易失性版本的性能始终很快,而易失性版本的性能不一致(有时甚至显着(慢。

一位同事指出,我应该将开始和停止变量声明为易失性,以避免代码重新排序。

对不起,但你的同事错了。

编译器不会对定义在

编译时不可用的函数的调用重新排序。试想一下,如果编译器对forkexec等调用重新排序,或者围绕这些调用移动代码,将会有多热闹。

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

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

我不能推荐看足够的原子武器:C++记忆模型和现代硬件,因为它讨论了关于(编译时(内存障碍和volatile的误解以及许多其他有用的东西。

尽管如此,我还是添加了易失性,发现基准测试不仅花费了更长的时间,而且每次运行都非常不一致。没有易失性(并且幸运地确保代码没有重新排序(,基准测试始终需要 600-700 毫秒。对于易失性,通常需要 1200 毫秒,有时甚至超过 5000 毫秒

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

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

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

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

我相信_ReadWriteBarrier等同于Microsoft的东西。

根据马克西姆·耶戈鲁什金的回答,重新排序不太可能是导致您问题的原因。

Volatile 确保一件事,而且只有一件事:每次从内存中读取可变变量的读取 - 编译器不会假设该值可以缓存在寄存器中。同样,写入将被写入内存。编译器不会将其保留在寄存器中"一段时间,然后再将其写出内存"。

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

_ReadWriteBarrier(( - 全围栏

_ReadBarrier(( - 负载的双面围栏

_WriteBarrier(( - 商店的双面围栏

ICC包括__memory_barrier((完整的围栏。

完整的围栏

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

状态重新排序(大多数编译器在启用优化时都会这样做(,这也是使用编译器优化编译时某些程序无法操作操作的主要原因。

将建议阅读 http://preshing.com/20120625/memory-ordering-at-compile-time 以查看我们在编译器重新设置等方面可能面临的潜在问题。

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

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

对变量使用易失性不是您想要的。这将导致编译器每次都从 RAM 加载和存储这些变量(假设必须保留其副作用:aka - 适用于 I/O 寄存器(。当你在基准测试时,你对测量从记忆中获取某些东西或写在那里需要多长时间不感兴趣。通常,您只希望变量位于 CPU 寄存器中。

如果您在未优化的循环(例如对数组求和(之外分配一次,作为打印结果的替代方法,则可以使用volatile。 (就像问题中的长时间运行的函数一样(。 但不是在一个小循环;这将引入存储/重新加载指令和存储转发延迟。


我认为将您的编译器提交到不优化您的基准代码到地狱的唯一方法是使用 asm .这允许你欺骗编译器,让它认为它对你的变量内容或用法一无所知,所以它每次都必须做所有事情,只要你的循环要求它做

例如,如果我想对 m 是一些uint64_t m & -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)
{
}

然后你可以试试:

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

这会产生汇编代码(来自 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     

在循环内完全执行三个请求的汇编指令,加上用于循环开销的 sub 和 jne。

这里的诀窍是通过使用asm volatile 1 并告诉编译器

  1. "r"输入操作数:它使用 result 的值作为输入,因此编译器必须在寄存器中将其具体化。
  2. "+r"输入/输出操作数:m保留在同一寄存器中,但(可能(被修改。
  3. volatile:它有一些神秘的副作用和/或不是输入的纯函数;编译器必须像源代码一样多次执行它。这会强制编译器将测试片段单独保留在循环中。 请参阅 gcc 手册的扩展 Asm#易失性部分。

脚注 1:此处需要volatile,否则编译器会将其转换为空循环。 非易失性asm(具有任何输出操作数(被认为是其输入的纯函数,如果未使用结果,则可以将其优化掉。 或者 CSEd 在对同一输入多次使用时仅运行一次。


下面的一切都不是我的——我不一定同意。

如果您使用了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>

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

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

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

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

您可能希望对延迟进行微基准测试,以便更轻松地检测使用 -mbmi-march=haswell 进行编译的好处,让编译器在一条指令中使用blsi %rax, %rax并计算m &= -m;。 但是,如果C++源与 asm 具有相同的依赖项,则更容易跟踪您正在执行的操作,而不是欺骗编译器引入新的依赖项。

你可以制作两个 C 文件,SlowCalculationg++ -O3 编译(高级优化(,以及用 g++ -O1 编译的基准文件(较低级别,仍然优化 - 这可能足以满足该基准测试部分(。

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

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

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

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

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

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

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

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

此外,如果您在 O1 或 O0 上编译,编译器很可能不会打扰重新排序指令。您的问题与(编译时间障碍 - 编译器代码重新排序 - gcc 和 pthreads(

最新更新