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

Enforcing statement order in C++

假设我有许多要在其中执行的语句 一个固定的顺序。我想在优化级别 2 下使用 g++,所以一些 语句可以重新排序。有哪些工具可以强制执行特定的语句顺序?

考虑以下示例。

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;

不,不能。根据 C++ 标准 [intro.execution]:

14 Every value computation and side effect associated with a full-expression is sequenced before every value computation and side effect associated with the next full-expression to be evaluated.

完整表达式基本上是以分号结束的语句。如您所见,上述规则规定语句必须按顺序执行。 within 语句允许编译器更自由地控制(即在某些情况下允许以从左到右或其他任何顺序之外的顺序评估构成语句的表达式具体)。

请注意,此处不满足应用假设规则的条件。认为任何编译器都能够证明重新排序调用以获取系统时间不会影响可观察的程序行为的想法是不合理的。如果在这种情况下,可以在不改变观察到的行为的情况下对两次获取时间的调用进行重新排序,那么实际生产一个编译器来分析一个程序,并有足够的理解力,从而能够确定地推断出这一点,这将是极其低效的。

没有

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

动一个获取当前时间的系统调用显然不满足那个条件。有意或无意地这样做的编译器是不合规的,而且真的很愚蠢。

一般来说,即使是积极优化的编译器,我也不希望任何导致系统调用的表达式成为 "second-guessed"。它只是不太了解该系统调用的作用。

重新排序可能由编译器或处理器完成。

大多数编译器都提供特定于平台的方法来防止读写指令重新排序。在 gcc 上,这是

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

(More information here)

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

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

然而,被测函数在编译时得到评估的情况并不少见。要强制执行 "realistic",您可能需要从 I/O 或 volatile 读取中获取 foo() 的输入。


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

在 gcc 上,这将是 __attribute__ ((noinline))


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

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

因此,我们通常如何获得可比 时间:确保它们是可重现的,并且误差范围较低。这使它们有些做作。

"hot cache" 与 "cold cache" 执行性能很容易相差一个数量级——但实际上,它介于两者之间 ("lukewarm"?)

总结:

似乎无法保证防止重新排序的方法,但只要未启用 link-time/full-program 优化,将调用的函数定位在单独的编译单元中似乎是一个不错的选择。 (至少对于 GCC,尽管逻辑表明其他编译器也可能如此。)这是以函数调用为代价的——内联代码根据定义在同一个编译单元中并且可以重新排序。

原回答:

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:

g++ -S --std=c++11 -O0 fred.cpp :

_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    , %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    , %rsp
        popq    %rbp
        ret

但是:

g++ -S --std=c++11 -O2 fred.cpp :

_Z4fredi:
        pushq   %rbx
        subq    , %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        call    _ZNSt6chrono3_V212system_clock3nowEv
        leal    (%rbx,%rbx), %eax
        addq    , %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;
}

g++ -S --std=c++11 -O2 fred.cpp :

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

但是,如果这是 linked with -flto(link-时间优化):

0000000100401710 <main>:
   100401710:   53                      push   %rbx
   100401711:   48 83 ec 20             sub    [=15=]x20,%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    [=15=]x20,%rsp
   10040172d:   5b                      pop    %rbx
   10040172e:   c3                      retq

C++ 语言以多种方式定义了可观察的内容。

如果foo()没有任何可观察到的东西,那么它就可以完全消除。如果 foo() 只进行将值存储在 "local" 状态(无论是在堆栈上还是在某处的对象中)的计算, 编译器可以证明没有安全派生的指针可以进入 Clock::now() 代码,那么移动 Clock::now() 调用没有明显的后果。

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

虽然您可以使用特定于编译器的 hack 来强制代码不四处移动(如内联汇编),但另一种方法是试图超越您的编译器。

创建一个动态加载的库。在相关代码之前加载它。

那个库暴露了一件事:

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 );
}

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

在动态库里面,我们做:

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

这很简单。

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

它仍然可以消除 foo() 零副作用,但是你赢了一些,你输了一些。

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

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

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

首先,在编译器中防止这种情况的唯一方法是告诉它所有这些基本操作都是可观察的。问题是这将排除绝大多数编译器优化。在编译器内部,我们基本上没有很好的机制来模拟 timing 是可观察的,但没有别的。我们甚至没有关于 哪些操作需要时间 的良好模型。例如,将 32 位无符号整数转换为 64 位无符号整数是否需要时间?它在 x86-64 上花费零时间,但在其他架构上花费非零时间。这里没有普遍正确的答案。

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

基本的认识是算术是不可观察的(即使在计时级别)这一事实渗透到计算机的各个层中。对于编译器、运行时甚至硬件来说都是如此。强制它是可观察的会极大地限制编译器,但它也会极大地限制硬件。

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

此处显示的技术也由各种微基准库提供,例如 Google 的:https://github.com/google/benchmark#preventing-optimization

这些技术的关键是关注数据。您使计算的输入对优化器不透明,并且计算的结果对优化器不透明。一旦你这样做了,你就可以可靠地计时了。让我们看一下原始问题中示例的真实版本,但 foo 的定义对实现完全可见。我还从 Google 基准库中提取了 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    , %rsp
.Ltmp1:
        .cfi_def_cfa_offset 32
.Ltmp2:
        .cfi_offset %rbx, -16
        movl    , 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    , %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函数+内联汇编黑盒+全数据依赖

这是基于 但因为我没有看到任何明确的理由说明为什么 ::now() 不能在那里重新排序,所以我宁愿偏执并将它放在一个 noinline 函数中与 asm.

这样我很确定不会发生重新排序,因为 noinline "ties" ::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 upstream.

编译并运行:

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    [=12=]x2a,%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    [=12=]x1,%rdx
    11d8:       48 0f af c1             imul   %rcx,%rax
    11dc:       48 83 c0 01             add    [=12=]x1,%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

不幸的是,要正确执行此操作非常困难,如下所示:

如果您的时间测量可以直接在内联汇编中完成而无需调用,则可以使用此技术。例如 gem5 magic instrumentation instructions, x86 RDTSC(不确定这是否具有代表性)和可能的其他性能计数器就是这种情况。

相关话题:

  • Is it legal for a C++ optimizer to reorder calls to clock()?

已使用 GCC 8.3.0、Ubuntu 19.04 进行测试。