在进行基准测试时防止编译器优化

19

我最近看到了这个关于cpp2015的精彩演讲CppCon 2015: Chandler Carruth "Tuning C++: Benchmarks, and CPUs, and Compilers! Oh My!"

其中提到了一些技术来防止编译器优化代码,包括使用以下函数。

static void escape(void *p) {
  asm volatile("" : : "g"(p) : "memory");
}

static void clobber() {
  asm volatile("" : : : "memory");
}

void benchmark()
{
  vector<int> v;
  v.reserve(1);
  escape(v.data());
  v.push_back(10);
  clobber()
}

我正在尝试理解这个问题。以下是问题:

1)转义与覆盖的优势是什么?

2)从上面的例子看来,clobber()似乎阻止了之前的语句(push_back)被优化。如果是这样,为什么下面的代码片段不正确?

 void benchmark()
 {
     vector<int> v;
     v.reserve(1);
     v.push_back(10);
     clobber()
 }

如果这还不够令人困惑,Folly(FB的线程库)甚至有一个更奇怪的实现方式

相关片段:

template <class T>
void doNotOptimizeAway(T&& datum) {
  asm volatile("" : "+r" (datum));
}

我的理解是,上面的代码片段告诉编译器汇编块将写入数据。但是,如果编译器发现没有任何使用此数据的消费者,它仍然可以优化掉生产数据的实体,对吗?

我认为这不是常识,任何帮助都将不胜感激!


3
实际上,“+r”表示代码段将会读取和写入数据。由于(gcc)编译器无法知道汇编语言可能如何使用/修改数据,因此在任何情况下它都无法对其进行优化处理。 - David Wohlferd
1
那个功能现在已经内置到Google基准测试中了。如下所示:github.com/google/benchmark,使用benchmark::DoNotOptimize。 - xaxxon
2个回答

17
tl;dr: doNotOptimizeAway函数创建了一个人工的“use”操作,防止编译器将无用代码删除。
术语说明:定义(def)是指赋值语句,将一个变量赋值;使用(use)是指使用变量的值执行某些操作的语句。
如果在一个def之后,从该点到程序退出的所有路径都没有遇到变量的use,则该def被称为dead。死代码消除(DCE)会将其删除。这可能会导致其他def也变得dead(如果该def是由于具有变量操作数而使用的),等等。
假设经过聚合标量替换(SRA)处理后,程序将本地的std::vector转换为两个变量lenptr。程序的某个位置将值赋给了ptr,这是一个def。
现在,原始程序没有对向量进行任何操作;换句话说,没有使用lenptr。因此,它们所有的defs都是dead,DCE可以将它们删除,有效地删除所有代码并使基准测试毫无价值。
添加doNotOptimizeAway(ptr)创建了一个人工use,防止DCE删除defs。(顺便说一下,我认为“+”没有意义,“g”足以)。
对于内存加载和存储,可以按照类似的推理方式进行:如果到程序结尾的路径中不包含从该存储位置加载(使用)的load,则该store(def)是dead。由于跟踪任意内存位置比跟踪单个伪寄存器变量要困难得多,因此编译器采用保守的方法——如果没有到程序结尾的路径可能会遇到该存储的use,则该存储是dead。
一个这样的例子是将一个存储器区域存储到保证不与其他存储器区域重叠的区域中。在释放该存储器之后,不可能存在不触发未定义行为的该存储的use。换句话说,不存在这样的use。
因此,编译器可以消除v.push_back(42)。但是,这里有一个escape函数——它会导致v.data()被视为任意别名,如@Leon所描述的那样。
示例I:
void f() {
  int v[1];
  v[0] = 42;
}

这不会生成任何代码。

例子二:

extern void g();

void f() {
  int v[1];
  v[0] = 42;
  g();
}

这只生成对 g() 的调用,没有内存存储。函数 g 不可能访问 v,因为 v 没有别名。
例子三:
void clobber() {
  __asm__ __volatile__ ("" : : : "memory");
}

void f() {
  int v[1];
  v[0] = 42;
  clobber();
}

和前面的例子一样,因为v没有别名,并且调用clobber被内联到空操作中,所以没有生成存储。

例子 IV:

template<typename T>
void use(T &&t) {
  __asm__ __volatile__ ("" :: "g" (t));
}

void f() {
  int v[1];
  use(v);
  v[0] = 42;
}

这次v逃逸了(即可以从其他激活帧中访问)。然而,由于在此之后没有潜在使用该内存的情况(没有UB),因此仍会删除存储。
示例V:
template<typename T>
void use(T &&t) {
  __asm__ __volatile__ ("" :: "g" (t));
}

extern void g();

void f() {
  int v[1];
  use(v);
  v[0] = 42;
  g(); // same with clobber()
}

最后我们得到了存储器,因为 v 被转义,编译器必须保守地假设调用 g 可能会访问存储的值。
(实验请参见https://godbolt.org/g/rFviMI

9

1)逃逸(escape)与清空(clobber)相比有什么优势?

escape()并没有比clobber()更具优势。 escape()在以下重要方面补充clobber()的作用:

clobber()的影响仅限于可能通过虚拟全局根指针访问的内存。换句话说,编译器对分配的内存的模型是一个连接的块图,通过指针相互引用,并且所述虚拟全局根指针用作该图的入口点。(该模型不考虑内存泄漏,即编译器忽略了由于失去指针值而导致的一次可访问块变得不可访问的可能性)。新分配的块不是这样的图形的一部分,并且免疫于clobber()的任何副作用。escape()确保传入的地址属于全局可访问的内存块集。当应用于新分配的内存块时,escape()的效果是将其添加到该图中。

2) From the example above it looks like clobber() prevents the previous statement ( push_back ) to be optimized way. If that's the case why the below snippet is not correct ?

 void benchmark()
 {
     vector<int> v;
     v.reserve(1);
     v.push_back(10);
     clobber();
 }
< p > v.reserve(1)里面的内存分配直到通过escape()注册后才会对clobber()可见。< /p >

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接