Google的 `DoNotOptimize()` 函数如何实现语句顺序的强制执行?

5

我正在尝试理解Google的DoNotOptimize()的工作原理。

为了完整起见,这是它的定义(适用于clang和非const数据):

template <class Tp>
inline BENCHMARK_ALWAYS_INLINE void DoNotOptimize(Tp& value) {
  asm volatile("" : "+r,m"(value) : : "memory");
}

据我所了解,我们可以在代码中像这样使用它:

start_time = time();
bench_output = run_bench(bench_inputs);
result = time() - start_time;

为了确保基准测试留在关键部分:
start_time = time();
DoNotOptimize(bench_inputs);
bench_output = run_bench(bench_inputs);
DoNotOptimise(bench_output);
result = time() - start_time;

我不明白的是,为什么这能保证(确实吗?)run_bench()不会被移到start_time = time()之上。

(有人在这条评论中问了完全相同的问题,但我不理解答案。)

据我所知,上面的DoNotOptimze()做了几件事情:

  • 它将value强制推入栈中,因为它是通过C++引用传递的。你不能有一个指向寄存器的指针,所以它必须在内存中。
  • 由于value现在在堆栈上,随后破坏内存(如在asm约束中所做的那样)将迫使编译器假定value被调用DoNotOptimize(value)读取和写入。
  • (我不确定+r,m约束是否相关。据我所知,这表示指针本身可以存储在寄存器或内存中,但指针值本身可能被读取和/或写入。)

这就是我感到困惑的地方。

如果start_time也是堆栈分配的,则DoNotOptimize()中的内存破坏意味着编译器必须假设DoNotOptimize()可能会读取start_time。因此语句的顺序只能是:

start_time = time(); // on the stack
DoNotOptimize(bench_inputs); // reads start_time, writes bench_inputs
bench_output = run_bench(bench_inputs)

但是,如果start_time没有存储在内存中,而是存储在寄存器中,那么破坏内存就不会破坏start_time,对吗?在这种情况下,start_time = time()DoNotOptimize(bench_inputs)的期望顺序将丢失,编译器可以自由地执行:
DoNotOptimize(bench_inputs); // only writes bench_inputs
bench_output = run_bench(bench_inputs)
start_time = time(); // in a register

显然我误解了一些东西。有人可以帮忙解释一下吗?谢谢 :) 我在想这是否是因为重新排序优化发生在寄存器分配之前,因此在那时假定所有内容都是堆栈分配的。但如果是这样的话,那么DoNotOptimize()将是多余的,因为ClobberMemory()就足够了。
1个回答

7

摘要:根据“memory”破坏的顺序,DoNotOptimizetime()有序,就像它是对一个不透明函数的另一个函数调用,该函数可以修改任何全局状态。

DoNotOptimize根据计算依赖于输入的数据以及输出对计算的依赖关系进行排序,正如您链接的Q&A中Chandler Carruth所解释的那样。 对于此部分,“memory”破坏是无关紧要的。


"memory" clobber就像非内联函数调用

DoNotOptimizeasm语句包含一个"memory" clobber。对于优化器来说,这相当于一个不透明的函数调用:必须假定它读写所有全局可访问的对象1。(即使是这个编译单元可能不知道的对象。)

由于time()本身在任何头文件中都没有内联定义,因此与DoNotOptimize在编译时不能重新排序,原因与编译器无法看到那些函数的定义时无法重新排序调用foo()bar()的调用一样。同样的原因,编译器不需要任何特殊逻辑来阻止它们重新排序puts("hi"); puts("mom");

一个假设的time()函数,如果内联且仅包含一个asm语句,必须使用asm volatile来确保重复调用不会只使用第一个输出。 asm volatile语句不能与彼此或对volatile变量的访问重新排序,因此出于不同的原因,这也是可以的。

注1:全局可访问=任何可能被任何假设的全局变量所指向的对象。即除了此函数中的局部变量或使用new新分配的内存之外,如果逃逸分析可以证明它们没有指针指向它们,则为任何东西。


asm语句的工作原理

我认为你对asm的工作原理有些误解。 "+r,m"告诉编译器在寄存器(或内存)中实现值,并在(空)asm模板的结尾使用该值作为该C++对象的新值。

因此,它强制编译器实际上在某个地方产生了(生成)该值,这意味着必须计算它。 这也意味着必须忘记它先前知道的关于该值的信息(例如它是一个编译时常量5,非负数等),因为"+"修饰符声明了一个读/写操作数。

在输入上使用DoNotOptimize的目的是为了打败常量传播,从而使基准测试无法进行优化。

在输出上使用DoNotOptimize的目的是确保最终结果实际上在寄存器(或内存)中实现,而不是优化掉导致未使用结果的所有计算。(这就是asm volatile相关的地方;打败常量传播仍然适用于非易失性的asm。)

因此,您想要进行基准测试的计算必须在两个DoNotOptimize()语句之间发生,并且这两个语句不能与time()重新排序。

编译器必须假定asm语句修改像val ^= random这样的值,以及更改除未操作数之外的任何/每个其他对象的内存中的值,因此例如"memory" clobber不会阻止编译器将本地循环计数器保留在内存中。(它不会特殊处理空的asm模板字符串;程序不会无意中包含这样的asm语句,因此没有人希望它们被优化掉。)


关于引用参数和选择"m"的误解

在了解你试图推理关于"+r,m"操作数和引用函数参数的细节之前,我只了解到一部分,但是我认为最好从头开始解释。正确的原因并不复杂。但有几点需要特别纠正:

C++函数包含asm语句可以内联,让引用函数参数优化消除。(甚至声明为inline __attribute__((always_inline))以强制即使禁用优化也进行内联,尽管在这种情况下引用变量将无法优化消除。)

最终结果就像直接将asm语句用于传递给DoNotOptimize的C++变量一样。例如DoNotOptimize(foo)就像asm volatile("" : "+r,m"(foo) :: "memory")

编译器总是可以选择寄存器,例如在asm语句之前将变量的值加载到寄存器中。 (如果C++语义要求在内存中更新变量的值,则在asm语句之后还会发出存储指令。)

例如,我们可以看到GCC确实选择这样做。(我想我本可以用incl %0作为例子,但我只是选择了nop来展示编译器选择操作数位置的替代方法,而不是# %0纯注释,所以 Godbolt编译器浏览器不会将其过滤掉。)
void foo(int *p)
{
    asm volatile("nop # operand picked %0" : "+r,m" (p[4]) );
}

# GCC 11.2 -O2
foo(int*):
        movl    16(%rdi), %eax
        nop # operand picked %eax
        movl    %eax, 16(%rdi)
        ret

vs. clang选择将值留在内存中,因此asm模板中的每个指令都将访问内存而不是寄存器。(如果有任何指令)。
# clang 12.0.1 -O2 -fPIE
foo(int*):                               # @foo(int*)
        nop     # operand picked 16(%rdi)
        retq

有趣的事实:""r,m"" 是为了解决 clang 优化失误的 bug 而尝试的方法,该 bug 使其始终选择内存作为 ""rm"" 约束的存储器,即使该值已经在寄存器中。即使必须为表达式的值输入发明一个临时位置,它也会首先将其溢出。

感谢您抽出时间撰写这篇精彩的答案! - Edd Barrett

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