Intel SandybridgeファミリCPUのパイプライン用のプログラムの最適化解除


322

私はこの割り当てを完了するために一週間頭を悩ませてきました、そして私はここの誰かが正しい道に向かって私を導くことを望んでいます。インストラクターの指示から始めましょう:

あなたの割り当ては、素数プログラムを最適化することでした最初のラボの割り当ての逆です。この割り当ての目的は、プログラムを悲観的にすること、つまりプログラムの実行を遅くすることです。これらは両方ともCPUを集中的に使用するプログラムです。ラボPCでの実行には数秒かかります。アルゴリズムを変更することはできません。

プログラムを最適化解除するには、Intel i7パイプラインの動作方法に関する知識を活用してください。WAR、RAW、およびその他の危険性を導入するために命令パスを並べ替える方法を想像してみてください。キャッシュの効果を最小限に抑える方法を考えてください。悪魔のような能力がない。

この割り当てでは、砥石またはモンテカルロのプログラムを選択できました。キャッシュ効果のコメントは、ほとんどがWhetstoneにのみ適用されますが、私はモンテカルロシミュレーションプログラムを選択しました。

// Un-modified baseline for pessimization, as given in the assignment
#include <algorithm>    // Needed for the "max" function
#include <cmath>
#include <iostream>

// A simple implementation of the Box-Muller algorithm, used to generate
// gaussian random numbers - necessary for the Monte Carlo method below
// Note that C++11 actually provides std::normal_distribution<> in 
// the <random> library, which can be used instead of this function
double gaussian_box_muller() {
  double x = 0.0;
  double y = 0.0;
  double euclid_sq = 0.0;

  // Continue generating two uniform random variables
  // until the square of their "euclidean distance" 
  // is less than unity
  do {
    x = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    y = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    euclid_sq = x*x + y*y;
  } while (euclid_sq >= 1.0);

  return x*sqrt(-2*log(euclid_sq)/euclid_sq);
}

// Pricing a European vanilla call option with a Monte Carlo method
double monte_carlo_call_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(S_cur - K, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

// Pricing a European vanilla put option with a Monte Carlo method
double monte_carlo_put_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(K - S_cur, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

int main(int argc, char **argv) {
  // First we create the parameter list                                                                               
  int num_sims = 10000000;   // Number of simulated asset paths                                                       
  double S = 100.0;  // Option price                                                                                  
  double K = 100.0;  // Strike price                                                                                  
  double r = 0.05;   // Risk-free rate (5%)                                                                           
  double v = 0.2;    // Volatility of the underlying (20%)                                                            
  double T = 1.0;    // One year until expiry                                                                         

  // Then we calculate the call/put values via Monte Carlo                                                                          
  double call = monte_carlo_call_price(num_sims, S, K, r, v, T);
  double put = monte_carlo_put_price(num_sims, S, K, r, v, T);

  // Finally we output the parameters and prices                                                                      
  std::cout << "Number of Paths: " << num_sims << std::endl;
  std::cout << "Underlying:      " << S << std::endl;
  std::cout << "Strike:          " << K << std::endl;
  std::cout << "Risk-Free Rate:  " << r << std::endl;
  std::cout << "Volatility:      " << v << std::endl;
  std::cout << "Maturity:        " << T << std::endl;

  std::cout << "Call Price:      " << call << std::endl;
  std::cout << "Put Price:       " << put << std::endl;

  return 0;
}

私が行った変更により、コードの実行時間が1秒ほど長くなったようですが、コードを追加せずにパイプラインを停止するために何を変更できるかは完全にはわかりません。正しい方向へのポイントは素晴らしいでしょう、私はどんな反応にも感謝します。


更新:この課題を担当した教授が詳細を投稿しました

ハイライトは次のとおりです。

  • コミュニティカレッジの2学期の建築クラスです(ヘネシーとパターソンのテキストを使用)。
  • ラボのコンピューターにはHaswell CPUが搭載されています
  • 受講者は、CPUID命令とキャッシュサイズの決定方法、および組み込み関数と命令に触れましたCLFLUSH
  • 任意のコンパイラオプションが許可され、インラインasmも許可されます。
  • 独自の平方根アルゴリズムを書くことは、淡い外にあると発表されました

メタスレッドに関するCowmoogunのコメントは、コンパイラーの最適化がこれの一部である可能性が-O0あることは明らかではないことを示しており、実行時間の17%の増加は妥当であると想定しています。

したがって、課題の目標は、生徒に既存の課題を並べ替えて命令レベルの並列処理などを減らすことであるように思われますが、人々が深く掘り下げてより多くを学んだことは悪いことではありません。


これはコンピュータアーキテクチャの問題であり、C ++を一般的に遅くする方法についての問題ではないことに注意してください。


97
私はi7が非常にうまくいっていないと聞いていますwhile(true){}
クリフAB

3
HN atmの2番:news.ycombinator.com/item
id=

5
OpenMPでは、あなたがひどくそれを行う場合には、N個のスレッドが1よりも時間がかかる作ることができるはず
フレキソ

9
この質問は現在メタ
マダラのゴースト

3
@bluefeet:再開されてから1時間も経たないうちにすでに1つの近い投票が集まったため、これを追加しました。メタで議論されているのを見るためにコメントを読んでいることに気付かずに、VTCと一緒に来るのに必要なのは5人だけです。現在、もう1つの近い投票があります。少なくとも1つの文は、クローズ/再開のサイクルを回避するのに役立つと思います。
Peter Cordes

回答:


405

重要な背景資料Agner Fogのmicroarch pdfと、おそらくUlrich Drepperの「すべてのプログラマーがメモリについて知っておくべきこと」。の他のリンクも参照してくださいタグウィキ、特にIntelの最適化マニュアル、およびDavid Kanter によるHaswellマイクロアーキテクチャー分析(図付き)

とてもクールな割り当て。学生がのコードを最適化するように求められgcc -O0た実際のコードでは関係のないたくさんのトリックを学んだ私が見たものよりもはるかに優れています。この場合、CPUパイプラインについて学び、それを使用して、盲目的な推測だけでなく、最適化解除の取り組みをガイドするよう求められます。 この1つの最も楽しい部分は、意図的な悪意ではなく、「悪魔のような無能」で悲観化を正当化することです。


割り当ての表現とコードの問題

このコードのuarch固有のオプションは制限されています。配列を使用せず、コストの多くはexp/ logライブラリ関数の呼び出しです。多かれ少なかれ命令レベルの並列処理を行う明白な方法はなく、ループで運ばれる依存チェーンは非常に短いです。

依存関係を変更するために式を再配置し、依存関係(ハザード)のみからILPを削減することで、速度を低下させようとする答えを見たいです。 私はそれを試みていません。

Intel SandybridgeファミリCPUは、多くのトランジスタと電力を使用して並列処理を検出し、従来のRISCインオーダーパイプラインに支障をきたす危険性(依存性)を回避する積極的なアウトオブオーダー設計です。通常、速度を低下させる従来の唯一の危険は、レイテンシによってスループットが制限される原因となるRAW「真の」依存関係です。

レジスタの名前変更により、レジスタのWARおよびWAWの問題はほとんど問題になりません。(以外popcnt/lzcnt/tzcnt、持っている偽の依存関係にインテルのCPU上で目的地をそれの書き込み専用。すなわちWAWは、RAWハザード+書き込みとして扱われているにもかかわらず、)。メモリの順序付けでは、最新のCPUはストアキューを使用して、リタイアするまでキャッシュへのコミットを遅らせ、WARとWAWの危険も回避します

Agnerの指示表とは異なり、mulssがHaswellで3サイクルしかかからないのはなぜですか?FPドット積ループでのレジスタ名の変更とFMAレイテンシの非表示について詳しく説明します。


"i7"ブランド名はNehalem(Core2の後継)導入されました。一部のIntelマニュアルでは、Nehalemを意味すると思われる場合でも "Core i7"と記載されていますが、Sandybridgeおよびそれ以降のマイクロアーキテクチャでは "i7"ブランドを使用しています。 SnBは、P6ファミリーが新種のSnBファミリーに進化したときです。多くの点で、NehalemはPentium IIIとSandybridgeよりも共通点があります(たとえば、物理レジスタファイルを使用するように変更されたため、SnBではレジスタ読み取りストールとROB読み取りストールは発生しません。また、uopキャッシュと別の内部uop形式)。 「i7アーキテクチャ」という用語は役に立たない、SnBファミリをNehalemとグループ化しても、Core2とはグループ化しないのはほとんど意味がないためです。(ただし、Nehalemは、複数のコアを一緒に接続するための共有の包括的L3キャッシュアーキテクチャを導入しました。また、統合されたGPUも導入しました。したがって、チップレベルでのネーミングの方が理にかなっています。)


悪魔のような無能が正当化できる良いアイデアの要約

悪魔のような能力がない人でも、明らかに役に立たない作業や無限ループを追加する可能性は低く、C ++ / Boostクラスで混乱させることは、割り当ての範囲を超えています。

  • 単一の共有 std::atomic<uint64_t>ループカウンターを備えたマルチスレッドなので、適切な合計反復回数が発生します。Atomic uint64_tは、で特に悪い-m32 -march=i586です。ボーナスポイントについては、位置がずれるように調整し、ページ境界を不均一な分割で横切ってください(4:4ではありません)。
  • その他の非アトミック変数の誤った共有 ->メモリー順序の誤推論パイプラインのクリア、および追加のキャッシュミス。
  • -FP変数で使用する代わりに、上位バイトと0x80をXORして符号ビットを反転し、ストア転送ストールを引き起こします。
  • 各イテレーションを個別に計時しRDTSCます。例:CPUID/ RDTSCまたはシステムコールを作成する時間関数。直列化命令は本質的にパイプラインに対応していません。
  • 定数による乗算を変更して、その逆数で除算します(「読みやすくするため」)。 divは遅く、完全にパイプライン化されていません。
  • AVX(SIMD)でmultiply / sqrtをベクトル化しますvzeroupperが、スカラーの数学ライブラリexp()log()関数の呼び出し前に使用できず、AVX <-> SSE遷移がストールします。
  • RNG出力をリンクリストに格納するか、順不同でトラバースする配列に格納します。各反復の結果についても同じで、最後に合計します。

この回答にも含まれていますが、要約からは除外されています。パイプライン化されていないCPUでは速度が遅い、または悪質な能力がなくても正当化できないと思われる提案。たとえば、明らかに異なる/悪いasmを生成する多くのgimp-the-compilerアイデア。


マルチスレッドがひどい

たぶん、OpenMPを使用して、非常に少ない反復でマルチスレッドループを作成し、速度の向上よりもはるかに多くのオーバーヘッドをかけます。ただし、モンテカルロコードには、実際に速度を上げるのに十分な並列処理があります。各反復を遅くすることに成功した場合。(各スレッドpayoff_sumは、最後に追加された部分を計算します)。 #omp parallelそのループではおそらく最適化であり、悲観化ではありません。

マルチスレッドですが、両方のスレッドが同じループカウンターを共有するように強制しatomicます(増分の合計数が正しいため)。これは悪魔のように論理的です。つまり、static変数をループカウンターとして使用します。この正当化は、使用のatomicループカウンタのため、そして作成し、実際のキャッシュライン行き来し続け(限りのスレッドがハイパースレッディングと同じ物理コア上で実行されていないとして、ではないかもしれませんように遅いです)。とにかく、これはの競合しない場合よりもはるかに遅くなりますlock inc。また、32ビットシステムでlock cmpxchg8b競合するものをアトミックにインクリメントするuint64_tには、ハードウェアにアトミックなアービトレーションを行わせるのではなく、ループで再試行する必要がありますinc

また、偽の共有を作成します。この場合、複数のスレッドがプライベートデータ(RNG状態など)を同じキャッシュラインの異なるバイトに保持します。 (調べるためのパフォーマンスカウンターを含む、それに関するIntelチュートリアル)これにマイクロアーキテクチャ固有の側面があります:インテルのCPUは、メモリ誤発注が上推測ではない起こって、そしてありますメモリ次機械クリアPERFイベントは、少なくともP4上で、これを検出します。ペナルティはHaswellではそれほど大きくない可能性があります。そのリンクが指摘しているように、locked命令はこれが起こると想定し、誤解を回避します。通常のロードでは、ロードが実行されてからプログラム順にリタイアするまでの間に、他のコアがキャッシュラインを無効にしないことが推測されます(を使用しない限りpause)。lockedの指示なしの真の共有は通常バグです。非アトミック共有ループカウンターをアトミックケースと比較すると興味深いでしょう。実際に悲観的にするには、共有アトミックループカウンターを保持し、他の変数の同じまたは異なるキャッシュラインで誤った共有を引き起こします。


ランダムなuarch固有のアイデア:

予測できないブランチを導入できる場合は、コードが大幅に悲観化されます。最近のx86 CPUは非常に長いパイプラインを持っているので、誤予測のコストは最大15サイクルです(uopキャッシュから実行する場合)。


依存チェーン:

これは割り当ての意図された部分の1つだったと思います。

複数の短い依存関係チェーンではなく、1つの長い依存関係チェーンを持つ操作の順序を選択して、命令レベルの並列処理を利用するCPUの機能を無効にします。-ffast-math(以下で説明するように)結果を変更する可能性があるため、を使用しない限り、コンパイラーはFP計算の演算の順序を変更できません。

これを本当に効果的にするには、ループで運ばれる依存関係チェーンの長さを増やします。ただし、明白なものはありません。記述されているループには、ループを運ぶ依存関係のチェーンが非常に短く、FPの追加だけです。(3サイクル)。複数のイテレーションpayoff_sum +=は、前のイテレーションの終わりのかなり前に開始できるため、一度に計算を実行できます。(log()そしてexp、多くの指示を受け取りますが、並列性を見つけるためのHaswellの順不同ウィンドウよりも多くはありません:ROBサイズ= 192融合ドメインuops、およびスケジューラサイズ= 60融合ドメインuops。現在のイテレーションの実行が次のイテレーションからの命令を発行する余地を作るのに十分なほど進行するとすぐに、古い命令が実行ユニットを離れると、入力の準備ができているその部分(つまり、独立した/分離したdepチェーン)が実行を開始できます。無料(たとえば、スループットではなく待ち時間でボトルネックになっているため)。

RNG状態は、ほぼ確実に、を超えるループキャリー依存チェーンになりますaddps


より遅い/より多くのFP演算を使用します(特に除算を増やします):

0.5を掛けるのではなく、2.0で割ります。FP乗算は、Intelデザインではパイプライン化されており、Haswell以降では0.5cごとに1つのスループットがあります。 FP divsd/ divpdは部分的にパイプライン化されています。(Skylakeのdivpd xmmレイテンシは13〜14cであり、Nehalem(7〜22c)ではまったくパイプライン化されていないため、の4cあたりのスループットは1 です。)

do { ...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0);明確にそうはっきりそれが適切になり、距離のためにテストしているsqrt()こと。:P(sqrtよりもさらに遅いdiv)。

@Paul Claytonが示唆しているように、連想/分散等価物を使用-ffast-mathして式を書き換えると、より多くの作業が発生する可能性があります(コンパイラーを再最適化するために使用しない限り)。 (exp(T*(r-0.5*v*v))になる可能性がありexp(T*r - T*v*v/2.0)ます。実数の計算は連想的ですが、オーバーフロー/ NaNを考慮しなくても、浮動小数点の計算はにはなりません(これが-ffast-mathデフォルトでオンになっていない理由です)。非常に毛深いネストされた提案については、ポールのコメントを参照してくださいpow()

計算を非常に小さな数値にスケールダウンできる場合、FP数学演算は、2つの通常の数値に対する演算が非正規化を生成するときに、マイクロコードにトラップするために約120サイクル余分にかかります。正確な数と詳細については、Agner Fogのmicroarch pdfを参照してください。乗算が多数あるため、これはありそうもないので、スケール係数は2乗され、0.0までアンダーフローします。必要なスケーリングを無能なもの(悪魔的なものも含む)で正当化する方法はありません。意図的な悪意だけです。


組み込み関数を使用できる場合(<immintrin.h>

movntiキャッシュからデータを削除するために使用します。悪魔のような:それは新しく、順序が弱いので、CPUがより速く実行できるようになるはずですよね?または、誰かが正確にこれを行う危険にさらされていた場合のリンクされた質問を参照してください(一部の場所のみがホットだった分散した書き込みの場合)。 clflush悪意がなければおそらく不可能です。

FP数学演算の間に整数シャッフルを使用して、バイパス遅延を引き起こします。

SSEとAVX命令を適切に使用せずに混在さvzeroupperせると、 Skylake以前は大きなストール発生します Skylakeではペナルティ異なります)。それがなくても、ベクトル化はスカラーよりも悪くなる可能性があります(256bベクトルで一度に4つのモンテカルロ反復のadd / sub / mul / div / sqrt操作を実行することによって保存されるよりも、ベクトルへ/からデータをシャッフルするのに多くのサイクルが費やされます) 。add / sub / mul実行ユニットは完全にパイプライン化され、全幅ですが、256bベクトルのdivとsqrtは128bベクトル(またはスカラー)ほど高速ではないため、スピードアップは劇的ではありませんdouble

exp()そしてlog()一部が戻っスカラへのベクトル要素を抽出し、個別にライブラリ関数を呼び出し、その後、戻っベクトルに結果をシャッフル必要になりそうという、ハードウェアをサポートしていません。libmは通常、SSE2のみを使用するようにコンパイルされているため、スカラー数学命令のレガシーSSEエンコーディングを使用します。コードが256bベクトルを使用expし、vzeroupper最初に実行せずに呼び出す場合、停止します。戻った後、vmovsd次のベクトル要素をargとして設定するようなAVX-128命令expもストールします。そしてexp()、それはSSE命令を実行したときに再び失速します。 これはまさにこの質問で起こったことであり、10倍のスローダウンを引き起こしています。 (@ZBosonに感謝します)。

このコードについては、Nathan KurzがIntelの数学ライブラリとglibcを比較した実験も参照してください。今後のglibcには、ベクトル化された実装などが含まれる予定です。exp()


IvB以前、またはespを対象とする場合。Nehalem、gccに16ビットまたは8ビットの操作とそれに続く32ビットまたは64ビットの操作を伴う部分レジスターのストールを引き起こすようにしてください。ほとんどの場合、gccはmovzx8ビットまたは16ビットの操作の後に使用しますが、ここではgccが変更ahしてから読み取る場合を示します。ax


(インライン)asm:

(インライン)asmを使用すると、uopキャッシュを壊す可能性があります。3つの6uopキャッシュラインに収まらない32Bコードのチャンクは、uopキャッシュからデコーダーへの切り替えを強制します。内側のループ内のブランチターゲットで、数個の長いs の代わりにALIGN多くのシングルバイトnops を使用する能力のnopない人がうまくいくかもしれません。または、配置パディングをラベルの前ではなく、ラベルの後に置きます。:Pこれは、フロントエンドがボトルネックである場合にのみ問題となり、コードの残りの部分を悲観化することに成功した場合は問題になりません。

自己変更コードを使用して、パイプラインのクリアをトリガーします(別名、マシンnukes)。

即値が大きすぎて8ビットに収まらない16ビット命令からのLCPストールは、役に立ちそうにありません。SnB以降のuopキャッシュは、デコードのペナルティを1回だけ支払うことを意味します。Nehalem(最初のi7)では、28 uopループバッファーに収まらないループで動作する可能性があります。gccは-mtune=intel、32ビット命令を使用できた場合でも、そのような命令を生成することがあります。


タイミングの一般的なイディオムはCPUID(シリアル化する)thenRDTSCです。CPUID/ RDTSCを使用して各反復の時間を個別にRDTSC計り、前の命令で順序が変更されないようにします。これにより、処理速度が大幅に低下します。(実際には、時間を計るスマートな方法は、すべての反復を個別に計時して合計するのではなく、すべての反復を一緒に計時することです)。


多くのキャッシュミスとその他のメモリのスローダウンを引き起こす

union { double d; char a[8]; }一部の変数にはa を使用します。 バイトの1つだけに狭いストア(または読み取り-変更-書き込み)を実行することにより、ストア転送ストールを引き起こします。(そのwiki記事は、ロード/ストアキューに関する他の多くのマイクロアーキテクチャーに関するものもカバーしています)。たとえば、別のxmmレジスタの定数を使用して上位バイトのみでXOR 0x80を使用することの符号を反転しdoubleます、演算子の代わりにます。悪魔のように能力のない開発者は、FPが整数よりも遅いと聞いたことがあるかもしれません。(SSEレジスタのFP演算を対象とする非常に優れたコンパイラは、これをが、x87にとってこれがひどくない唯一の方法は、コンパイラが値を否定していることを認識し、次の加算を差し引く。)-xorps


を使用しvolatileてコンパイルし-O3、使用しない場合に使用してstd::atomic、コンパイラーに実際にすべての場所にストア/リロードを強制します。(ローカルではなく)グローバル変数も一部のストア/リロードを強制しますが、C ++メモリモデルの弱い順序付けでは、コンパイラーが常にメモリにスピル/リロードする必要はありません。

ローカル変数を大きな構造体のメンバーに置き換えて、メモリレイアウトを制御できるようにします。

構造体で配列を使用して、パディング(および乱数を格納して、その存在を正当化します)。

すべてがL1キャッシュの同じ「セット」内の別の行に入るように、メモリレイアウトを選択します。これは、8ウェイの連想のみです。つまり、各セットには8つの「ウェイ」があります。キャッシュラインは64Bです。

さらに良いのは、4096Bを正確に離して配置することです。これは、ロードが異なるページへのストアに誤った依存関係を持っているため、ページ内のオフセットが同じだからです。アグレッシブアウトオブオーダーCPUは、メモリの曖昧性解消を使用して、結果を変更せずにロードとストアを並べ替えることができる時期を特定します。Intelの実装には、ロードの早期開始を防ぐ誤検知があります。おそらく、それらはページオフセットより下のビットのみをチェックするため、TLBが仮想ページから物理ページに上位ビットを変換する前にチェックを開始できます。Agnerのガイドと同様に、Stephen Canonからの回答と、同じ質問に対する@Krazy Glewの回答の終わり近くのセクションも参照してください。(Andy Glewは、インテルのオリジナルのP6マイクロアーキテクチャーのアーキテクトの1人でした。)

__attribute__((packed))変数を誤って整列させて、キャッシュラインまたはページ境界にまたがるようにするために使用します。(したがって、1つのロードにはdouble2つのキャッシュラインからのデータが必要です)。キャッシュラインとページラインを交差する場合を除いて、インテルi7のuarchではロードのミスアライメントによるペナルティはありません。 キャッシュラインの分割には、まだ余分なサイクルが必要です。Skylakeは、ページ分割ロードのペナルティを100から5サイクルに劇的に減らします(セクション2.1.3)。おそらく、2つのページウォークを並行して実行できることに関連しています。

のページ分割はatomic<uint64_t>、最悪の場合、特にです。1ページで5バイト、他のページで3バイト、または4:4以外の場合。途中で分割することも、IIRCの一部の地域では16Bベクトルを使用したキャッシュライン分割の場合により効率的です。alignas(4096) struct __attribute((packed))もちろん、スペースを節約するために、RNGの結果を格納するための配列を含め、すべてをに配置します。カウンターの前で、uint8_tまたはuint16_t何かを使用して、ミスアライメントを達成します。

コンパイラーにインデックス付きアドレッシングモードを使用させることができれば、uop micro-fusion無効になります。たぶん、#definesを使用して単純なスカラー変数をに置き換えますmy_data[constant]

追加レベルの間接参照を導入できるため、ロード/ストアアドレスが早期に認識されない場合、さらに悲観的になる可能性があります。


不連続な順序で配列をトラバースする

そもそも配列を導入するための無能な正当化を考え出すことができると思います。これにより、乱数の生成と乱数の使用を分離できます。各反復の結果を配列に格納して、後で合計することもできます(より悪魔的な能力がない場合)。

「最大のランダム性」の場合、ランダム配列をループして新しい乱数を書き込むスレッドを作成できます。乱数を消費するスレッドは、乱数をロードするためのランダムなインデックスを生成する可能性があります。(ここにはいくつかの作業がありますが、マイクロアーキテクチャー的には、ロードアドレスが早期にわかるため、ロードされたデータが必要になる前に、起こりうるロードレイテンシを解決できます。)異なるコアにリーダーとライターがあると、メモリの順序付けにミスが発生します。 -スペキュレーションパイプラインがクリアされます(偽共有の場合について前述したとおり)。

最大の悲観化のために、4096バイトのストライド(つまり、512ダブル)で配列をループします。例えば

for (int i=0 ; i<512; i++)
    for (int j=i ; j<UPPER_BOUND ; j+=512)
        monte_carlo_step(rng_array[j]);

アクセスパターンがあるので、0、4096、8192、...、
8、4104、8200、...
16、4112、8208、...

これはdouble rng_array[MAX_ROWS][512]、間違った順序(@JesperJuhlが示唆するように、内部ループの行内の列ではなく、行をループする)で2D配列にアクセスするために得られるものです。悪魔のような無能がそのような次元の2D配列を正当化できる場合、庭のさまざまな実世界の無能は、間違ったアクセスパターンでのループを簡単に正当化します。これは実際のコードで発生します。

配列がそれほど大きくない場合は、必要に応じてループ境界を調整して、同じ数ページを再利用するのではなく、多くの異なるページを使用します。ハードウェアのプリフェッチは、ページ間で(またはまったく)機能しません。プリフェッチャーは、各ページ内で1つのフォワードストリームと1つのバックワードストリームを追跡できます(ここで行われることです)が、プリフェッチャーは、メモリ帯域幅が非プリフェッチでまだ飽和していない場合にのみ動作します。

これにより、ページがhugepageにマージされない限り、多くのTLBミスが発生します(Linuxはmalloc/のような匿名の(ファイルに依存しない)割り当てに対してこれを日和見的に行いnewますmmap(MAP_ANONYMOUS))。

結果のリストを格納する配列の代わりに、リンクリストを使用できます。次に、すべての反復でポインター追跡ロードが必要になります(次のロードのロードアドレスに対するRAWの真の依存性ハザード)。アロケータが悪いと、リストノードをメモリ内に分散させ、キャッシュを無効にすることができます。悪質な能力のないアロケータを使用すると、すべてのノードを独自のページの先頭に置くことができます。(たとえばmmap(MAP_ANONYMOUS)、適切にサポートするためにページを分割したりオブジェクトサイズを追跡したりせずに、直接割り当てますfree)。


これらは実際にはマイクロアーキテクチャ固有ではなく、パイプラインとはほとんど関係がありません(これらのほとんどは、パイプライン化されていないCPUの速度低下にもなります)。

やや話題外:コンパイラに悪いコードを生成させる/より多くの作業を行わせる:

C ++ 11 std::atomic<int>およびstd::atomic<double>最も悲観的なコードを使用します。MFENCEとlocked命令は、別のスレッドからの競合がない場合でも、かなり低速です。

-m32x87コードはSSE2コードよりも悪いため、コードは遅くなります。スタックベースの32ビットの呼び出し規約は、より多くの命令を取り、スタック上のFP引数ものような関数に渡しますexp()atomic<uint64_t>::operator++onに-m32lock cmpxchg8Bループが必要です(i586)。(それをループカウンターに使用してください![悪笑い])。

-march=i386悲観的になります(@Jesperに感謝します)。FPとの比較fcomは686より遅いですfcomi。586より前のatomicバージョンでは、アトミック64ビットストア(cmpxchg はもちろんのこと)が提供されていないため、すべての64ビットopはlibgcc関数呼び出しにコンパイルされます(実際にロックを使用するのではなく、おそらくi686用にコンパイルされます)。最後の段落のGodbolt Compiler Explorerリンクで試してください。

sizeof()が10または16のABI(位置合わせ用のパディング付き)で精度と速度をさらに上げるには、long double/ sqrtl/ explを使用long doubleします。(IIRC、64ビットWindowsでは、8バイトにlong double相当しdoubleます。(とにかく、10バイト(80ビット)FPオペランドのロード/ストアは4/7 uopsであるfloatか、または/ doubleに対してそれぞれ1 uop しかかかりません。)x87を強制すると、 gcc 。fld m64/m32fstlong double-m64 -march=haswell -O3

atomic<uint64_t>ループカウンターを使用long doubleしない場合は、ループカウンターを含むすべてに使用します。

atomic<double>コンパイルしますが、+=(64ビットでも)などの読み取り-変更-書き込み操作はサポートされていません。 atomic<long double>アトミックなロード/ストアのためだけにライブラリ関数を呼び出す必要があります。x86 ISAはアトミックな10バイトのロード/ストアを自然にサポートしていないため、おそらく非効率的であり、ロック(cmpxchg16b)なしで考えることができる唯一の方法は64ビットモードを必要とします。


では-O0、一時的な変数にパーツを割り当てることによって大きな式を分割すると、より多くのストア/リロードが発生します。volatile実際のコードの実際のビルドが使用する最適化設定では、これが何であれ、これは問題になりません。

Cのエイリアシングルールでは、a charがあらゆるものをエイリアスできるため、a を介して格納するとchar*、コンパイラーはバイトストアの前後にすべてを格納/再ロードします-O3。(これは、たとえばの配列を操作するuint8_t自動ベクトル化コードの問題です。)

uint16_tおそらく16ビットのオペランドサイズ(潜在的なストール)や追加のmovzx命令(安全)を使用して、ループカウンターを試して16ビットに切り捨てることを強制します。 符号付きオーバーフローは未定義の動作なので、64ビットポインターへのオフセットとして使用されている場合でも-fwrapv、少なくともを使用しない限り-fno-strict-overflow符号付きループカウンターを反復ごとに再符号拡張する必要はありません


整数との間の変換を強制floatします。および/またはdouble<=> float変換。命令には1つ以上のレイテンシがあり、スカラーint-> float(cvtsi2ss)は、xmmレジスタの残りをゼロにしないように設計されています。(pxorこのため、gccは依存関係を壊すために追加を挿入します。)


CPUアフィニティを別のCPUに頻繁に設定します(@Egworが推奨)。悪魔のような推論:1つのコアが長時間スレッドを実行することによって過熱されないようにしたいですか?多分別のコアに交換すると、そのコアがより高いクロック速度にターボできるようになります。(実際には、これらは熱的に非常に接近しているため、マルチソケットシステムを除いて、これはほとんどあり得ません)。ここで、調整を間違えて、頻繁に実行します。OSの保存/スレッド状態の復元に費やされた時間に加えて、新しいコアにはコールドL2 / L1キャッシュ、uopキャッシュ、および分岐予測子があります。

不必要なシステムコールを頻繁に導入すると、それらが何であっても速度が低下する可能性があります。のようないくつかの重要ですが単純なものgettimeofdayは、カーネルモードに移行せずに、ユーザー空間で実装できます。(カーネルはのコードをエクスポートするため、Linux上のglibcはカーネルの助けを借りてこれを行いますvdso)。

システムコールのオーバーヘッド(コンテキストスイッチ自体だけでなく、ユーザースペースに戻った後のキャッシュ/ TLBミスを含む)の詳細については、FlexSCペーパーに、現在の状況の優れたパフォーマンスカウンター分析とバッチシステムの提案があります。大量のマルチスレッドサーバープロセスからの呼び出し。


10
@JesperJuhl:ええ、私はその正当性を買います。「悪魔のように無能」はとても素晴らしいフレーズです:)
Peter Cordes

2
定数による乗算を定数の逆数による除算に変更すると、パフォーマンスが少し低下する可能性があります(少なくとも-O3 -fastmathを凌駕しようとしない場合)。同様に、連想性を使用して仕事を増やす(にexp(T*(r-0.5*v*v))なるexp(T*r - T*v*v/2.0);にexp(sqrt(v*v*T)*gauss_bm)なるexp(sqrt(v)*sqrt(v)*sqrt(T)*gauss_bm))。連想性(および一般化)はexp(T*r - T*v*v/2.0)、 `pow((pow(e_value、T)、r)/ pow(pow(pow((pow(e_value、T)、v)、v))、-2.0)[または何かに変換することもできます。その]のような数学のトリックは本当にマイクロアーキテクチャdeoptimizationsとしてカウントされません。
ポール・A.クレイトン

2
この反応に本当に感謝しています。Agner's Fogは大きな助けになりました。このダイジェストを使用して、今日の午後に作業を開始します。これは、何が起こっているのかを実際に学習するという点で、おそらく最も有用な割り当てでした。
Cowmoogun、2016年

19
これらの提案の一部は非常に悪質な能力を備えていないため、出力を確認するために彼がじっと座っておくには、今の7分の実行時間が長すぎるかどうかを確認するために教授に相談する必要があります。まだこれで作業していますが、これはおそらく私がプロジェクトで持っていた中で最も楽しいものでした。
Cowmoogun、2016年

4
何?ミューテックスはありませんか?200万のスレッドがミューテックスで同時に実行され、すべての個々の計算を保護します(念のために!)、地球上で最速のスーパーコンピューターをひっくり返します。とはいえ、私はこの悪質な能力のない答えが大好きです。
David Hammen、2016年

35

可能な限りパフォーマンスを低下させるためにできるいくつかのこと:

  • i386アーキテクチャ用のコードをコンパイルします。これにより、SSEおよび新しい命令の使用が防止され、x87 FPUの使用が強制されます。

  • std::atomicどこでも変数を使用します。コンパイラがメモリバリアをあちこちに挿入しなければならないため、これは非常に高価になります。そして、これは無能な人が「スレッドの安全性を保証する」ためにもっともらしいことかもしれないものです。

  • プリフェッチャーが予測できる最悪の方法でメモリにアクセスするようにしてください(列メジャーvs行メジャー)。

  • 変数をさらに高価にするには、変数にnew「自動ストレージ期間」(スタックが割り当てられる)を割り当てるのではなく、変数を割り当てることにより、すべて「ダイナミックストレージ期間」(ヒープが割り当てられる)を確保できます。

  • 割り当てるすべてのメモリが非常に奇妙に配置されていることを確認してください。そうすると、TLBの効率が高すぎるため、巨大なページを割り当てることは避けてください。

  • 何をするにせよ、コンパイラオプティマイザを有効にしてコードをビルドしないでください。そして、可能な限り最も表現力豊かなデバッグシンボルを有効にしてください(コードの実行を遅くすることはありませんが、余分なディスク領域を浪費します)。

注:この回答は基本的に、@ Peter Cordesがすでに非常に良い回答に組み込んでいる私のコメントを要約したものです。1つだけ余裕がある場合は、彼があなたの賛成票を獲得することをお勧めします。


9
これらのいくつかに対する私の主な反対点は、質問の言い回しです。 プログラムを最適化解除するには、Intel i7パイプラインの動作方法に関する知識を使用します x87やstd::atomic、あるいはダイナミックアロケーションからの間接的な追加レベルについてuarch固有のものはないと思います。AtomやK8でも同様に遅くなります。まだ賛成票を投じていますが、それが私があなたの提案のいくつかに抵抗していた理由です。
Peter Cordes

それらは公正なポイントです。とにかく、それらのことはまだいくらか要求者の目標に向かって働いています。賛成投票に感謝します:)
Jesper Juhl、

SSEユニットはポート0、1、および5を使用します。x87ユニットはポート0および1のみを使用します
Michas

@Michas:あなたはそれについて間違っています。Haswellは、ポート5でSSE FP数学命令を実行しません。ほとんどのSSE FPは、シャッフルとブール(xorps / andps / orps)をシャッフルします。x87は遅いですが、理由の説明が少し間違っています。(そして、この点は完全に間違っています。)
Peter Cordes

1
@Michas:movapd xmm, xmm通常、実行ポートは必要ありません(IVB以降のレジスタ名変更ステージで処理されます)。FMA以外はすべて非破壊であるため、AVXコードではほとんど必要ありません。しかし、十分に公平で、Haswellはそれが排除されない場合、それをport5で実行します。私はx87 register-copy(fld st(i))を見ていませんでしたが、Haswell / Broadwellには適切です。p01で実行されます。Skylakeはp05で実行し、SnBはp0で実行し、IvBはp5で実行します。したがって、IVB / SKLはp5でいくつかのx87(比較を含む)を行いますが、SNB / HSW / BDWはx87でp5をまったく使用しません。
Peter Cordes、2016年

11

long double計算に使用できます。x86では、80ビット形式である必要があります。レガシーのx87 FPUのみがこれをサポートしています。

x87 FPUのいくつかの欠点:

  1. SIMDがないため、さらに指示が必要な場合があります。
  2. スタックベース、スーパースカラーおよびパイプラインアーキテクチャでは問題が多い。
  3. 個別の非常に小さなレジスタのセットは、他のレジスタからの変換やメモリ操作を増やす必要がある場合があります。
  4. Core i7には、SSE用に3つのポートがあり、x87用に2つしかありません。プロセッサーは、より少ない並列命令を実行できます。

3
スカラー数学の場合、x87数学命令自体はわずかに遅くなります。ただし、10バイトのオペランドの格納/ロードは大幅に遅くなり、x87のスタックベースの設計では追加の命令(などfxch)が必要になる傾向があります。と-ffast-mathと、優れたコンパイラはモンテカルロループをベクトル化でき、x87はそれを防止できます。
Peter Cordes

答えを少し広げました。
Michas

1
re:4:話しているi7 uarchとその指示は?Haswellはmulssp01で実行できますが、でfmulのみ実行できますp0。 と同じようにaddssのみ実行さp1faddます。FP数学演算を処理する実行ポートは2つだけです。(これの唯一の例外は、Skylakeが専用の追加ユニットをaddss削除し、p01のFMAユニットで実行faddされることですが、p5で実行されます。fadd命令をとfma...psすることにより、理論上、わずかにより多くの合計FLOPを実行できます。)
ピーターコルド

2
また、Windows x86-64 ABIには64ビットがありますlong double。つまり、double。。long doubleただし、SysV ABIは80ビットを使用します。また、re:2:レジスタの名前変更は、スタックレジスタの並列処理を公開します。スタックベースのアーキテクチャではfxchg、esp などの追加の指示が必要です。並列計算をインターリーブするとき。したがって、uarchがそこにあるものを利用するのは難しいというよりは、メモリの往復なしに並列処理を表現するのは難しいようなものです。ただし、他の正規表現からさらに変換する必要はありません。その意味がわからない。
Peter Cordes

6

遅い答えですが、リンクされたリストとTLBを乱用したとは思いません。

mmapを使用してノードを割り当て、ほとんどの場合アドレスのMSBを使用します。これにより、TLBルックアップチェーンが長くなり、ページは12ビットで、変換用に52ビット、または毎回走査する必要がある約5レベルになります。少し運が良ければ、ノードに到達するために5レベルのルックアップと1つのメモリアクセスのために毎回メモリにアクセスする必要があります。トップレベルはおそらくどこかのキャッシュにあるため、5 *メモリアクセスを期待できます。が最悪の境界線をまたぐようにノードを配置して、次のポインターを読み取ると、3〜4の変換ルックアップがさらに発生するようにします。また、大量の翻訳ルックアップが原因で、これは完全にキャッシュを破壊する可能性があります。また、仮想テーブルのサイズにより、ほとんどのユーザーデータが余分な時間ディスクにページングされる可能性があります。

単一のリンクされたリストから読み取るときは、必ずリストの先頭から読み取るようにして、単一の数値の読み取りで最大の遅延が発生するようにしてください。


x86-64ページテーブルは、48ビットの仮想アドレスに対して4レベルの深さです。(PTEには52ビットの物理アドレスがあります)。将来のCPUは、さらに9ビットの仮想アドレス空間(57)のために、5レベルのページテーブル機能をサポートする予定です。 64ビットで、仮想アドレスが物理アドレス(52ビット長)と比較して4ビット短い(48ビット長)のはなぜですか?。OSはデフォルトでは有効になりません。これは、仮想アドレス空間がそれほど必要でない限り、速度が遅くなり、メリットがないためです。
Peter Cordes

しかし、はい、楽しいアイデアです。mmapファイルまたは共有メモリ領域で使用して、同じ物理ページ(同じ内容)の複数の仮想アドレスを取得し、同じ量の物理RAMでより多くのTLBミスを許可できます。リンクされたリストnextが単に相対オフセットであった場合、+4096 * 1024最終的に別の物理ページに到達するまで、aを使用して同じページの一連のマッピングを持つことができます。またはもちろん、L1dキャッシュヒットを回避するために複数のページにまたがっています。ページウォークハードウェア内には、より高レベルのPDEのキャッシュがあるため、virt addrスペースに分散します。
Peter Cordes

古いアドレスにオフセットを追加すると、[ [reg+small_offset]アドレッシングモードの特殊なケース]が無効になるため、ロード使用のレイテンシが悪化します(base + offsetがベースとは異なるページにある場合、ペナルティはありますか?)。add64ビットオフセットのメモリソースを取得するか、のような負荷とインデックス付きアドレッシングモードを取得します[reg+reg]。また、L2 TLBミスの後に何が起こるかを参照してください-SnBファミリのL1dキャッシュを介したページウォークフェッチ。
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.