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

时间:2016-10-19 04:28:31

标签: c++ gcc clang performance-testing compiler-optimization

我最近遇到了这个精彩的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)越过clobber逃脱的好处是什么?

2)从上面的示例看起来,clobber()可以防止先前的语句(push_back)被优化。如果这就是为什么以下代码段不正确的原因?

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

如果这不足以让人感到困惑,那么愚蠢(FB的线程化lib)会有一个偶数stranger implementation

相关摘要:

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

我的理解是上面的代码段告诉编译器程序集块将写入数据。但是,如果编译器发现没有此数据的消费者,它仍然可以优化生成数据的实体吗?

我认为这不是常识,任何帮助都表示赞赏!

2 个答案:

答案 0 :(得分:6)

tl; dr doNotOptimizeAway创造了一个人为的&#34;使用&#34; s。

这里有一点术语:&#34; def&#34; (&#34; definition&#34;)是一个语句,它为变量赋值; a&#34;使用&#34;是一个语句,它使用变量的值来执行某些操作。

如果从def之后的那一点开始,程序出口的所有路径都没有遇到变量的使用,那么def被称为dead并且死代码消除(DCE)传递将删除它。这反过来可能导致其他defs变为死亡(如果def因为具有可变操作数而被使用)等等。

想象一下在标量替换聚合(SRA)传递之后的程序,它将本地std::vector转换为两个变量lenptr。在某些时候,程序会为ptr分配一个值;该陈述是一个def。

现在,原始程序没有对矢量做任何事情;换句话说,lenptr没有任何使用。因此,他们所有的def都已经死了,DCE可以删除它们,有效地删除所有代码并使基准毫无价值。

添加doNotOptimizeAway(ptr)会创建一个人工使用,这会阻止DCE删除defs。 (作为旁注,我认为&#34; +&#34;,&#34; g&#34;应该已经足够了)。

类似的推理线可以跟随内存加载和存储:如果没有到程序末尾的路径,则存储(def)已经死亡,其中包含来自该存储位置的加载(使用)。由于跟踪任意内存位置比跟踪单个伪寄存器变量要困难得多,编译器保守地说 - 如果没有到达程序末尾的路径,那么存储就会死亡,这可能可能遇到使用那家商店。

一个这样的情况,是存储区域的存储,保证不会别名 - 在释放内存之后,不可能使用该存储,这不会触发未定义的行为。 IOW,没有这样的用途。

因此编译器可以消除v.push_back(42)。但有escape - 它会导致v.data()被视为任意别名,如上所述@Leon。

示例中clobber()的目的是创建所有别名内存的人工使用。我们有一个商店(来自push_back(42)),商店是一个全局别名的位置(由于escape(v.data())),因此clobber()可能包含该商店的使用(IOW) ,商店副作用是可观察的),因此不允许编译器删除商店。

一些简单的例子:

示例I:

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

这不会产生任何代码。

示例II:

extern void g();

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

这只会调用g(),没有内存存储。函数g无法访问v,因为v没有别名。

例III:

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

答案 1 :(得分:4)

  

1)越过clobber逃脱的好处是什么?

escape()clobber()相比没有优势。 escape() 以下列重要方式补充 clobber()

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

  

2)从上面的例子看起来,clobber()阻止了   以前的语句(push_back)是优化的方式。如果那是   为什么下面的代码段不正确?

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

v.reserve(1)中隐藏的分配在clobber()之前无法显示,直到通过escape()注册。