web-dev-qa-db-ja.com

CPUキャッシュを最大限に活用してパフォーマンスを向上させるコードをどのように作成しますか?

これは主観的な質問のように聞こえるかもしれませんが、私が探しているのは、これに関連して遭遇する可能性のある特定のインスタンスです。

  1. コードを作成し、キャッシュを有効/キャッシュフレンドリーにする方法(キャッシュヒットを増やし、キャッシュミスをできるだけ少なくする)両方の観点から、データキャッシュとプログラムキャッシュ(命令キャッシュ)、つまり、データ構造とコード構成に関連するコードの内容は、キャッシュを有効にするために注意する必要があります。

  2. 使用/回避しなければならない特定のデータ構造がありますか、それともコードキャッシュを効果的にするためにその構造のメンバーにアクセスする特定の方法などがありますか?.

  3. プログラムの構成要素(if、for、switch、break、gotoなど)、コードフロー(if内、for内など)は、この問題でフォロー/回避する必要がありますか?

一般的にキャッシュを効率的なコードにすることに関連する個々の経験を聞くことを楽しみにしています。任意のプログラミング言語(C、C++、アセンブリなど)、任意のハードウェアターゲット(ARM、Intel、PowerPCなど)、任意のOS(Windows、Linux、S ymbianなど)などを使用できます。 。

多様性は、それを深く理解するのに役立ちます。

152
goldenmean

キャッシュは、CPUがメモリ要求の実行を待機する回数を減らすためにあります(メモリを回避するlatency)。おそらく、転送する必要があるデータの全体量を減らすための2番目の効果(メモリbandwidthを保持)。

通常、メモリフェッチの遅延に悩まされることを回避するための手法は、最初に考慮すべき事項であり、時には大いに役立ちます。特に、多くのスレッドがメモリバスを使用するマルチコアおよびマルチスレッドアプリケーションの場合、メモリ帯域幅の制限も制限要因になります。後者の問題への対処には、さまざまな手法が役立ちます。

spatial localityを改善すると、各キャッシュラインがキャッシュにマッピングされた後に完全に使用されるようになります。さまざまな標準ベンチマークを調べたところ、キャッシュラインが削除される前に、フェッチされたキャッシュラインの100%を使用できないのは驚くほどの割合であることがわかりました。

キャッシュラインの使用率を改善すると、次の3つの点で役立ちます。

  • キャッシュにより有用なデータが収まる傾向があり、基本的に有効なキャッシュサイズが増加します。
  • 同じキャッシュラインに、より有用なデータを収める傾向があり、要求されたデータがキャッシュで見つかる可能性が高くなります。
  • フェッチが少なくなるため、メモリ帯域幅の要件が軽減されます。

一般的な手法は次のとおりです。

  • より小さなデータ型を使用する
  • データを整理して位置合わせの穴を避けます(サイズを小さくして構造体メンバーを並べ替えることは1つの方法です)
  • 標準の動的メモリアロケータに注意してください。このアロケータは、ウォームアップ時にデータをメモリに拡散し、データを拡散する可能性があります。
  • ホットループですべての隣接データが実際に使用されていることを確認してください。それ以外の場合は、ホットループがホットデータを使用するように、データ構造をホットコンポーネントとコールドコンポーネントに分割することを検討してください。
  • 不規則なアクセスパターンを示すアルゴリズムとデータ構造を避け、線形データ構造を優先します。

また、キャッシュを使用する以外に、メモリレイテンシを隠す方法が他にもあることに注意してください。

最近のCPU:には、1つ以上のハードウェアプリフェッチャーがよくあります。彼らはキャッシュ内のミスを訓練し、規則性を見つけようとします。たとえば、後続のキャッシュラインを数回ミスすると、hwプリフェッチャーはキャッシュラインのキャッシュへのフェッチを開始し、アプリケーションのニーズを予測します。通常のアクセスパターンがある場合、ハードウェアプリフェッチャーは通常非常に良い仕事をしています。プログラムに通常のアクセスパターンが表示されない場合は、プリフェッチ命令を自分で追加することで改善できます。

キャッシュで常にミスする命令が互いに近く発生するように命令を再グループ化すると、CPUがこれらのフェッチをオーバーラップさせて、アプリケーションが1つのレイテンシヒットのみを維持できる場合があります(メモリレベルの並列性)。

全体的なメモリバスのプレッシャーを軽減するには、temporal localityと呼ばれるものに対処する必要があります。これは、キャッシュから削除されていないデータを再利用する必要があることを意味します。

同じデータに触れるループをマージし(loop fusion)、tilingまたはblockingはすべて、これらの余分なメモリフェッチを回避しようとします。

この書き換えの練習にはいくつかの経験則がありますが、通常は、プログラムのセマンティクスに影響を与えないように、ループで運ばれるデータの依存関係を慎重に考慮する必要があります。

これらのことは、通常、2番目のスレッドを追加した後のスループットの改善があまり見られないマルチコアの世界で実際に成果を上げるものです。

116
Mats N

これに対する答えがこれ以上ないことは信じられません。とにかく、古典的な例の1つは、多次元配列を「裏返し」に繰り返すことです。

pseudocode
for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[j][i]

これがキャッシュ非効率である理由は、単一のメモリアドレスにアクセスすると、最新のCPUがメインメモリから「近い」メモリアドレスをキャッシュラインにロードするためです。内部ループ内の配列の「j」(外部)行を反復処理しているため、内部ループを通過するたびに、キャッシュラインがフラッシュされ、[に近いアドレスの行がロードされますj] [i]エントリ。これが同等のものに変更された場合:

for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[i][j]

はるかに高速に実行されます。

52

メモリとソフトウェアの相互作用に興味がある場合は、9部構成の記事 すべてのプログラマがメモリについて知っておくべきこと Ulrich Drepperを読むことをお勧めします。 104ページのPDF としても利用できます。

この質問に特に関連するセクションは、 パート2 (CPUキャッシュ)および パート5 (プログラマーができること-キャッシュの最適化)です。

43
Tomi Kyöstilä

基本的なルールは実際にはかなり単純です。トリッキーになるのは、それらがコードにどのように適用されるかです。

キャッシュは、時間的局所性と空間的局所性という2つの原則で機能します。前者は、最近特定のデータチャンクを使用した場合、おそらくすぐに再び必要になるという考えです。後者は、最近アドレスXのデータを使用した場合、おそらくすぐにアドレスX + 1が必要になることを意味します。

キャッシュは、最近使用したデータのチャンクを記憶することにより、これに対応しようとします。通常は128バイト程度のサイズのキャッシュラインで動作するため、1バイトのみが必要な場合でも、それを含むキャッシュライン全体がキャッシュにプルされます。したがって、その後に次のバイトが必要になった場合、それはすでにキャッシュにあります。

そしてこれは、これらの2つの形式のローカリティを可能な限り活用するために、常に独自のコードが必要であることを意味します。メモリを飛び越えないでください。 1つの小さな領域でできる限り多くの作業を行い、次に次の領域に移動して、できる限り多くの作業を行います。

簡単な例は、1800の答えが示した2D配列トラバースです。一度に1行ずつトラバースすると、メモリが順番に読み取られます。列ごとに行う場合は、1つのエントリを読んでから、まったく別の場所(次の行の先頭)にジャンプし、1つのエントリを読んで、もう一度ジャンプします。そして、最終的に最初の行に戻ると、その行はキャッシュに含まれなくなります。

同じことがコードにも当てはまります。ジャンプまたは分岐は、キャッシュの使用効率が低下することを意味します(命令を順番に読み取らず、別のアドレスにジャンプするため)。もちろん、小さなif文はおそらく何も変更しません(数バイトだけスキップするので、キャッシュされた領域内に残ります)が、関数呼び出しは通常、まったく別の場所にジャンプしていることを意味しますキャッシュされない可能性のあるアドレス。最近呼ばれていない限り。

ただし、通常、命令キャッシュの使用はそれほど問題ではありません。通常心配する必要があるのは、データキャッシュです。

構造体またはクラスでは、すべてのメンバーが連続して配置されます。これは良いことです。配列では、すべてのエントリも連続してレイアウトされます。リンクリストでは、各ノードは完全に異なる場所に割り当てられますが、これは悪いことです。一般に、ポインターは無関係なアドレスを指す傾向があるため、逆参照するとキャッシュミスが発生する可能性があります。

また、複数のコアを活用したい場合は、通常は一度に1つのCPUのみがL1キャッシュ内の特定のアドレスを持つことができるため、非常に興味深いものになります。そのため、両方のコアが常に同じアドレスにアクセスすると、アドレスをめぐって競合しているため、一定のキャッシュミスが発生します。

43
jalf

データアクセスパターンとは別に、キャッシュフレンドリーなコードの主な要因は、データサイズです。データが少ないということは、より多くのデータがキャッシュに収まることを意味します。

これは主に、メモリに整合したデータ構造の要因です。 「従来の」知恵では、CPUは単語全体にしかアクセスできないため、データ構造はWordの境界に揃える必要があり、Wordに複数の値が含まれる場合は追加の作業が必要になる(単純な書き込みではなく、読み取り-修正-書き込み) 。ただし、キャッシュはこの引数を完全に無効にすることができます。

同様に、Javaブール配列は、個々の値を直接操作できるように、各値にバイト全体を使用します。実際のビットを使用すると、データサイズを8分の1に削減できますが、個々の値へのアクセスはより複雑になり、ビットシフトとマスク操作が必要になります(BitSetクラスがこれを行います)ただし、キャッシュ効果のため、これはboolean []を使用するよりもかなり高速です。アレイが大きい場合IIRC私はかつてこの方法で2倍または3倍の高速化を達成しました。

14

キャッシュの最も効果的なデータ構造は配列です。 CPUがメインメモリからキャッシュライン全体(通常は32バイト以上)を一度に読み取るため、データ構造が連続してレイアウトされている場合、キャッシュが最適に機能します。

ランダムな順序でメモリにアクセスするアルゴリズムは、ランダムにアクセスされたメモリに対応するために常に新しいキャッシュラインを必要とするため、キャッシュを破壊します。一方、配列を順番に実行するアルゴリズムが最適です:

  1. CPUに先読みの機会を与えます。投機的にキャッシュにメモリを追加し、後でアクセスします。この先読みにより、パフォーマンスが大幅に向上します。

  2. また、大きな配列でタイトループを実行すると、CPUはループで実行されるコードをキャッシュでき、ほとんどの場合、外部メモリアクセスをブロックすることなく、キャッシュメモリからアルゴリズムを完全に実行できます。

9
grover

ゲームエンジンで使用した例の1つは、データをオブジェクトから独自の配列に移動することでした。物理の影響を受けたゲームオブジェクトには、他にも多くのデータが添付されている場合があります。しかし、物理更新ループでは、位置、速度、質量、バウンディングボックスなどに関するデータがすべてのエンジンに関係していました。したがって、それらはすべて独自の配列に配置され、SSEに対して可能な限り最適化されました。

そのため、物理ループでは、ベクトル演算を使用して物理データが配列順に処理されました。ゲームオブジェクトは、オブジェクトIDをさまざまな配列へのインデックスとして使用しました。配列を再配置する必要がある場合、ポインターが無効になる可能性があるため、ポインターではありませんでした。

これは多くの点でオブジェクト指向の設計パターンに違反していましたが、同じループで操作する必要のあるデータを近づけて配置することで、コードを大幅に高速化しました。

ほとんどの現代のゲームはHavokのような事前に構築された物理エンジンを使用すると予想されるため、この例はおそらく時代遅れです。

8
Zan Lynx

ユーザーによる「古典的な例」へのコメント1800 INFORMATION(コメントが長すぎます)

2つの反復順序(「外側」と「内側」)の時間差を確認したかったので、大きな2D配列で簡単な実験を行いました。

measure::start();
for ( int y = 0; y < N; ++y )
for ( int x = 0; x < N; ++x )
    sum += A[ x + y*N ];
measure::stop();

forループが交換された2番目のケース。

遅いバージョン( "x first")は0.88秒で、速いバージョンは0.06秒でした。それがキャッシングの力です:)

gcc -O2を使用しましたが、ループはnotで最適化されていました。 Ricardoによるコメントは、「現代のコンパイラのほとんどはこれを自分自身で理解できる」とは言えません。

7
Jakub M.

触れた投稿は1つだけでしたが、プロセス間でデータを共有するときに大きな問題が発生します。複数のプロセスが同じキャッシュラインを同時に変更しようとするのを避けたい。ここで注意すべき点は、「偽」共有です。この場合、2つの隣接するデータ構造がキャッシュラインを共有し、一方を変更すると他方のキャッシュラインが無効になります。これにより、マルチプロセッサシステムでデータを共有するプロセッサキャッシュ間でキャッシュラインが不必要に前後に移動する可能性があります。これを回避する方法は、データ構造を位置合わせしてパディングし、それらを異なる行に配置することです。

7
RussellH

(2)と答えることができます。C++の世界では、リンクリストはCPUキャッシュを簡単に殺すことができます。可能であれば、配列はより優れたソリューションです。同じことが他の言語にも当てはまるかどうかについての経験はありませんが、同じ問題が発生することは容易に想像できます。

4
Andrew

キャッシュは「キャッシュライン」に配置され、(実)メモリはこのサイズのチャンクで読み書きされます。

したがって、単一のキャッシュラインに含まれるデータ構造はより効率的です。

同様に、連続したメモリブロックにアクセスするアルゴリズムは、ランダムな順序でメモリをジャンプするアルゴリズムよりも効率的です。

残念ながら、キャッシュラインのサイズはプロセッサによって大きく異なるため、あるプロセッサで最適なデータ構造が他のプロセッサで効率的であることを保証する方法はありません。

4
Alnitak

キャッシュ、キャッシュの有効性、および他のほとんどの質問のコードの作成方法を尋ねるのは、通常、プログラムを最適化する方法を尋ねることです。これは、キャッシュがパフォーマンスに大きな影響を与えるため、最適化されたプログラムはキャッシュであるためです効果的なキャッシュフレンドリー。

最適化について読むことをお勧めします。このサイトにはいくつかの良い答えがあります。書籍に関しては、キャッシュの適切な使用法に関する細かいテキストが記載されている コンピューターシステム:プログラマーの視点 をお勧めします。

(b.t.w-キャッシュミスと同じくらい悪いが、悪いことがあります-プログラムがハードドライブから ページング である場合...)

4
Liran Orevi

データ構造の選択、アクセスパターンなどの一般的なアドバイスに関する多くの回答がありました。ここでは、アクティブを利用するsoftware pipelineと呼ばれる別のコード設計パターンを追加します。キャッシュ管理。

このアイデアは、他のパイプライン技術から借りたものです。 CPU命令のパイプライン化。

このタイプのパターンは、次の手順に最適です。

  1. 合理的な複数のサブステップ、S [1]、S [2]、S [3]、...に分解できます。その実行時間はおおよそRAMアクセス時間(〜 60-70ns)。
  2. 入力のバッチを取り、結果を得るために前述の複数のステップを実行します。

サブプロシージャが1つしかない単純なケースを考えてみましょう。通常、コードは次のようになります。

def proc(input):
    return sub-step(input))

パフォーマンスを向上させるには、関数呼び出しのオーバーヘッドを償却し、コードキャッシュの局所性を高めるために、複数の入力をバッチで関数に渡すことができます。

def batch_proc(inputs):
    results = []
    for i in inputs:
        // avoids code cache miss, but still suffer data(inputs) miss
        results.append(sub-step(i))
    return res

ただし、前述のように、ステップの実行がRAMアクセス時間とほぼ同じ場合、コードを次のようにさらに改善できます。

def batch_pipelined_proc(inputs):
    for i in range(0, len(inputs)-1):
        prefetch(inputs[i+1])
        # work on current item while [i+1] is flying back from RAM
        results.append(sub-step(inputs[i-1]))

    results.append(sub-step(inputs[-1]))

実行フローは次のようになります。

  1. prefetch(1) input [1]をキャッシュにプリフェッチするようCPUに要求します。ここで、プリフェッチ命令はPサイクル自体を取り戻し、バックグラウンドでinput [1]はRサイクル後にキャッシュに到着します。
  2. works_on(0) 0のコールドミスで動作し、M
  3. prefetch(2)別のフェッチを発行する
  4. works_on(1) P + R <= Mの場合、inputs [1]はこのステップの前にすでにキャッシュにある必要があり、データキャッシュミスを回避します。
  5. works_on(2) ...

より多くのステップが含まれる場合がありますが、ステップのタイミングとメモリアクセス遅延が一致する限り、マルチステージパイプラインを設計できます。コード/データキャッシュミスがほとんどありません。ただし、ステップの正しいグループ化とプリフェッチ時間を見つけるには、このプロセスを多くの実験で調整する必要があります。必要な労力により、高性能データ/パケットストリーム処理での採用が増えています。適切な製品コードの例は、DPDK QoSエンキューパイプラインデザインにあります。 http://dpdk.org/doc/guides/prog_guide/qos_framework.html 第21.2.4.3章エンキューパイプライン。

詳細情報が見つかりました:

https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf

4
Wei Shen

構造とフィールドの整列に加えて、ヒープが割り当てられている場合は、整列された割り当てをサポートするアロケーターを使用できます。 _aligned_malloc(sizeof(DATA)、SYSTEM_CACHE_LINE_SIZE);そうしないと、ランダムな偽共有が発生する可能性があります。 Windowsでは、デフォルトヒープのアラインメントは16バイトです。

1
aracntido

最小限のサイズを取るようにプログラムを作成してください。そのため、GCCで-O3最適化を使用することは常に良い考えではありません。サイズが大きくなります。多くの場合、-Osは-O2と同じくらい良いです。ただし、使用するプロセッサによって異なります。 YMMV。

一度に小さなデータの塊を処理します。そのため、データセットが大きい場合、非効率的なソートアルゴリズムはクイックソートよりも速く実行できます。大きなデータセットを小さなデータセットに分割する方法を見つけます。他の人がこれを提案しています。

命令の時間的/空間的局所性をより有効に活用するために、コードがどのようにアセンブリに変換されるかを調べることができます。例えば:

for(i = 0; i < MAX; ++i)
for(i = MAX; i > 0; --i)

2つのループは、単に配列を解析するだけでも、異なるコードを生成します。いずれにせよ、あなたの質問はアーキテクチャ特有のものです。そのため、キャッシュの使用を厳密に制御する唯一の方法は、ハードウェアの動作を理解し、コードを最適化することです。

1
sybreon