web-dev-qa-db-ja.com

C ++オプティマイザーがこれらの一時変数に問題を抱えているのはなぜですか、それともなぜ `v []`をタイトループで回避する必要があるのですか?

この コードスニペット では、機能的に同じ2つのループのパフォーマンスを比較しています。

for (int i = 1; i < v.size()-1; ++i) {
  int a = v[i-1];
  int b = v[i];
  int c = v[i+1];

  if (a < b  &&  b < c)
    ++n;
}

そして

for (int i = 1; i < v.size()-1; ++i) 
  if (v[i-1] < v[i]  &&  v[i] < v[i+1])
    ++n;

最適化フラグがO2に設定されているさまざまなC++コンパイラで、最初のものは2番目のものよりも大幅に遅くなります。

  • clang 3.7.0では、2番目のループは約330%slowerです
  • 2番目のループはgcc 4.9.3で約2%遅くなります
  • visual C++ 2015では2番目のループが約2%遅くなります

最新のC++オプティマイザーには、このケースの処理に問題があることに戸惑っています。何か手がかりはありますか?最高のパフォーマンスを得るには、一時変数を使用せずにいコードを記述する必要がありますか?

一時変数を使用すると、コードが高速になり、時には劇的になります。何が起こっている?

私が使用している完全なコードは次のとおりです。

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) {
    int a = v[i-1];
    int b = v[i];
    int c = v[i+1];

    if (a < b  &&  b < c)
      ++n;
  }

  return n;
}


int f1()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
    if (v[i-1] < v[i]  &&  v[i] < v[i+1])
      ++n;

  return n;
}


int main()
{
  auto benchmark = [](int (*f)()) {
    const int N = 100;

    volatile long long result = 0;
    vector<long long>  timings(N);

    for (int i = 0; i < N; ++i) {
      auto t0 = high_resolution_clock::now(); 
      result += f();
      auto t1 = high_resolution_clock::now(); 

      timings[i] = duration_cast<nanoseconds>(t1-t0).count();
    }

    sort(timings.begin(), timings.end());
    cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
    cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
  };

  mt19937                    generator   (31415);   // deterministic seed
  uniform_int_distribution<> distribution(0, 1023);

  for (auto& e: v) 
    e = distribution(generator);

  benchmark(f0);
  benchmark(f1);

  cout << "\ndone\n";

  return 0;
}
62
Paul Jurczak

コンパイラには、std::vector<>::size()と内部ベクトルバッファサイズの関係についての知識がないようです。 std::vectorがわずかなバグのあるカスタムのbugged_vectorベクトルのようなオブジェクトであることを考慮してください-その::size()は内部バッファーサイズnよりも1つ大きいことがありますが、それはv[n-2] >= v[n-1]のみです。

次に、2つのスニペットのセマンティクスが再び異なります。最初のエレメントには、要素v[v.size() - 1]にアクセスするための未定義の動作があります。ただし、2番目のものにはありません。&&の短絡性のため、最後の反復でv[v.size() - 1]を読み取ることはありません。

そのため、コンパイラがvbugged_vectorでないことを証明できない場合は、短絡する必要があり、これによりマシンコードに追加のジャンプが発生します。

clangからのアセンブリ出力を見ると、実際に発生していることがわかります。

Godbolt Compiler Explorer から、clang 3.7.0 -O2では、f0のループは次のとおりです。

### f0: just the loop
.LBB1_2:                                # =>This Inner Loop Header: Depth=1
    mov     edi, ecx
    cmp     edx, edi
    setl    r10b
    mov     ecx, dword ptr [r8 + 4*rsi + 4]
    lea     rsi, [rsi + 1]
    cmp     edi, ecx
    setl    dl
    and     dl, r10b
    movzx   edx, dl
    add     eax, edx
    cmp     rsi, r9
    mov     edx, edi
    jb      .LBB1_2

_f1の場合:

### f1: just the loop
.LBB2_2:                                # =>This Inner Loop Header: Depth=1
    mov     esi, r10d
    mov     r10d, dword ptr [r9 + 4*rdi]
    lea     rcx, [rdi + 1]
    cmp     esi, r10d
    jge     .LBB2_4                     # <== This is Extra Jump
    cmp     r10d, dword ptr [r9 + 4*rdi + 4]
    setl    dl
    movzx   edx, dl
    add     eax, edx
.LBB2_4:                                # %._crit_Edge.3
    cmp     rcx, r8
    mov     rdi, rcx
    jb      .LBB2_2

f1の余分なジャンプを指摘しました。そして、(願わくば)知っているように、タイトループでの条件付きジャンプはパフォーマンスに悪影響を及ぼします。 (詳細については、 x86 タグwikiのパフォーマンスガイドを参照してください。)

GCCとVisual Studioはstd::vectorが適切に動作することを認識しており、両方のスニペットに対してほぼ同一のアセンブリを生成します。 編集clangの方がコードの最適化に優れていることがわかります。 3つすべてのコンパイラーは、2番目の例で比較する前にv[i + 1]を読むことが安全であることを証明できません(または選択しないことを選択します)が、v[i + 1]の読み取りが有効またはUBであるという追加情報で最初の例を最適化するのはclangのみです。

2%のパフォーマンスの違いは無視できますが、いくつかの命令の異なる順序または選択によって説明できます。

50
deniss

以下は、@ denissの答えを拡張するための追加の洞察で、問題を正しく診断しました。

ちなみに、これは これまでで最も人気のあるC++ Q&A "なぜソートされていない配列よりもソートされた配列を高速に処理しているのですか?" .

主な問題は、コンパイラーが論理AND演算子(&&)を尊重しなければならず、最初の条件が真でない限りv [i + 1]からロードしないことです。これは、論理AND演算子のセマンティクスとC++ 11で導入されたメモリモデルセマンティクスの強化の結果であり、標準のドラフトの関連する句は次のとおりです。

5.14論理AND演算子[expr.log.and]

とは異なり、&&は左から右への評価を保証します:2番目最初のオペランドがfalseの場合、オペランドは評価されません。
ISO C++ 14標準(ドラフトN3797)

投機的読み取りの場合

1.10マルチスレッド実行とデータ競合[intro.multithread]

23[注:潜在的に共有されるメモリ位置の投機的読み取りを導入する変換は、C++プログラムのセマンティクスをデータ競合を引き起こす可能性があるため、この標準で定義されています。ただし、これらは通常、データの競合に対して明確に定義されたセマンティクスを持つ特定のマシンをターゲットとする最適化コンパイラのコンテキストで有効です。これらは、レースを許容しない、またはハードウェアのレース検出を提供する仮想マシンでは無効です。 —end note]
ISO C++ 14標準(ドラフトN3797)

私の推測では、オプティマイザーは安全にプレイし、投機的負荷がそのターゲットの検出可能なデータ競合を引き起こす可能性があるかどうか、ターゲットプロセッサごとに特別なケースではなく、潜在的に共有メモリに投機的負荷を発行しないことを現在選択しています。

これを実装するために、コンパイラは条件分岐を生成します。最近のプロセッサには非常に高度な分岐予測があり、予測ミス率は通常非常に低いため、通常これは目立ちません。ただし、ここのデータはランダムです-これは分岐予測を無効にします。予測ミスのコストは、CPUが通常サイクルあたり2命令を廃止することを考慮すると、10〜20 CPUサイクルです。これは20〜40命令に相当します。予測率が50%(ランダム)の場合、すべての反復には10から20命令に相当する予測ミスのペナルティがあります-[〜#〜] huge [〜#〜]

注:コンパイラは、要素_v[0]_からv[v.size()-2]がその順序で参照されることを、それらに含まれる値。これにより、この場合のコンパイラは、ベクトルの最後の要素を除いてすべてを無条件にロードするコードを生成できます。 v [v.size()-1]にあるベクトルの最後の要素は、ループの最後の反復でのみロードでき、最初の条件が真である場合にのみロードできます。したがって、コンパイラーは、最後の反復まで短絡分岐のないループのコードを生成し、最後の反復の短絡分岐で別のコードを使用できます-データがランダムであり、分岐予測が役に立たないことをコンパイラーに知らせる必要がありますしたがって、それを気にする価値はあります-コンパイラはそれほど洗練されていません-まだ。

論理AND(&&)によって生成される条件分岐を回避し、メモリ位置をローカル変数にロードしないようにするために、論理AND演算子をビット単位のANDに変更できます コードスニペット 、結果はほぼ4倍になりますデータがランダムな場合は高速

_int f2()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
     n += (v[i-1] < v[i])  &  (v[i] < v[i+1]); // Bitwise AND

  return n;
}
_

出力

_3.642443ms min
3.779982ms median
Result: 166634

3.725968ms min
3.870808ms median
Result: 166634

1.052786ms min
1.081085ms median
Result: 166634


done
_

Gcc 5.3の結果は8倍高速です( Coliruでのライブ

_g++ --version
g++ -std=c++14  -O3 -Wall -Wextra -pedantic -pthread -pedantic-errors main.cpp -lm  && ./a.out
g++ (GCC) 5.3.0
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

3.761290ms min
4.025739ms median
Result: 166634

3.823133ms min
4.050742ms median
Result: 166634

0.459393ms min
0.505011ms median
Result: 166634


done
_

コンパイラーが比較_v[i-1] < v[i]_withoutを使用して条件分岐を生成する方法を疑問に思うかもしれません。答えはターゲットによって異なります。x86の場合、これはSETcc命令により可能です。この命令は、EFLAGSレジスタの条件に応じて1バイトの結果(0または1)を生成します。条件分岐ではありますが、分岐はありません。 @denissによって生成された生成コードでは、生成されたsetlを見ることができます。これは、条件「より小さい」が満たされた場合に結果を1に設定します。

_cmp     edx, edi       ; a < b ?
setl    r10b           ; r10b = a < b ? 1 : 0
mov     ecx, dword ptr [r8 + 4*rsi + 4] ; c = v[i+1]
lea     rsi, [rsi + 1] ; ++i
cmp     edi, ecx       ; b < c ?
setl    dl             ; dl = b < c ? 1 : 0
and     dl, r10b       ; dl &= r10b
movzx   edx, dl        ; edx = zero extended dl
add     eax, edx       ; n += edx
_
41
amdn

f0とf1は意味的に異なります。

x() && y()には、x()が既知のようにfalseである場合の短絡が含まれます。これは、x() falseの場合、y() must notが評価されます。

これにより、y())を評価するためのデータのプリフェッチが防止され、(少なくともclangでは)条件付きジャンプが挿入され、分岐予測ミスが発生します。

別の2つのテストを追加することがポイントを証明します。

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
    int n = 0;

    for (int i = 1; i < v.size()-1; ++i) {
        int a = v[i-1];
        int b = v[i];
        int c = v[i+1];

        if (a < b  &&  b < c)
            ++n;
    }

    return n;
}


int f1()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
        if (v[i-1] < v[i]  &&  v[i] < v[i+1])
            ++n;

    return n;
}

int f2()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        auto t1 = v[i-1] < v[i];
        auto t2 = v[i] < v[i+1];
        if (t1 && t2)
            ++n;
    }

    return n;
}

int f3()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        n += 1 * (v[i-1] < v[i]) * (v[i] < v[i+1]);
    }

    return n;
}



int main()
{
    auto benchmark = [](int (*f)()) {
        const int N = 100;

        volatile long long result = 0;
        vector<long long>  timings(N);

        for (int i = 0; i < N; ++i) {
            auto t0 = high_resolution_clock::now();
            result += f();
            auto t1 = high_resolution_clock::now();

            timings[i] = duration_cast<nanoseconds>(t1-t0).count();
        }

        sort(timings.begin(), timings.end());
        cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
        cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
    };

    mt19937                    generator   (31415);   // deterministic seed
    uniform_int_distribution<> distribution(0, 1023);

    for (auto& e: v) 
        e = distribution(generator);

    benchmark(f0);
    benchmark(f1);
    benchmark(f2);
    benchmark(f3);

    cout << "\ndone\n";

    return 0;
}

結果(Apple clang、-O2):

1.233948ms min
1.320545ms median
Result: 166850

3.366751ms min
3.493069ms median
Result: 166850

1.261948ms min
1.361748ms median
Result: 166850

1.251434ms min
1.353653ms median
Result: 166850
7
Richard Hodges

これまでのところ、gccまたはclangが完全に最適化できるf()のバージョンは提供されていません。これらはすべて、各反復を両方とも比較するasmを生成します。 Gosbolt Compiler Explorer のasm出力を含むコードを参照してください。 (asm出力からパフォーマンスを予測するための重要な背景知識: Agner Fogのマイクロアーキテクチャガイド 、および x86 タグwikiのその他のリンク。いつものように、通常はパフォーマンスでプロファイルするのが最適です。屋台を見つけるためのカウンター。)

_v[i-1] < v[i]_は、_v[i] < v[i+1]_を評価したときに、すでに最後の反復を行った作業です。理論的には、コンパイラの最適化を支援することで、最適化が向上します(f3()を参照)。実際には、それは場合によっては自動ベクトル化を無効にすることになり、gccは_-mtune=core2_が大きな問題である場合でも、部分レジスタストールでコードを出力します。

ループの上限チェックからv.size() - 1を手動で引き上げることが役立つようです。 OPの_f0_および_f1_は、v内の開始/終了ポインターからv.size()を実際に再計算しませんが、どういうわけか最適化がいつまでも行われませんループ外でsize_t upper = v.size() - 1を計算する(f2()およびf4())。

別の問題は、intループカウンターを_size_t_上限で使用すると、ループが潜在的に無限であることを意味します。これが他の最適化にどれほどの影響を与えるかはわかりません。


一番下の行:コンパイラーは複雑な獣です。どのバージョンが適切に最適化されるかを予測することは、まったく明白でも簡単でもありません。


Core2 E6600(Merom/Conroeマイクロアーキテクチャ)上の64ビットUbuntu 15.10での結果。

_clang++-3.8 -O3 -march=core2   |   g++ 5.2 -O3 -march=core2         | gcc 5.2 -O2 (default -mtune=generic)
f0    1.825ms min(1.858 med)   |   5.008ms min(5.048 med)           | 5.000 min(5.028 med)
f1    4.637ms min(4.673 med)   |   4.899ms min(4.952 med)           | 4.894 min(4.931 med)
f2    1.292ms min(1.323 med)   |   1.058ms min(1.088 med) (autovec) | 4.888 min(4.912 med)
f3    1.082ms min(1.117 med)   |   2.426ms min(2.458 med)           | 2.420 min(2.465 med)
f4    1.291ms min(1.341 med)   |   1.022ms min(1.052 med) (autovec) | 2.529 min(2.560 med)
_

結果は、Intel SnBファミリのハードウェアでは異なります。 IvyBridge以降では、部分的なレジスタの減速はまったくありません。 Core2は、低速の非整列負荷によって制限され、サイクルごとに1つの負荷のみです。ただし、ループは十分に小さいため、デコードは問題になりません。


_f0_および_f1_:

gcc 5.2:OPの_f0_および_f1_は両方とも分岐ループを作成し、自動ベクトル化されません。ただし、_f0_は1つのブランチのみを使用し、奇妙な_setl sil_/_cmp sil, 1_/_sbb eax, -1_を使用して、短絡比較の後半を実行します。そのため、繰り返しごとに両方の比較を行っています。

clang 3.8:_f0_:反復ごとに1回だけロードしますが、両方を比較し、andsを一緒に実行します。 _f1_:両方とも各反復を比較し、1つはCセマンティクスを保持するためのブランチを使用します。反復ごとに2つのロード。


_int f2() {
  int n = 0;
  size_t upper = v.size()-1;   // difference from f0: hoist upper bound and use size_t loop counter
  for (size_t i = 1; i < upper; ++i) {
    int a = v[i-1], b = v[i], c = v[i+1];
    if (a < b  &&  b < c)
      ++n;
  }
  return n;
}
_

gcc 5.2 _-O3_:3つのロードで自動ベクトル化し、4つの比較結果の1つのベクトルを生成するために必要な3つのオフセットベクトルを取得します。また、2つのpcmpgtd命令からの結果を結合した後、それらをすべてゼロのベクトルと比較し、それをマスクします。ゼロはすでに追加のアイデンティティ要素であるため、それは本当にばかげています。

clang 3.8 _-O3_:展開:すべての反復で2つのロード、3つのcmp/setcc、2つのands、および2つのaddsを実行します。


_int f4() {
  int n = 0;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int a = v[i-1], b = v[i], c = v[i+1];
      bool ab_lt = a < b;
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);  // some really minor code-gen differences from f2: auto-vectorizes to better code that runs slightly faster even for this large problem size
  }

  return n;
}
_
  • gcc 5.2 _-O3_:_f2_と同様に自動ベクトル化されますが、余分なpcmpeqdはありません。
  • gcc 5.2 _-O2_:これが_f2_の2倍の速度である理由を調査しませんでした。
  • clang _-O3_:_f2_とほぼ同じコード。

コンパイラーの手持ちの試み

_int f3() {
  int n = 0;
  int a = v[0], b = v[1];   // These happen before checking v.size, defeating the loop vectorizer or something
  bool ab_lt = a < b;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int c = v[i+1];       // only one load and compare inside the loop
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);

      ab_lt = bc_lt;
      a = b;                // unused inside the loop, only the compare result is needed
      b = c;
  }
  return n;
}
_
  • clang 3.8 _-O3_:ループ内で4回のロードで展開します(clangは通常、複雑なループ搬送依存関係がない場合、4で展開するのが好きです)。
    4 cmp/setcc、4xおよび/ movzx、4x追加。それで、clangは私が望んでいたことを正確に行い、最適に近いスカラーコードを作成しました。 これは最速のベクトル化されていないバージョンであり、(movups非整列ロードが遅いcore2では)gccのベクトル化バージョンと同じくらい高速です。

  • gcc 5.2 _-O3_:自動ベクトル化に失敗します。それに関する私の理論は、ループ外で配列にアクセスすると自動ベクトル化機構が混乱するということです。おそらく、v.size()をチェックする前にそれを行うか、または単に一般的な理由かもしれません。

    反復ごとに1つのロード、1つのcmp/setcc、および1つのandを使用して、期待するスカラーコードにコンパイルします。ただし、gccは、_-mtune=core2_が大きな問題である場合でも、部分レジスタストールを作成します(マージuopを挿入するための2から3サイクルのストール)その一部のみを書き込んだ後にワイドregを読み取る場合)。 (setccは、8ビットのオペランドサイズでのみ利用可能です。IMOは、AMD64 ISAの設計時にAMDが変更すべきものです。)これが、gccのコードがclangの2.5倍遅い主な理由です。

_## the loop in f3(), from gcc 5.2 -O3 (same code with -O2)
.L31:
    add     rcx, 1    # i,
    mov     edi, DWORD PTR [r10+rcx*4]        # a, MEM[base: _19, index: i_13, step: 4, offset: 0]
    cmp     edi, r8d  # a, a                 # gcc's verbose-asm comments are a bit bogus here: one of these `a`s is from the last iteration, so this is really comparing c, b
    mov     r8d, edi  # a, a
    setg    sil     #, tmp124
    and     edx, esi  # D.111089, tmp124     # PARTIAL-REG STALL: reading esi after writing sil
    movzx   edx, dl                          # using movzx to widen sil to esi would have solved the problem, instead of doing it after the and
    add     eax, edx  # n, D.111085          # n += ...
    cmp     r9, rcx   # upper, i
    mov     edx, esi  # ab_lt, tmp124
    jne     .L31      #,
    ret
_

2
Peter Cordes