benchmarking, code reordering, volatile

7 浏览
0 Comments

benchmarking, code reordering, volatile

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

#include 
#include 
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);

起初我对这种极端的重排持怀疑态度,但经过一些研究和实验后,我得知它是允许的。

然而,volatile并不像是正确的解决方案;volatile难道只是用于内存映射I/O吗?

尽管如此,我还是添加了volatile,发现基准测试不仅时间显著延长,而且每次运行结果都不稳定。没有使用volatile(并且确保代码没有被重排),基准测试始终花费600-700毫秒。使用volatile时,经常需要花费1200毫秒,有时甚至超过5000毫秒。两个版本的反汇编代码几乎没有任何区别,除了寄存器的选择不同。这让我想知道是否有其他方法可以避免代码重排,而不会产生如此压倒性的副作用。

我的问题是:

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

我的问题类似于这个问题(关于使用volatile避免省略而不是重排),这个问题(没有回答如何防止重排),和这个问题(关于代码重排或死代码消除的讨论)。虽然这三个问题都涉及到了这个具体的主题,但实际上没有回答到我的问题。

更新:答案似乎是我的同事弄错了,这种重排与标准不一致。我赞同每个说法如此的人,并将赏金奖给了Maxim。

我见过一种情况(基于这个问题中的代码),在其中Visual Studio 2010对clock调用进行了如我所示的重排(仅适用于64位构建)。我正在尝试制作一个最小化的案例来说明这一点,以便我可以在Microsoft Connect上提交一个错误报告。

对于那些说volatile应该更慢的人来说,这与代码生成并不完全一致。在我在这个问题的答案中,我展示了带有和不带有volatile的反汇编代码。在循环内部,所有内容都保持在寄存器中。唯一显著的区别似乎是寄存器选择。我对x86汇编不够了解,无法理解为什么非volatile版本的性能始终很快,而volatile版本不一致(有时甚至明显)地变慢。

0
0 Comments

volatile关键字的作用是确保每次读取volatile变量时都从内存中读取,编译器不会认为该值可以在寄存器中缓存。同样,写操作也会直接写入内存,编译器不会在写入内存之前将其保留在寄存器中。

为了防止编译器进行代码重排序,可以使用所谓的编译器屏障。MSVC包含3个编译器屏障:

- _ReadWriteBarrier() - 完全屏障

- _ReadBarrier() - 用于加载的双边屏障

- _WriteBarrier() - 用于存储的双边屏障

ICC包含__memory_barrier()完全屏障。在这个层面上,完全屏障通常是最好的选择(编译器屏障在运行时基本上没有成本)。

语句重排序(大多数编译器在启用优化时会进行)也是某些程序在编译器优化时无法正常运行的主要原因。

建议阅读http://preshing.com/20120625/memory-ordering-at-compile-time了解我们在编译器重排序等方面可能遇到的潜在问题。

此外,volatile关键字还保证了将值写入内存的方式符合ABI定义的该对象的值表示方式;可以读回任何有效的ABI值表示,并且编译器对于从这样的读取中获得的值不做任何假设,即使在此之前进行了写入的读取操作。

0
0 Comments

由于编译器可能对代码重新排序,我的同事指出我应该将start和stop变量声明为volatile以避免这种情况。然而,很抱歉,你的同事是错误的。编译器不会对在编译时无法获得定义的函数调用进行重新排序。如果编译器对这样的调用如fork和exec进行重新排序或在其周围移动代码,将会出现令人发笑的情况。换句话说,任何没有定义的函数都是编译时的内存屏障,也就是说,编译器不会在调用之前将后续语句移动到调用之前,也不会将先前的语句移动到调用之后。

在你的代码中,对std::clock的调用最终会调用一个在编译时无法获得定义的函数。我强烈推荐观看《atomic Weapons: The C++ Memory Model and Modern Hardware》,因为它讨论了关于(编译时)内存屏障和volatile的误解,以及其他许多有用的内容。

然而,我添加了volatile后发现,基准测试不仅花费了更长的时间,而且运行结果也非常不一致。没有volatile(并且确保代码没有重新排序),基准测试一致需要600-700毫秒。而使用volatile,它经常需要1200毫秒,有时甚至超过5000毫秒。

我不确定是否应该归咎于volatile。报告的运行时间取决于基准测试的运行方式。确保禁用CPU频率缩放,以防止其在运行过程中启用Turbo模式或切换频率。此外,微基准测试应该以实时优先级进程的方式运行,以避免调度噪声。可能在另一个运行中,某个后台文件索引器开始与您的基准测试竞争CPU时间。有关更多详细信息,请参见此链接。

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

如果你是正确的,那么我的编译器(64位模式下的MSVC++ 2010)是有问题的,因为我找到了一个情况,它按照我展示的方式重新排序了时钟调用。我想我会提交一个bug报告。至于volatile导致的运行时间不一致,我意识到了外部因素,并且已经将它们最小化。奇怪的是,使用volatile的时间非常一致地不一致,而不使用volatile的时间一致地一致,所以我认为这不是像文件索引器那样的随机因素。感谢视频链接,它已经在我的“待观看”列表中。

你可能想在Linux上使用Valgrind来运行你的代码,以查看逐行执行时间和缓存效果。尽管Windows上可能有类似的工具。不过,我想看看它重新按照你描述的方式重新排序代码的代码。

它不会重新排序对std::clock的调用,但它可能会内联并将对SlowCalculation的调用移动到它喜欢的任何位置(并且通常确实会这样做)。否则,为什么人们会使用屏障呢?

我确实读过它。有什么可以阅读的呢?当你连续有3个对volatile变量的写操作时,编译器不能重新排序这些操作。即使这3个计算都可以内联。

假设编译器不知道它实际上可以知道的一些东西是危险的。例如,std::clock是一个定义在标准库中的函数,编译器正在提供它。用户不能在命名空间std中定义任何内容,所以编译器知道你正在调用它的版本的std::clock,因此这不是不允许的原因。即使SlowCalculation在其他翻译单元中定义,这也不会关闭优化,因为Visual Studio、clang和gcc都支持链接时优化。

我不认为编译器“知道”函数的任何内容,除非它是一个内置函数。

:除了用户无法特化或重载的函数之外,没有任何东西阻止供应商为其编译器提供有关std命名空间中任何函数的特殊知识。

原因是有问题的。std::clock不需要是一个库I/O函数,因此它本身不免于重新排序。即使ISO C++规定std::clock实现为函数(而不是宏),并且没有任何合理的实现应该重新排序它的调用,但这并不保证在任何符合规范的实现中都会按照你所说的方式工作。而将内联视为编译时屏障在一般情况下是错误的。实现可以在可以证明函数没有副作用时完全消除后续调用,例如当在G++中使用__attribute__((__const__))声明时。

正确,从标准的角度来看,任何没有标记为__attribute__((__const__))的函数都是一个I/O函数。

这是实现的观点,这是一种保守但实用的策略。然而,我发现标准并不要求它是一个会产生"改变执行环境状态的副作用"的I/O函数的一部分。至少目前,标准没有任何类似于__attribute__((__const__))的内容。

一个人可以构想出整个标准库或仅std::clock的另一种实现,在其中std::clock执行I/O,这仍然是符合标准的。

这是现状。然而,如果我希望我的代码本身符合标准(也就是避免依赖任何具体的实现细节),我必须自己努力保证编译器永远不会重新排序代码。同时,也许唯一的选择是非常小心地编码:在计算代码的计数器变量和中间结果存储时都添加volatile。这可能是op的同事所考虑的,不幸的是,他在某种程度上是正确的。

gcc和其他编译器可以/会重新排序对时钟函数的调用(相对于测试代码而言),并使基准测试结果无效。到目前为止,volatile是防止这种情况发生的唯一方法。

_timer展示一个例子。

当程序员使用(特定于编译器的)属性时,编译器可以了解用户编写的函数的一些信息:“例如,您可以使用属性指定函数永远不会返回(noreturn),返回值仅取决于其参数的值(const),或具有printf样式的参数(format)。”

0
0 Comments

在代码中,出现了一些问题,需要解决。这些问题包括benchmarking、code reordering和volatile。下面将分析这些问题的出现原因以及解决方法。

出现这些问题的原因是代码的重新排序和内存访问的顺序。重新排序是指编译器在优化代码时,可能会改变代码的执行顺序,从而影响程序的行为。而内存访问的顺序是指程序访问内存的顺序可能与代码中的顺序不一致,这可能导致内存读写的冲突。

为了防止代码重新排序,可以使用编译屏障。在gcc中,通常使用asm volatile ("":::"memory");这个指令来实现。这个指令在汇编语言中什么也不做,但是我们告诉编译器它会破坏内存,所以编译器不允许在它的前后重新排序代码。这个指令的代价只是删除重新排序的代价,不会像其他优化级别的改变那样昂贵。在Microsoft的代码中,可以使用_ReadWriteBarrier来实现类似的效果。

至于内存的访问顺序,可以使用volatile关键字来解决。volatile关键字告诉编译器,对于声明为volatile的变量,在访问时不要进行优化,要按照代码中的顺序进行访问。这样可以确保内存的访问顺序与代码中的顺序一致,避免了可能的冲突。

总之,为了解决benchmarking、code reordering和volatile问题,可以使用编译屏障和volatile关键字。编译屏障可以防止代码的重新排序,而volatile关键字可以确保内存的访问顺序与代码中的顺序一致。这样可以保证程序的行为符合预期,避免可能的问题。

0