条件付き判断ステートメントと分岐予測

ここに画像の説明を挿入この作品は、クリエイティブ・コモンズ表示-非営利目的-共有4.0国際ライセンス契約に基づい同じ方法ライセンスされています
ここに画像の説明を挿入この作品(LizhaoロングによってボーエンリーZhaolongのによって作成)李Zhaolongの確認は、著作権を明記してください。

前書き

この記事を書く理由は、[1]の問題を見るためです。分岐予測については以前に学びましたが、極端な条件下でのパフォーマンスへの影響がそれほど大きくなるとは思っていませんでした。もともとこの質問とその答えを自分の理解に基づいて詳しく説明したかったのですが、すでに多くの記事がこの目標を達成していることがわかったので、この記事で簡単に説明します。

問題の説明

1つ目は、非常に古典的なコードを確認することです。

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    
    
    const unsigned ARRAY_SIZE = 50000;
    int data[ARRAY_SIZE];
    const unsigned DATA_STRIDE = 256;

    for (unsigned c = 0; c < ARRAY_SIZE; ++c) data[c] = std::rand() % DATA_STRIDE;

    std::sort(data, data + ARRAY_SIZE);

    {
    
      // 测试部分
        clock_t start = clock();
        long long sum = 0;

        for (unsigned i = 0; i < 100000; ++i) {
    
    
            for (unsigned c = 0; c < ARRAY_SIZE; ++c) {
    
    
                if (data[c] >= 128) sum += data[c];
            }
        }

        double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

        std::cout << elapsedTime << "\n";
        std::cout << "sum = " << sum << "\n";
    }
    return 0;
}

もちろん、常識的には影響はありませんが、O(N)バラからの時間計算量のため、注文は遅くなりますO(NlogN)。結果は次のとおりです。
ここに画像の説明を挿入
上記ソートは追加されますが、以下は追加されません。

どうですか、ショックを受けました。3倍以上のパフォーマンスギャップがあり、これは完全に期待と一致していません。質問への答えは実際には分支预测です。

マイクロコンピュータの原理を学んだときBLU、CPUの場合、命令は最初から最後まで次の手順を実行する必要があるため、実際にはプリフェッチ命令が実行されることがわかりました

  1. フェッチ:フェッチ
  2. 翻訳とは:デコード
  3. 実行:実行
  4. 回写:Write-back

明らかに実行は一部にすぎません。CPUはバスリソースを無駄にすることなく実行でき、命令の実行後に命令をフェッチするプロセスを待つ必要がないため、アプリケーションパイプライン(パイプライン)は明らかにCPUを圧縮するためのより良い方法です。 。ただし、条件付き判断ステートメントに遭遇すると問題が発生する可能性があります。判断条件の正誤によっては、ジャンプが発生する可能性があり、条件付き判断の前にどの分岐にジャンプするかがわかりません。明らかに2つあります。 1つは同期的に待機することで、間違った命令がフェッチされないようにしますが、非常に低速です。2つ目の方法は、特定の条件に基づいて分岐を選択し、それを最初に命令キューにロードして、成功を予測することです。エラーが発生した場合は、バッファをフラッシュし、前の分岐にロールバックして、命令を再フェッチします。特定の分岐予測戦略については、[5] [3]を参照してください。

Wikiには、分岐予測に関する次の説明があります[3]。

  • 分岐予測がない場合、プロセッサは、条件付きジャンプ命令が実行ステージを通過するまで待機してから、次の命令がパイプラインのフェッチステージに入る必要があります。分岐予測子は、条件付きジャンプが実行される可能性が最も高いかどうかを推測することにより、この時間の浪費を回避しようとします。次に、最も可能性が高いと推測されるブランチがフェッチされ、投機的に実行されます。推測が間違っていたことが後で検出された場合、投機的に実行された、または部分的に実行された命令は破棄され、パイプラインは正しい分岐からやり直し、遅延が発生します。
  • ブランチの予測ミスの場合に浪費される時間は、フェッチステージから実行ステージまでのパイプラインのステージ数に等しくなります。最近のマイクロプロセッサはパイプラインが非常に長い傾向があるため、予測ミスの遅延は10〜20クロックサイクルです。その結果、パイプラインを長くすると、より高度な分岐予測子の必要性が高まります。
  • 分岐予測がない場合、プロセッサは条件付きジャンプ命令が実行ステージを通過するまで待機する必要があり、その後、次の命令がパイプラインのフェッチステージに入ることができます。分岐予測子は、条件付きジャンプが発生する可能性があるかどうかを推測することにより、時間の浪費を回避しようとします。次に、推測される可能性が最も高いブランチを取得し、投機的に実行します。後で推測が間違っていることが検出された場合、投機的に実行された命令または部分的に実行された命令は破棄され、パイプラインは正しいブランチから再開され、遅延が発生します。
  • 分岐予測が正しくない場合に浪費される時間は、取得段階から実行段階までのパイプラインの段階数に等しくなります。最近のマイクロプロセッサはパイプラインが非常に長いことが多いため、予測ミスの遅延は10〜20クロックサイクルです。その結果、パイプラインを長くすると、より高度な分岐予測子の需要が高まります。

したがって、上記の簡単なデモから簡単な結論を導き出すことができます。ループの数が多い状況での最適化ロジックの判断に注意することを忘れないでください。


もちろん、デザインパターンでストラテジーパターンを使用すると、複数のif-else問題を回避できますが、一般的なストラテジーパターンの実装は、基本的にテーブルルックアップ[6]に基づいています。つまり、ハッシュテーブルがその中に配置され、さまざまなタイプのパラメーターがあります。渡されます。さまざまなコードブロックを実行します。戦略パターンの定義は次のとおりです。

  • アルゴリズムのファミリーを定義し、それぞれをカプセル化して、互換性を持たせます。ストラテジーにより、アルゴリズムはそれを使用するクライアントとは独立して変化します。
  • アルゴリズムクラスのファミリーを定義し、各アルゴリズムを個別にカプセル化して、相互に置き換えることができるようにします。ストラテジーモードでは、アルゴリズムを使用するクライアントとは関係なく、アルゴリズムを変更できます。

戦略パターンが実行したいのは、単に効率を上げることではなく、呼び出し元とコードプロバイダーを疎結合にし、オープンとクローズの原則を満たすことであることがわかります。

もちろん、私たちがもっと頻繁に必要とするのは、コードの可読性と保守性です。パフォーマンスの最適化は非常に遅い問題であり、ストレージやカーネルのシナリオなど、命令レベルの最適化を必要とするシナリオは少ないと思います。

最適化

上記のコードは確かに極端なので、最適化できますか?もちろん、ビット演算を使用して条件分岐を最適化することは可能です。このように、分岐予測はCPUに必要ではなく、もちろん、上記のコードのような乱れたパフォーマンスの変化はありません。

戦略は[2]から来ています:

|x| >> 31 = 0 # 非负数右移31为一定为0
~(|x| >> 31) = -1 # 0取反为-1
 
-|x| >> 31 = -1 # 负数右移31为一定为0xffff = -1
~(-|x| >> 31) = 0 # -1取反为0
 
-1 = 0xffff
-1 & x = x # 以-1为mask和任何数求与,值不变
int t = (data[c] - 128) >> 31; # statement 1
sum += ~t & data[c]; # statement 2

もっと賢いのはこれです:

int t=-((data[c]>=128)); # generate the mask
sum += ~t & data[c]; # bitwise AND

実際data[c]、128より大きい&は対応する0xffffであり、逆に0です。条件よりも大きいこの種の判断ステートメントは実際には非常に一般的であるため、プログラミングスキルの一種である多くの場所でそれを変更できます。[7]でアライメント操作のより多くの使用法を見ることができます。

コードのインスピレーション

実際、私が話したいのは2つのGCCbuilt-in functionsです。これらは分岐予測に関連しており、そのうちの1つはコードで使用できるはずです。

__builtin_speculation_safe_value

この関数の役割は、GNUマニュアルで次のように説明されています。

  • この組み込み関数は、安全でない投機的実行を軽減するために使用できます。typeは、任意の整数型または任意のポインター型にすることができます。
  • この組み込み関数は、安全でない予測実行を軽減するために使用できます。タイプは、任意の整数タイプまたは任意のポインタータイプにすることができます。

以下の例がマニュアルに記載されています。

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return array[untrusted_index];
  return 0;
}

これは実際には私たちの日常のコーディングプロセスでは問題ありませんが、実際にはこのコードは少し危険であり、問​​題は分岐予測にあります。

500未満の値でこの関数を複数回呼び出してから、範囲外の値で関数を呼び出すと、CPUが予測が正しくないと判断するまで、最初にコードブロックを実行しようとします(CPUはすべての誤った操作をキャンセルします)。ただし、関数の結果の使用方法によっては、一部のトレースがキャッシュに残る場合があり、これらのトレースにより、範囲外の場所に格納されているコンテンツが明らかになる可能性がありますこれ__builtin_speculation_safe_value、以下を使用することで回避できます。

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return array[__builtin_speculation_safe_value (untrusted_index)];
  return 0;
}

コードを上記の形式に変更すると、セキュリティが保証されます。マニュアルには、組み込み関数には現時点で2つの可能な動作があると記載されています。

  1. 条件分岐が完全に解決されるまで実行を停止します。
  2. 投機的実行の続行を許可できますが、制限を超えると、代わりに0が使用されますuntrusted_value

マニュアルには、推測が正しく実行されない場合、メモリ位置にアクセスするのは安全ではない可能性があると記載されています。現時点では、コードは次のように書き直すことができます。

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return *__builtin_speculation_safe_value (&array[untrusted_index], NULL);
  return 0;
}

この状況は実際にはもっと混乱します。実行の2番目の可能性、つまり0を使用することは危険な動作であると説明できると思います。現時点でarray[untrusted_index]は、それが予測値である場合は、直接キャッシュに入れます。NULL

__builtin_expect

マニュアルで次のように説明されている単純で失礼な機能:

  • __builtin_expectを使用して、コンパイラに分岐予測情報を提供できます。

もちろん、プログラムのアクセス頻度が確かな場合はこれを使用できますが、マニュアルでばかげているように:

プログラマーは、プログラムが実際にどのように実行されるかを予測するのが苦手なことで有名です。

ただし、多くのシナリオは依然として非常に便利です。非常に古典的な例では、ロギングが必要ですgettid。これは明らかに非常にコストのかかるシステムコールですが、スレッドの実行中に変更されることはありません。したがって、キャッシュ値はごく普通です。そして合理的な操作、私たちはこのように書くことができます、コードはadlのadlserverから来ています

//currentTheread.h
extern __thread int t_cachedTid;
void cacheTid();
inline int tid() {
    
    
  if (__builtin_expect(t_cachedTid == 0, 0)) {
    
    
    cacheTid();
  }
  return t_cachedTid;
}
//currentTheread.cpp
__thread int CurrentThread::t_cachedTid = 0;

void CurrentThread::cacheTid() {
    
    
  if (t_cachedTid == 0) {
    
    
    t_cachedTid = adl::gettid();
  }
}

コードは非常に理解しやすいので、説明しません。

総括する

元旦に大晦日のお金をもっと集めてください!正月は体が健康になり、気分も格段に良くなります!毎日頑張って、味が美味しい!金は家にあり、紙幣は壁に長いです。

参照:

  1. コードのif-elseブランチの何が問題になっていますか?」保守性に加えて、プログラムの運用効率にも影響はありますか?
  2. CPU分岐予測(分岐予測)モデルの詳細な理解
  3. wiki分岐予測
  4. GNUマニュアルhttps://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html
  5. 分岐予測
  6. オタクタイムデザインパターンの美しさ
  7. ビットをいじるハック

おすすめ

転載: blog.csdn.net/weixin_43705457/article/details/113797121