在 C + + 中强制执行语句顺序

假设我有许多要执行的语句 一个固定的顺序。我想使用 g + + 与优化级别2,所以一些 语句可以重新排序。有什么工具可以强制执行语句的某种顺序?

考虑下面的例子。

using Clock = std::chrono::high_resolution_clock;


auto t1 = Clock::now(); // Statement 1
foo();                  // Statement 2
auto t2 = Clock::now(); // Statement 3


auto elapsedTime = t2 - t1;

在这个例子中,语句1-3在 但是,编译器不能认为语句2是 独立于1和3,并按如下方式执行代码?

using Clock=std::chrono::high_resolution_clock;


foo();                  // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3


auto elapsedTime = t2 - t1;
23132 次浏览

不,根据 C + + 的标准[ intr.execute ] :

14每个值的计算和副作用与一个 全表达式在每个值计算和边之前排序 与下一个要求值的完整表达式相关联的效果。

完整表达式基本上是以分号结束的语句。正如您所看到的,上述规则规定语句必须按顺序执行。编译器可以更自由地使用 内心语句(也就是说,在某些情况下,编译器可以按照从左到右或其他特定顺序计算构成语句的表达式)。

请注意,此处不满足使用仿真规则的条件。认为任何编译器都能够对 证明重新排序调用以获得系统时间不会影响可观察到的程序行为的想法是不合理的。如果存在这样一种情况,即两个获取时间的调用可以在不改变观察行为的情况下重新排序,那么实际上生成一个编译器来分析一个程序是非常低效的,这个编译器具有足够的理解能力,能够确切地推断出这一点。

没有。

有时,根据“ as-if”规则,语句可能会被重新排序。这不是因为它们在逻辑上彼此独立,而是因为这种独立性允许在不改变程序语义的情况下进行这种重新排序。

移动获得当前时间的系统调用显然不满足该条件。有意或无意这样做的编译器是不兼容的,而且非常愚蠢。

通常,我不会期望任何导致系统调用的表达式会被哪怕是一个咄咄逼人的编译器最佳化“质疑”。它只是对系统调用的作用了解得不够。

重新排序可以由编译器完成,也可以由处理器完成。

大多数编译器都提供了一种特定于平台的方法来防止读写指令的重新排序

asm volatile("" ::: "memory");

(更多信息请点击这里)

请注意,这只是间接地阻止了重新排序操作,只要它们依赖于读/写操作。

在实践中,我还没有看到过 Clock::now()中的系统调用具有与这种屏障相同的效果的系统。可以检查生成的程序集以确保。

但是,在编译期间对被测函数进行计算并不罕见。为了执行“现实的”执行,您可能需要从 I/O 或 volatile读取获得 foo()的输入。


另一个选择是禁用 foo()的内联-同样,这是编译器特定的,通常不可移植,但会有相同的效果。

在 GCC 上,这是 __attribute__ ((noinline))


@ Ruslan 提出了一个基本问题: 这种测量方法有多现实?

执行时间受到许多因素的影响: 一个是我们正在运行的实际硬件,另一个是对共享资源(如缓存、内存、磁盘和 CPU 核心)的并发访问。

因此,我们通常做什么,以获得 差不多计时: 确保他们是 可重现性与一个低误差幅度。这使得它们有些人为。

“热缓存”和“冷缓存”的执行性能很容易因数量级而异——但实际上,它们是介于两者之间的(“冷缓存”?)

摘要:

似乎没有保证的方法来防止重新排序,但只要链接时间/全程序优化没有启用,在一个单独的编译单元中定位被调用的函数似乎是一个相当不错的选择。(至少对于 GCC 来说是这样,尽管从逻辑上看,其他编译器也可能是这样。)这是以函数调用为代价的——内联代码根据定义是在同一个编译单元中,并且可以重新排序。

原答案:

海湾合作委员会重新排序的呼叫下的 O2优化:

#include <chrono>
static int foo(int x)    // 'static' or not here doesn't affect ordering.
{
return x*2;
}
int fred(int x)
{
auto t1 = std::chrono::high_resolution_clock::now();
int y = foo(x);
auto t2 = std::chrono::high_resolution_clock::now();
return y;
}

海湾合作委员会5.3.0:

返回文章页面

_ZL3fooi:
pushq   %rbp
movq    %rsp, %rbp
movl    %ecx, 16(%rbp)
movl    16(%rbp), %eax
addl    %eax, %eax
popq    %rbp
ret
_Z4fredi:
pushq   %rbp
movq    %rsp, %rbp
subq    $64, %rsp
movl    %ecx, 16(%rbp)
call    _ZNSt6chrono3_V212system_clock3nowEv
movq    %rax, -16(%rbp)
movl    16(%rbp), %ecx
call    _ZL3fooi
movl    %eax, -4(%rbp)
call    _ZNSt6chrono3_V212system_clock3nowEv
movq    %rax, -32(%rbp)
movl    -4(%rbp), %eax
addq    $64, %rsp
popq    %rbp
ret

但是:

返回文章页面

_Z4fredi:
pushq   %rbx
subq    $32, %rsp
movl    %ecx, %ebx
call    _ZNSt6chrono3_V212system_clock3nowEv
call    _ZNSt6chrono3_V212system_clock3nowEv
leal    (%rbx,%rbx), %eax
addq    $32, %rsp
popq    %rbx
ret

现在,使用 foo ()作为外部函数:

#include <chrono>
int foo(int x);
int fred(int x)
{
auto t1 = std::chrono::high_resolution_clock::now();
int y = foo(x);
auto t2 = std::chrono::high_resolution_clock::now();
return y;
}

返回文章页面

_Z4fredi:
pushq   %rbx
subq    $32, %rsp
movl    %ecx, %ebx
call    _ZNSt6chrono3_V212system_clock3nowEv
movl    %ebx, %ecx
call    _Z3fooi
movl    %eax, %ebx
call    _ZNSt6chrono3_V212system_clock3nowEv
movl    %ebx, %eax
addq    $32, %rsp
popq    %rbx
ret

但是,如果这与-flto (链接时优化)相关联:

0000000100401710 <main>:
100401710:   53                      push   %rbx
100401711:   48 83 ec 20             sub    $0x20,%rsp
100401715:   89 cb                   mov    %ecx,%ebx
100401717:   e8 e4 ff ff ff          callq  100401700 <__main>
10040171c:   e8 bf f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
100401721:   e8 ba f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
100401726:   8d 04 1b                lea    (%rbx,%rbx,1),%eax
100401729:   48 83 c4 20             add    $0x20,%rsp
10040172d:   5b                      pop    %rbx
10040172e:   c3                      retq

C + + 语言以多种方式定义了什么是可观察的。

如果 foo()没有什么可观察到的,那么它可以被完全消除。如果 foo()只是以“本地”状态存储值(不管是在堆栈上还是在某个对象中) ,那么编译器可以证明 还有没有安全派生的指针可以进入 Clock::now()代码,那么移动 Clock::now()调用就不会产生任何可观察到的后果。

如果 foo()与文件或显示器交互,而编译器无法证明 Clock::now()确实与 没有与文件或显示器交互,那么就无法进行重新排序,因为与文件或显示器交互是可观察到的行为。

虽然可以使用特定于编译器的技巧强制代码不要移动(如内联程序集) ,但另一种方法是尝试比编译器更聪明。

创建一个动态加载的库。在有问题的代码之前加载它。

那个图书馆暴露了一件事:

namespace details {
void execute( void(*)(void*), void *);
}

然后像这样包起来:

template<class F>
void execute( F f ) {
struct bundle_t {
F f;
} bundle = {std::forward<F>(f)};


auto tmp_f = [](void* ptr)->void {
auto* pb = static_cast<bundle_t*>(ptr);
(pb->f)();
};
details::execute( tmp_f, &bundle );
}

它打包一个空 lambda 并使用动态库在编译器无法理解的上下文中运行它。

在动态库中,我们这样做:

void details::execute( void(*f)(void*), void *p) {
f(p);
}

这很简单。

现在要重新排序对 execute的调用,它必须理解动态库,而在编译测试代码时它不能理解动态库。

它仍然可以消除 foo()与零副作用,但你赢得了一些,你失去了一些。

在与 C + + 标准委员会讨论之后,我想尝试提供一个更全面的答案。除了是 C + + 委员会的成员之外,我还是 LLVM 和 Clang 编译器的开发人员。

从根本上说,没有办法使用屏障或序列中的某些操作来实现这些转换。最根本的问题是,整数加法的操作语义学是实现的 完全知道。它可以模拟它们,它知道它们不能被正确的程序观察到,并且总是可以自由地移动它们。

我们可以试图阻止这种情况,但它会产生极其负面的结果,并最终失败。

首先,在编译器中防止这种情况发生的唯一方法是告诉它所有这些基本操作都是可观察的。问题是这样就会排除绝大多数编译器优化。在编译器内部,我们基本上没有很好的机制来建模 时机是可观察的,除此之外没有别的。我们甚至没有一个好的 什么行动需要时间模型。例如,将32位无符号整数转换为64位无符号整数是否需要时间?在 x86-64上花费的时间为零,但在其他体系结构上花费的时间为非零。这里没有一般正确的答案。

但是,即使我们成功地阻止了编译器对这些操作进行重新排序,也不能保证这就足够了。考虑一种在 x86机器上执行 C + + 程序的有效且一致的方法: DynamoRIO。这是一个动态评估程序的机器代码的系统。它能做的一件事就是在线优化,它甚至能够在计时之外投机性地执行整个基本算术指令范围。这种行为并不是动态计算器所独有的,实际的 x86 CPU 也会推测(数量少得多的)指令并动态地重新排序它们。

基本的认识是,算术是不可观察的事实(即使在时间层次上)是渗透到计算机的各个层次的东西。对于编译器、运行时,甚至对于硬件,都是如此。强制它是可观察的既会显著地限制编译器,但也会显著地限制硬件。

但这一切不应该让你失去希望。当您想要计算基本数学运算的执行时间时,我们已经很好地研究了可靠工作的技术。通常在执行 微观基准测试微观基准测试时使用这些方法。我在 CppCon2015: https://youtu.be/nXaxk27zwlk上做了一个关于这个的演讲

这里展示的技术也由各种微基准库提供,比如 Google 的: https://github.com/google/benchmark#preventing-optimization

这些技术的关键是关注数据。可以使计算输入对优化器不透明,计算结果对优化器不透明。一旦你这样做了,你可以可靠地计时。让我们看看原始问题中示例的现实版本,但是实现完全可以看到 foo的定义。我还从 GoogleBenchmark 库中提取了一个(不可移植的) DoNotOptimize版本,你可以在这里找到: https://github.com/google/benchmark/blob/v1.0.0/include/benchmark/benchmark_api.h#L208

#include <chrono>


template <class T>
__attribute__((always_inline)) inline void DoNotOptimize(const T &value) {
asm volatile("" : "+m"(const_cast<T &>(value)));
}


// The compiler has full knowledge of the implementation.
static int foo(int x) { return x * 2; }


auto time_foo() {
using Clock = std::chrono::high_resolution_clock;


auto input = 42;


auto t1 = Clock::now();         // Statement 1
DoNotOptimize(input);
auto output = foo(input);       // Statement 2
DoNotOptimize(output);
auto t2 = Clock::now();         // Statement 3


return t2 - t1;
}

在这里,我们确保输入数据和输出数据在计算 foo周围被标记为不可优化的,并且只有在这些标记周围才计算计时。因为您正在使用数据来钳制计算,所以它保证停留在两个计时之间,而且允许计算本身进行优化。最近构建的 Clang/LLVM 生成的 x86-64程序集是:

% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3
.text
.file   "so.cpp"
.globl  _Z8time_foov
.p2align        4, 0x90
.type   _Z8time_foov,@function
_Z8time_foov:                           # @_Z8time_foov
.cfi_startproc
# BB#0:                                 # %entry
pushq   %rbx
.Ltmp0:
.cfi_def_cfa_offset 16
subq    $16, %rsp
.Ltmp1:
.cfi_def_cfa_offset 32
.Ltmp2:
.cfi_offset %rbx, -16
movl    $42, 8(%rsp)
callq   _ZNSt6chrono3_V212system_clock3nowEv
movq    %rax, %rbx
#APP
#NO_APP
movl    8(%rsp), %eax
addl    %eax, %eax              # This is "foo"!
movl    %eax, 12(%rsp)
#APP
#NO_APP
callq   _ZNSt6chrono3_V212system_clock3nowEv
subq    %rbx, %rax
addq    $16, %rsp
popq    %rbx
retq
.Lfunc_end0:
.size   _Z8time_foov, .Lfunc_end0-_Z8time_foov
.cfi_endproc




.ident  "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)"
.section        ".note.GNU-stack","",@progbits

在这里,您可以看到编译器将对 foo(input)的调用优化为一条指令 addl %eax, %eax,但是不会将其移出计时范围,也不会完全消除它,尽管输入是常量。

希望这有所帮助,C + + 标准委员会正在研究将类似于 DoNotOptimize的 API 标准化的可能性。

noinline函数 + 内联程序集黑盒 + 完整数据依赖项

这是基于 https://stackoverflow.com/a/38025837/895245,但是因为我没有看到任何明确的理由,为什么 ::now()不能被重新排序,我宁愿偏执,把它放在一个九行函数里面,连同 asm。

通过这种方式,我非常确定重新排序不会发生,因为 noinline“绑定”了 ::now和数据依赖关系。

Main.cpp

#include <chrono>
#include <iostream>
#include <string>


// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
// Make the compiler think we actually use / modify the value.
// It can't "see" what is going on inside the assembly string.
__asm__ __volatile__ ("" : "+g" (value));
return std::chrono::high_resolution_clock::now();
}


template <class T>
static T foo(T niters) {
T result = 42;
for (T i = 0; i < niters; ++i) {
result = (result * result) - (3 * result) + 1;
}
return result;
}


int main(int argc, char **argv) {
unsigned long long input;
if (argc > 1) {
input = std::stoull(argv[1], NULL, 0);
} else {
input = 1;
}


// Must come before because it could modify input
// which is passed as a reference.
auto t1 = get_clock(input);
auto output = foo(input);
// Must come after as it could use the output.
auto t2 = get_clock(output);
std::cout << "output " << output << std::endl;
std::cout << "time (ns) "
<< std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
<< std::endl;
}

GitHub 上游。

编译并运行:

g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000

这个方法唯一的小缺点是我们在 inline方法上添加了一个额外的 callq指令。objdump -CD显示 main包含:

    11b5:       e8 26 03 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
11ba:       48 8b 34 24             mov    (%rsp),%rsi
11be:       48 89 c5                mov    %rax,%rbp
11c1:       b8 2a 00 00 00          mov    $0x2a,%eax
11c6:       48 85 f6                test   %rsi,%rsi
11c9:       74 1a                   je     11e5 <main+0x65>
11cb:       31 d2                   xor    %edx,%edx
11cd:       0f 1f 00                nopl   (%rax)
11d0:       48 8d 48 fd             lea    -0x3(%rax),%rcx
11d4:       48 83 c2 01             add    $0x1,%rdx
11d8:       48 0f af c1             imul   %rcx,%rax
11dc:       48 83 c0 01             add    $0x1,%rax
11e0:       48 39 d6                cmp    %rdx,%rsi
11e3:       75 eb                   jne    11d0 <main+0x50>
11e5:       48 89 df                mov    %rbx,%rdi
11e8:       48 89 44 24 08          mov    %rax,0x8(%rsp)
11ed:       e8 ee 02 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>

所以我们看到 foo是内联的,但是 get_clock不是内联的并且包围它。

然而,get_clock本身是非常高效的,它包含一个单叶调用优化指令,甚至没有触及堆栈:

00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
14e0:       e9 5b fb ff ff          jmpq   1040 <std::chrono::_V2::system_clock::now()@plt>

由于时钟精度本身是有限的,我认为这是不太可能的,您将能够注意到一个额外的 jmpq的时间效应。请注意,无论如何都需要一个 call,因为 ::now()位于共享库中。

使用数据依赖项从内联程序集调用 ::now()

这将是最有效的解决方案,甚至可以克服上面提到的额外的 jmpq

不幸的是,这是非常难以正确地做到这一点,如图所示: 在扩展的内联 ASM 中调用 printf

但是,如果您的时间测量可以直接在内联汇编中进行,而不需要调用,那么可以使用这种技术。例如 魔法仪器说明书,x86RDTSC(不确定这是否再具有代表性)和可能的其他性能计数器就是这种情况。

相关文章:

使用 GCC 8.3.0和 Ubuntu 19.04进行测试。