【问题标题】:Preventing compiler optimizations while benchmarking在进行基准测试时防止编译器优化
【发布时间】:2017-02-28 13:08:47
【问题描述】:

我最近看到了这个精彩的 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 )被优化。如果是这样,为什么下面的 sn-p 不正确?

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

如果这还不够令人困惑,那么愚蠢(FB 的线程库)甚至有一个 stranger implementation

相关sn-p:

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

我的理解是上面的sn-p通知编译器汇编块将写入数据。但是如果编译器发现这个数据没有消费者,它仍然可以优化实体产生数据对吗?

我认为这不是常识,感谢任何帮助!

【问题讨论】:

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


【解决方案1】:

tl;dr doNotOptimizeAway 创造了一种人为的“使用”。

这里有一点术语:“def”(“定义”)是一个语句,它为变量赋值; “use”是一个语句,它使用变量的值来执行一些操作。

如果从紧接在 def 之后的点开始,程序退出的所有路径都没有遇到对变量的使用,则该 def 被称为 dead 并且死代码消除 (DCE) pass 将删除它。这反过来可能会导致其他 def 失效(如果该 def 由于具有可变操作数而被使用),等等。

想象一下在标量替换聚合 (SRA) 通过后的程序,它将本地 std::vector 转换为两个变量 lenptr。在某些时候,程序会为ptr 赋值;该语句是一个定义。

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

添加doNotOptimizeAway(ptr) 会创建人为使用,这会阻止 DCE 删除定义。 (作为旁注,我认为“+”没有意义,“g”应该足够了)。

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

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

因此编译器可以消除v.push_back(42)。但是出现了escape - 它导致v.data() 被视为任意别名,正如上面描述的@Leon。

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

几个简单的例子:

示例一:

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 的调用被内联为空。

示例四:

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

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

这次v 转义(即可以从其他激活帧潜在地访问)。但是,存储仍然被删除,因为在它之后没有潜在的使用内存(没有 UB)。

示例五:

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

【讨论】:

    【解决方案2】:

    1) 逃脱比破坏者有什么优势?

    escape() 没有clobber() 的优势。 escape() 补充 clobber() 在以下重要方面:

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

    2) 从上面的例子看来,clobber() 阻止了 上一条语句( push_back )被优化的方式。如果那是 为什么下面的 sn-p 不正确?

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

    隐藏在v.reserve(1) 中的分配对clobber() 不可见,直到通过escape() 注册。

    【讨论】:

      猜你喜欢
      • 2011-09-02
      • 2011-09-25
      • 1970-01-01
      • 1970-01-01
      • 2016-03-19
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多