web-dev-qa-db-ja.com

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;
99
S2108887

これがC++標準委員会と議論された後、もう少し包括的な答えを提供したいと思います。 C++委員会のメンバーであることに加えて、LLVMおよびClangコンパイラの開発者でもあります。

基本的に、これらの変換を達成するためにバリアまたはシーケンス内の何らかの操作を使用する方法はありません。基本的な問題は、整数加算のようなものの操作上のセマンティクスが実装に完全に知られていることです。それらをシミュレートでき、正しいプログラムではそれらを観察できないことを認識しており、いつでも自由に移動できます。

これを防ぐことはできますが、非常に悪い結果になり、最終的には失敗します。

まず、コンパイラでこれを防ぐ唯一の方法は、これらの基本的な操作がすべて観察可能であることを伝えることです。問題は、これによりコンパイラーの最適化の圧倒的多数が排除されることです。コンパイラの内部には、timingが観測可能であることをモデル化する優れたメカニズムはありませんが、他には何もありません。 どのような操作に時間がかかるかの良いモデルさえありません。例として、32ビットの符号なし整数を64ビットの符号なし整数に変換するには時間がかかりますか? x86-64ではゼロ時間がかかりますが、他のアーキテクチャではゼロ以外の時間がかかります。ここには一般的に正しい答えはありません。

しかし、コンパイラーがこれらの操作を並べ替えるのを防ぐことにいくつかの英雄的手段で成功したとしても、これで十分であるという保証はありません。 x86マシンでDynamoRIOを使用してC++プログラムを実行するための有効かつ適合した方法を検討してください。これは、プログラムのマシンコードを動的に評価するシステムです。できることの1つはオンライン最適化であり、タイミング以外の基本的な算術命令の全範囲を投機的に実行することさえできます。また、この動作は動的エバリュエーターに固有のものではありません。実際のx86 CPUは(非常に少数の)命令を推測し、動的に並べ替えます。

本質的な認識は、算術が(タイミングレベルでさえ)観測できないという事実は、コンピューターのレイヤーに浸透しているということです。コンパイラー、ランタイム、そして多くの場合ハードウェアにも当てはまります。強制的に観察可能にすると、コンパイラーは劇的に制約されますが、ハードウェアも劇的に制約されます。

しかし、これらすべてがあなたに希望を失わせることはないはずです。基本的な数学的操作の実行時間を計りたい場合、信頼性の高い方法を十分に研究しています。通常、これらはマイクロベンチマークを行うときに使用されます。これについては、CppCon2015で講演しました。 https://youtu.be/nXaxk27zwlk

ここに示されている手法は、Googleなどのさまざまなマイクロベンチマークライブラリでも提供されています。 https://github.com/google/benchmark#preventing-optimisation

これらの手法の鍵は、データに焦点を当てることです。計算への入力をオプティマイザーに対して不透明にし、計算の結果をオプティマイザーに対して不透明にします。それができたら、確実に時間を計ることができます。元の質問の例の現実的なバージョンを見てみましょうが、fooの定義は実装に対して完全に可視です。また、Google BenchmarkライブラリからDoNotOptimizeの(非ポータブル)バージョンを抽出しました。これは、ここで見つけることができます。 https://github.com/google/benchmark/blob/master/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の周りで最適化不可能としてマークされ、それらのマーカーの周りだけでタイミングが計算されるようにします。データを使用して計算を挟むため、2つのタイミングの間に留まることが保証されますが、計算自体は最適化されます。 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をここで標準化する可能性を検討しています。

88

概要:

順序変更を防ぐ保証された方法はないようですが、リンク時間/プログラム全体の最適化が有効になっていない限り、呼び出された関数を別のコンパイル単位に配置することはかなり良い方法だと思われます。 (少なくとも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;
}

GCC 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    $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

しかし:

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

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

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

_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
60
Jeremy

並べ替えは、コンパイラまたはプロセッサによって行われます。

ほとんどのコンパイラは、読み書き命令の並べ替えを防止するプラットフォーム固有の方法を提供します。 gccでは、これは

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

詳細はこちら

読み取り/書き込みに依存している限り、これは間接的に並べ替え操作のみを防ぐことに注意してください。

実際にはClock::now()のシステムコールがそのような障壁と同じ効果を持つシステムを見たことはありません。結果のアセンブリを確認して確認することができます。

ただし、テスト中の関数がコンパイル時に評価されることは珍しくありません。 「現実的な」実行を強制するには、I/Oまたはvolatile読み取りからfoo()の入力を取得する必要があります。


別のオプションは、foo()のインライン化を無効にすることです。これもコンパイラ固有であり、通常は移植性がありませんが、同じ効果があります。

Gccでは、これは__attribute__ ((noinline))になります


@Ruslanは根本的な問題を提起します:この測定はどれほど現実的ですか?

実行時間は多くの要因の影響を受けます。1つは実行中の実際のハードウェア、もう1つはキャッシュ、メモリ、ディスク、CPUコアなどの共有リソースへの同時アクセスです。

したがって、通常、比較可能なタイミングを取得するために行うことは、それらが再現可能であることを確認し、エラーマージンを低くすることです。これにより、多少人工的になります。

「ホットキャッシュ」と「コールドキャッシュ」の実行パフォーマンスは、桁違いに簡単に異なる可能性がありますが、実際には、その間に何かがあります(「ぬるま湯」?)

20
peterchen

C++言語は、さまざまな方法で観察可能なものを定義します。

foo()が観測可能なものを何も実行しない場合、完全に削除できます。 foo()が値を「ローカル」状態(スタック上またはオブジェクト内)に格納する計算のみを行う場合、およびコンパイラーは安全に派生したポインターができないことを証明できますClock::now()コードに入ると、Clock::now()呼び出しの移動に目に見える影響はありません。

foo()がファイルまたはディスプレイと相互作用し、コンパイラがClock::now()notをファイルまたはディスプレイと相互作用することを証明できない場合、相互作用のために並べ替えを実行できませんファイルまたはディスプレイで観察可能な動作です。

コンパイラ固有のハッキングを使用して、コードが動き回らないようにすることができます(インラインアセンブリなど)が、別のアプローチは、コンパイラを裏切ることです。

動的にロードされるライブラリを作成します。問題のコードの前にロードします。

そのライブラリは1つのことを公開します。

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()sを除去できますが、いくつか勝ち、いくつかを失います。

いいえ、できません。 C++標準[intro.execution]によると:

14完全な式に関連付けられたすべての値の計算と副作用は、評価される次の完全な式に関連付けられたすべての値の計算と副作用の前にシーケンスされます。

完全な式は、基本的にセミコロンで終了するステートメントです。上記のルールを見るとわかるように、ステートメントは順番に実行する必要があります。 withinステートメントは、コンパイラがより自由な手綱を許可されていることです(つまり、左から右または他の特定のもの以外の順序でステートメントを構成する式を評価することが許可されている状況下にあります)。

ここで適用されるas-ifルールの条件に注意してください。どんなコンパイラでも証明ができると考えるのは不合理です。システム時間を取得するために呼び出しを並べ替えても、観測可能なプログラムの動作には影響しません。観測された動作を変更せずに、時間を取得するための2つの呼び出しを並べ替えることができる状況があった場合、これを確実に推測できる十分な理解を持ってプログラムを分析するコンパイラーを実際に作成することは非常に非効率的です。

3
Smeeheey

いいえ

「as-if」ルールにより、ステートメントの順序が変わる場合があります。これは、それらが論理的に互いに独立しているためではなく、その独立性により、プログラムのセマンティクスを変更せずにこのような並べ替えが発生するためです。

現在の時刻を取得するシステムコールを移動しても、明らかにその条件を満たしません。故意または無意識のうちにそうするコンパイラは、非準拠であり、本当にばかげています。

一般的に、積極的に最適化するコンパイラーでさえ、システムコールをもたらす式が「セカンド推測」されるとは期待していません。それは、そのシステムコールが何をするかについて十分に知らないだけです。

noinline関数+インラインアセンブリブラックボックス+完全なデータ依存関係

これは https://stackoverflow.com/a/38025837/895245 に基づいていますが、::now()を並べ替えることができない理由の明確な正当化が見当たらないため、むしろ偏執狂的であり、それをasmと一緒にnoinline関数内に入れます。

この方法では、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__("" : "+m"(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 = 10000;
    }

    // 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 "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
              << std::endl;
}

コンパイルして実行:

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

このメソッドの唯一の小さな欠点は、callqメソッドの上に1つの余分なinline命令を追加することです。 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のタイミング効果に気付く可能性は低いと思います。 ::now()は共有ライブラリにあるため、1つのcallが必要であることに注意してください。

データ依存関係を使用してインラインアセンブリから::now()を呼び出します

これは、上記の余分なjmpqをも克服し、可能な限り最も効率的なソリューションになります。

残念ながら、次のように正しく実行するのは非常に困難です。 拡張インラインASMでのprintfの呼び出し

ただし、時間測定を呼び出しなしでインラインアセンブリで直接実行できる場合は、この手法を使用できます。これは、たとえば、 gem5マジックインスツルメンテーション命令 、x86 RDTSC (これがもう代表的なものかどうかはわかりません)およびその他のパフォーマンスカウンターの場合です。

GCC 8.3.0、Ubuntu 19.04でテスト済み。