Google 的 DoNotOptimize() 函数如何强制语句排序

How does Google's `DoNotOptimize()` function enforce statement ordering

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

为了完整起见,这里是它的定义(针对 clang 和非常量数据):

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().[=38 以上=]

(有人在 中问过这个问题,但我不明白答案)。

据我了解,上面的 DoNotOptimze() 做了几件事:

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

如果 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() 就足够了。

摘要:DoNotOptimize 已订购。 time()"memory" 破坏,就好像它是对可以修改任何全局状态的不透明函数的另一个函数调用。

DoNotOptimize 已订购。通过计算对输入的数据依赖性以及对计算的输出 对输入输出的计算。 "memory" 破坏与这部分无关。


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

DoNotOptimizeasm 语句包含 "memory" 破坏。就优化器而言,这相当于一个不透明的函数调用:必须假设它读取和写入每个全局可达的对象1。 (即使是本编译单元也可能不知道。)

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

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

脚注 1:全局可达 = 任何假设的全局变量可能指向的任何对象。也就是说,除了这个函数中的局部变量,或者用 new 新分配的内存之外的任何东西,如果 escape analysis 可以证明这个函数之外的任何东西都没有指向它们的指针。


asm 语句的工作原理

我认为您严重误解了 asm 的工作原理。 "+r,m" 告诉编译器在寄存器(或内存,如果需要的话)中具体化值,然后使用(空)asm 模板末尾的值作为该 C++ 对象的新值。

所以它强制编译器在某处实际实现(生成)值,这意味着必须计算它。这意味着必须忘记它之前知道的值(例如,它是一个编译时间常量 5,或非负数,或任何东西),因为 "+" 修饰符声明了一个 read/write 操作数。

输入 DoNotOptimize 的要点是打败会让基准优化消失的常量传播。

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

因此,您要进行基准测试的计算必须发生在两个 DoNotOptimize() 语句之间,并且这两个语句不能分别使用 time().[=112= 重新排序]

编译器必须假设 asm 语句修改它所知道的 val ^= random 之类的值,同时更改内存中 any/every 其他对象的值,但私有局部变量除外t 操作数,例如"memory" 破坏程序不会阻止编译器在内存中保留本地循环计数器。 (这里不是空 asm 模板字符串的特殊情况;程序不会偶然包含这样的 asm 语句,所以没有人希望它们被优化掉。)


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

我只是部分了解了您尝试推理 "+r,m" 操作数和参考函数参数的细节,然后才决定从头开始解释可能会更好。正确的原因并不那么复杂。但有几件事值得特别纠正:

包含 asm 语句的 C++ 函数可以内联,让引用函数 arg 优化掉。(甚至声明 inline __attribute__((always_inline)) 以强制即使在禁用优化的情况下内联,尽管在那种情况下引用变量不会优化掉。)

最终结果就像是直接在传递给 DoNotOptimize 的 C++ 变量上使用了 asm 语句。例如DoNotOptimize(foo) 就像 asm volatile("" : "+r,m"(foo) :: "memory")

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

例如,我们可以看到 GCC 确实选择这样做。 (我想我可以使用 incl %0 作为示例,但我只是选择 nop 作为一种方式来显示编译器为操作数位置选择了什么作为 # %0 纯注释的替代方案,所以 Godbolt compiler explorer 不会过滤掉它。)

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

对比clang 选择将值留在内存中,因此 asm 模板中的每条指令都将访问内存而不是寄存器。 (如果有说明的话)。

# clang 12.0.1 -O2 -fPIE
foo(int*):                               # @foo(int*)
        nop     # operand picked 16(%rdi)
        retq

有趣的事实:"r,m" 试图解决 clang 优化失败的错误,该错误使得它总是为 "rm" 约束选择内存,即使该值已经 在寄存器中。先溢出它,即使它必须为表达式的值创建一个临时位置作为输入。