「キャッシュフレンドリー」なコードとは何ですか?


739

キャッシュに適さないコード」と「キャッシュに適した」コードの違いは何ですか?

キャッシュ効率の高いコードを確実に作成するにはどうすればよいですか?


28
これはあなたにヒントを与えるかもしれない:stackoverflow.com/questions/9936132/...
ロバート・マーティン

4
キャッシュラインのサイズにも注意してください。最近のプロセッサーでは、多くの場合64バイトです。
John Dibling 2013年

3
ここに別の非常に良い記事があります。原則は、任意のOS(Linux、MaxOSまたはWindows)上のC / C ++プログラムに適用されます:lwn.net/Articles/255364
paulsm4


回答:


966

予選

最近のコンピューターでは、最下位レベルのメモリー構造(レジスター)のみが単一クロックサイクルでデータを移動できます。ただし、レジスターは非常に高価であり、ほとんどのコンピューターコアには数ダース未満のレジスターがあります(合計で数百から数千バイト)。メモリスペクトル(DRAM)のもう一方の端では、メモリは非常に安価です(つまり、文字通り数百万倍も安いです)が、データを受信する要求の後、数百サイクルかかります。超高速で高価、超低速で安価なこのギャップを埋めるのがキャッシュメモリです、速度とコストを下げることでL1、L2、L3と名付けました。アイデアは、実行中のコードのほとんどが小さな変数のセットに頻繁にヒットし、残りの部分(はるかに大きな変数のセット)がまれにヒットするというものです。プロセッサがL1キャッシュでデータを見つけられない場合、L2キャッシュを調べます。そこにない場合はL3キャッシュ、そこにない場合はメインメモリ。これらの「ミス」のそれぞれは、時間のかかるものです。

(システムメモリがハードディスクストレージであるため、キャッシュメモリはシステムメモリに類似しています。ハードディスクストレージは非常に安価ですが、非常に低速です)。

キャッシュは、待ち時間の影響を減らすための主要な方法の1つです。Herb Sutter(下のリンクを参照)を言い換えると、帯域幅を増やすのは簡単ですが、遅延から抜け出す方法はありません

データは常にメモリ階層を介して取得されます(最小==最速から最遅)。キャッシュヒット/ミスは通常、CPU内のキャッシュの最高レベルにヒット/ミスを指し-最高レベルで私が最も遅い==最大を意味します。キャッシュヒット率は、パフォーマンスのために重要であるため、かかるRAM(またはより悪い...)からフェッチデータ内のすべてのキャッシュミスの結果、多くの時間を(RAMのサイクル数百、数十HDD用サイクルの何百万人もの)。比較すると、(最高レベルの)キャッシュからのデータの読み取りには、通常、ほんの数サイクルしかかかりません。

最近のコンピューターアーキテクチャでは、パフォーマンスのボトルネックがCPUダイ(RAM以上へのアクセスなど)から離れています。これは時間の経過とともに悪化します。プロセッサ周波数の増加は現在、パフォーマンスの向上とは関係ありません。問題はメモリアクセスです。したがって、CPUのハードウェア設計の取り組みは、現在、キャッシュ、プリフェッチ、パイプライン、および同時実行の最適化に重点を置いています。たとえば、最近のCPUはダイの約85%をキャッシュに、最大99%をデータの格納/移動に費やしています!

この問題については、かなり多くのことが言われています。キャッシュ、メモリ階層、適切なプログラミングに関するいくつかの優れたリファレンスを以下に示します。

キャッシュフレンドリーなコードの主な概念

キャッシュフレンドリコードの非常に重要な側面は、局所性の原則にあります。その目的は、効率的なキャッシュを可能にするために、関連するデータをメモリ内に配置することです。CPUキャッシュに関しては、これがどのように機能するかを理解するためにキャッシュラインに注意することが重要です。キャッシュラインはどのように機能しますか?

次の特定の側面は、キャッシュを最適化するために非常に重要です。

  1. 時間的局所性:特定のメモリ位置にアクセスした場合、近い将来に同じ位置に再度アクセスする可能性があります。理想的には、この情報はその時点でまだキャッシュされています。
  2. 空間的局所性:これは、関連するデータを互いに近くに配置することを指します。キャッシュは、CPU内だけでなく、多くのレベルで発生します。たとえば、RAMから読み取る場合、プログラムはすぐにそのデータを必要とすることが非常に多いため、通常、特に要求されたものよりも大きなメモリチャンクがフェッチされます。HDDキャッシュも同じ考え方です。特にCPUキャッシュでは、キャッシュラインの概念が重要です。

適切に使用する コンテナ

キャッシュフレンドリーとキャッシュ非フレンドリーの簡単な例は次のとおりです。 「S std::vectorstd::list。の要素はstd::vector隣接するメモリに格納されるため、それらの要素にアクセスすることは、コンテンツを場所全体に格納するの要素にアクセスするよりもはるかにキャッシュフレンドリーですstd::list。これは空間的な局所性によるものです。

これの非常に素晴らしいイラストは、このyoutubeクリップでBjarne Stroustrupによって提供されています(リンクを提供してくれた@Mohammad Ali Baydounに感謝します!)。

データ構造とアルゴリズム設計でキャッシュを無視しないでください

可能な限り、キャッシュを最大限に活用できるようにデータ構造と計算の順序を調整してください。これに関する一般的な手法は、キャッシュブロッキング (Archive.orgバージョン)です。これは、ハイパフォーマンスコンピューティングで非常に重要です(たとえば、ATLASなど)。

データの暗黙の構造を知り、活用する

フィールドの多くの人が時々忘れてしまうもう1つの簡単な例は、列優先です(ex。 )対行優先順(例: )2次元配列を格納します。たとえば、次のマトリックスを考えてみます。

1 2
3 4

行優先順では、これはとしてメモリに格納されます1 2 3 4。列優先順では、これはとして格納され1 3 2 4ます。この順序付けを悪用しない実装が、キャッシュの問題(すぐに回避できる!)にすぐに遭遇することは簡単にわかります。残念ながら、私はこのようなものを参照してください非常に自分のドメイン(機械学習)である場合が多いです。@MatteoItaliaは彼の回答でこの例をより詳細に示しました。

マトリックスの特定の要素をメモリからフェッチすると、その近くの要素もフェッチされ、キャッシュラインに格納されます。順序付けが悪用されると、メモリアクセスが少なくなります(後続の計算に必要な次のいくつかの値がすでにキャッシュラインにあるため)。

簡単にするために、キャッシュが2つの行列要素を含むことができる単一のキャッシュラインを含み、特定の要素がメモリからフェッチされると、次の要素も同様であると仮定します。上記の例の2x2マトリックスのすべての要素の合計を求めたいとしましょう(それをと呼びましょうM):

順序付けを利用する(たとえば、最初に列インデックスを変更する) ):

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

順序付けを利用しない(たとえば、最初に行インデックスを変更する) ):

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

この単純な例では、順序付けを利用すると実行速度が約2倍になります(メモリアクセスには合計を計算するよりもはるかに多くのサイクルが必要になるため)。実際には、パフォーマンスの差ははるかに大きくなる可能性があります。

予測不可能な分岐を避ける

パイプラインとコンパイラを備えた最新のアーキテクチャは、メモリアクセスによる遅延を最小限に抑えるためのコードの並べ替えに非常に優れています。重要なコードに(予測できない)分岐が含まれている場合、データをプリフェッチすることは困難または不可能です。これにより、間接的にキャッシュミスが増加します。

これはここで非常によく説明されています(リンクの@ 0x90のおかげです):ソートされていない配列を処理するよりもソートされた配列を処理する方が高速なのはなぜですか?

仮想関数を避ける

のコンテキストで virtualメソッドは、キャッシュミスに関して物議を醸す問題を表しています(パフォーマンスの観点から可能な限り回避する必要があるという一般的なコンセンサスが存在します)。仮想関数はルックアップ中にキャッシュミスを引き起こす可能性がありますが、これは特定の関数が頻繁に呼び出されない場合にのみ発生します(そうでない場合はキャッシュされる可能性があります)。この問題に関するリファレンスについては、C ++クラスに仮想メソッドを含めることによるパフォーマンスコストを確認してください

一般的な問題

マルチプロセッサキャッシュを備えた最新のアーキテクチャに共通する問題は、偽共有と呼ばれます。これは、個々のプロセッサが別のメモリ領域のデータを使用しようとして、同じキャッシュラインにデータを格納しようとしたときに発生します。これにより、別のプロセッサが使用できるデータを含むキャッシュラインが何度も上書きされます。事実上、このような状況でキャッシュミスを誘発することにより、異なるスレッドが互いに待機させます。参照(リンクの@Mattに感謝):キャッシュラインサイズに合わせる方法とタイミング

RAMメモリでの不十分なキャッシングの極端な症状(これは、このコンテキストではおそらく意味しません)は、いわゆるスラッシングです。これは、ディスクアクセスを必要とするページフォールト(現在のページにないメモリへのアクセスなど)がプロセスによって継続的に生成される場合に発生します。


27
おそらく、マルチスレッドコードのデータもローカルになりすぎる可能性があることを説明することで、答えを少し広げることができます(たとえば、誤った共有)
TemplateRex

2
チップ設計者が有用だと考える数だけキャッシュのレベルを設定できます。一般に、速度とサイズのバランスをとっています。L1キャッシュをL5と同じ大きさで、同じくらい高速にできれば、L1だけが必要になります。
Rafael Baptista、2013年

24
StackOverflowでの同意の空の投稿は不承認ですが、これは正直言って、これまでに見た中で最も明確で最良の回答です。素晴らしい仕事、マーク。
Jack Aidley、2013年

2
@JackAidleyあなたの賞賛に感謝します!この質問に多くの注意が向けられているのを見たとき、私は多くの人がやや広範な説明に興味があるかもしれないと思いました。お役に立てて嬉しいです。
Marc Claesen

1
あなたが言及しなかったことは、キャッシュフレンドリーなデータ構造は、キャッシュライン内に収まるように設計されており、キャッシュラインを最適に使用するためにメモリに揃えられているということです。素晴らしい答えです!驚くばかり。
マット

140

@Marc Claesenの回答に加えて、キャッシュに適さないコードの有益な古典的な例は、Cの2次元配列(ビットマップイメージなど)を行ごとではなく列ごとにスキャンするコードだと思います。

行内で隣接している要素もメモリ内で隣接しているため、それらに順番にアクセスするとは、メモリの昇順でアクセスすることを意味します。キャッシュは連続したメモリブロックをプリフェッチする傾向があるため、これはキャッシュフレンドリーです。

代わりに、同じ列の要素はメモリ内で互いに離れているため(特に、それらの距離は行のサイズと等しい)、このような要素に列ごとにアクセスすることはキャッシュに適していません。このため、このアクセスパターンを使用すると、メモリ内でジャンプして、メモリ内の近くの要素を取得するキャッシュの労力を無駄にする可能性があります。

そして、パフォーマンスを台無しにするために必要なのは、

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

この効果は、キャッシュが小さいシステムや大きなアレイで作業しているシステム(たとえば、現在のマシンで10+メガピクセル24 bpp画像)で非常に劇的(数桁の速度)になる場合があります。このため、多くの垂直スキャンを行う必要がある場合は、多くの場合、最初に画像を90度回転し、後でさまざまな分析を実行して、キャッシュに適さないコードを回転に限定することをお勧めします。


エラー、それはx <widthですか?
mowwwalker 2013年

13
最新の画像エディターは、内部ストレージとしてタイルを使用します(64x64ピクセルのブロックなど)。これは、ほとんどの場合、隣接するピクセルが両方向のメモリ内で近接しているため、ローカル操作(軽くたたく、ぼかしフィルターを実行する)のキャッシュフレンドリーです。
2013年

私のマシンで同様の例のタイミングを計ってみましたが、時間が同じであることがわかりました。他の誰かがそれを計時してみましたか?
gsingh2011 2013年

@ I3arnon:いいえ、最初はキャッシュフレンドリーです。通常、Cの配列は行優先順で保存されるためです(もちろん、何らかの理由で画像が優先順で保存されている場合は、逆がtrueです)。
Matteo Italia

1
@Gauthier:はい、最初のスニペットは良いものです。私がこれを書いたとき、私は「[動作中のアプリケーションのパフォーマンスを台無しにするために必要なことは...から...に行くことだ」と考えていたと思います
Matteo Italia

88

キャッシュ使用の最適化には、主に2つの要因があります。

参照の局所性

(他の人がすでに言及している)最初の要素は参照の局所性です。ただし、参照の局所性には、実際には空間と時間という2つの次元があります。

  • 空間的な

空間的次元も2つの点に分類されます。1つ目は、情報を密にパックし、限られたメモリに多くの情報が収まるようにすることです。これは、たとえば、ポインタで結合された小さなノードに基づくデータ構造を正当化するには、計算の複雑さを大幅に改善する必要があることを意味します。

次に、一緒に処理される情報も一緒に配置する必要があります。典型的なキャッシュは「行」で機能します。つまり、一部の情報にアクセスすると、近くのアドレスにある他の情報が、触れた部分とともにキャッシュにロードされます。たとえば、1バイトをタッチすると、そのバイトの近くにある128バイトまたは256バイトがキャッシュに読み込まれる可能性があります。これを利用するには、通常、同時に読み込まれた他のデータも使用する可能性を最大化するようにデータを配置する必要があります。

ほんのささいな例としては、これは、線形検索の方が、バイナリ検索と比べて、予想よりもはるかに競争が激しいことを意味します。キャッシュラインから1つのアイテムをロードすると、そのキャッシュラインの残りのデータを使用することはほぼ無料です。バイナリ検索が著しく高速になるのは、データが十分に大きく、バイナリ検索がアクセスするキャッシュラインの数を減らす場合のみです。

  • 時間

時間ディメンションは、一部のデータに対していくつかの操作を行う場合、そのデータに対してすべての操作を(可能な限り)一度に実行することを意味します。

これにC ++のタグを付けたので、比較的キャッシュに適さないデザインの典型的な例を示しますstd::valarrayvalarrayオーバーロード最も算術演算子、Iは、(例えば)と言うことができるようにa = b + c + d;abcおよびdそれらの配列の要素ごとの追加を行うために、すべてのvalarraysです)。

これの問題は、1つの入力ペアをウォークスルーし、結果を一時的に配置し、別の入力ペアをウォークスルーすることなどです。大量のデータがあると、ある計算の結果が次の計算で使用される前にキャッシュから消えてしまう可能性があるため、最終的な結果が得られる前に、データの読み取り(および書き込み)を繰り返します。最終結果の各要素のようなものになる場合は(a[n] + b[n]) * (c[n] + d[n]);、我々は一般的に、それぞれを読むことを好むだろうa[n]b[n]c[n]d[n]一度、計算を行い、その結果、増分書くn我々が行われているゴマ」と繰り返しを。2

ライン共有

2番目の主な要因は、回線の共有を回避することです。これを理解するには、おそらくバックアップして、キャッシュがどのように構成されているかを少し見る必要があります。キャッシュの最も単純な形式は直接マッピングされます。つまり、メインメモリ内の1つのアドレスは、キャッシュ内の1つの特定の場所にのみ格納できます。キャッシュ内の同じ場所にマップする2つのデータアイテムを使用している場合は、うまく機能しません。一方のデータアイテムを使用するたびに、もう一方をキャッシュからフラッシュして、もう一方のスペースを空ける必要があります。キャッシュの残りの部分は空である可能性がありますが、それらのアイテムはキャッシュの他の部分を使用しません。

これを防ぐために、ほとんどのキャッシュは「セットアソシアティブ」と呼ばれるものです。たとえば、4ウェイセットアソシエイティブキャッシュでは、メインメモリのアイテムをキャッシュ内の4つの異なる場所のいずれかに保存できます。したがって、キャッシュがアイテムをロードしようとするとき、キャッシュはこれらの4つの中で最も最近使用されていない3つのアイテムを探し、それをメインメモリにフラッシュし、代わりに新しいアイテムをロードします。

問題はおそらくかなり明白です。直接マップされたキャッシュの場合、たまたま同じキャッシュの場所にマップされる2つのオペランドは、不適切な動作を引き起こす可能性があります。Nウェイのセットアソシアティブキャッシュは、数を2からN + 1に増やします。キャッシュをより「ウェイ」に編成すると、余分な回路が必要になり、一般的に実行速度が低下するため、(たとえば)8192ウェイセットの連想キャッシュが優れたソリューションになることはめったにありません。

結局のところ、この要素は移植可能なコードでは制御がより困難です。データの配置場所の制御は通常、かなり制限されています。さらに悪いことに、アドレスからキャッシュへの正確なマッピングは、他の同様のプロセッサー間で異なります。ただし、場合によっては、大きなバッファを割り当て、同じキャッシュラインを共有するデータに対してデータを確保するために割り当てた部分のみを使用するなどの価値がある場合があります(正確なプロセッサとこれを行うためにそれに応じて行動します)。

  • 偽りの共有

「偽りの共有」と呼ばれる別の関連アイテムがあります。これは、2つ(またはそれ以上)のプロセッサ/コアが別々のデータを持っているが、同じキャッシュラインにあるマルチプロセッサまたはマルチコアシステムで発生します。これにより、2つのプロセッサ/コアは、それぞれが独自の個別のデータ項目を持っている場合でも、データへのアクセスを調整する必要があります。特に、2つが交互にデータを変更する場合、データはプロセッサ間で絶えず往復する必要があるため、これにより大幅な速度低下が発生する可能性があります。これは、キャッシュをより「ウェイ」またはそのようなものに編成することによって簡単に解決することはできません。これを防ぐ主な方法は、2つのスレッドが同じキャッシュラインにある可能性のあるデータをめったに(できれば決して)変更しないようにすることです(データが割り当てられているアドレスを制御するのが難しいという同じ警告があります)。


  1. C ++をよく知っている人は、これが式テンプレートのようなものを介して最適化を受け入れるかどうか疑問に思うかもしれません。答えは確かにそうだと思います。それができたとしても、できればかなりの勝利になるでしょう。私は誰かがそうしたことに気づいていませんvalarrayが、慣れていないことを考えると、誰かがそうしているのを見ても、少なくとも少しは驚きます。

  2. valarray(パフォーマンスのために特別に設計された)これがひどく間違っているのではないかと誰かが疑問に思った場合、それは1つのことになります:高速のメインメモリを使用し、キャッシュを使用しない古いCrayのようなマシン用に設計されました。彼らにとって、これは本当にほぼ理想的なデザインでした。

  3. はい、私は単純化しています。ほとんどのキャッシュは、最も最近使用されていないアイテムを正確に測定していませんが、アクセスごとに完全なタイムスタンプを維持する必要なく、それに近いことを目的としたヒューリスティックを使用しています。


1
私はあなたの答えの余分な情報、特にvalarray例が好きです。
Marc Claesen

1
+1ついに:集合の関連性のわかりやすい説明!さらに編集:これは、SOに関する最も有益な回答の1つです。ありがとうございました。
エンジニア

32

データ指向設計の世界へようこそ。基本的な信条は、並べ替え、分岐の削除、バッチ、virtual呼び出しの削除です。

質問にC ++のタグを付けたので、必須の典型的なC ++ Bullshitを以下に示します。Tony Albrechtによるオブジェクト指向プログラミング落とし穴も、この主題の優れた入門書です。


1
バッチとはどういう意味ですか、理解できない場合があります。
0x90 2013年

5
バッチ処理:単一のオブジェクトに対して作業ユニットを実行する代わりに、オブジェクトのバッチに対して実行します。
arul

AKAブロッキング、ブロッキングレジスタ、ブロッキングキャッシュ。
0x90 2013年

1
ブロッキング/非ブロッキングは、通常、オブジェクトが並行環境でどのように動作するかを示します。
2013年

2
バッチ処理== ベクトル化
Amro

23

積み重ねるだけ:キャッシュに適さないコードとキャッシュに適したコードの典型的な例は、行列乗算の「キャッシュブロッキング」です。

単純な行列乗算は次のようになります。

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

Nが大きい場合、たとえばN * sizeof(elemType)がキャッシュサイズより大きい場合、へのすべてのアクセスsrc2[k][j]はキャッシュミスになります。

これをキャッシュ用に最適化するには、さまざまな方法があります。これは非常に単純な例です。内部ループのキャッシュラインごとに1つのアイテムを読み取る代わりに、すべてのアイテムを使用します。

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

キャッシュラインのサイズが64バイトで、32ビット(4バイト)の浮動小数点数で動作している場合、キャッシュラインごとに16のアイテムがあります。また、この単純な変換だけでキャッシュミスの数が約16分の1に減少します。

より洗練された変換は2Dタイルで動作し、複数のキャッシュ(L1、L2、TLB)向けに最適化されます。

「キャッシュブロッキング」のグーグルのいくつかの結果:

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

最適化されたキャッシュブロッキングアルゴリズムの素晴らしい動画アニメーション。

http://www.youtube.com/watch?v=IFWgwGMMrh0

ループタイリングは密接に関連しています。

http://en.wikipedia.org/wiki/Loop_tiling


7
これを読んだ人は、行列の乗算に関する私の記事にも興味があるかもしれません。ここでは、2つの2000x2000行列を乗算して、「キャッシュに適した」ikj-algorithmと非友好的なijk-algorithmをテストしました。
Martin Thoma

3
k==;これがタイプミスであることを願っていますか?
TrebledJ

13

今日のプロセッサは、多くのレベルのカスケードメモリ領域で動作します。したがって、CPUには、CPUチップ自体にある大量のメモリがあります。このメモリへのアクセスは非常に高速です。CPU上になく、アクセスが比較的遅いシステムメモリに到達するまで、キャッシュのレベルはそれぞれ異なります。

論理的には、CPUの命令セットでは、巨大な仮想アドレス空間のメモリアドレスを参照するだけです。単一のメモリアドレスにアクセスすると、CPUはそれをフェッチします。昔は、その単一のアドレスだけをフェッチしていました。しかし、今日、CPUは要求されたビットの周りに大量のメモリをフェッチし、それをキャッシュにコピーします。特定の住所を要求した場合、すぐに近くの住所を要求する可能性が高いと想定しています。たとえば、バッファをコピーする場合、連続したアドレスから次々と読み書きします。

したがって、今日アドレスをフェッチすると、最初のレベルのキャッシュをチェックして、そのアドレスがすでにキャッシュに読み込まれているかどうかを確認します。見つからない場合、これはキャッシュミスであり、次のレベルに移動する必要があります。最終的にメインメモリに移動する必要があるまで、キャッシュを見つけて見つけます。

キャッシュフレンドリーなコードは、アクセスをメモリ内で互いに近接させて、キャッシュミスを最小限に抑えようとします。

したがって、例として、巨大な2次元テーブルをコピーしたいとします。リーチ行がメモリ内で連続して編成され、1つの行が直後に続きます。

要素を左から右に一度に1行ずつコピーした場合-キャッシュフレンドリーになります。テーブルを一度に1列ずつコピーすることにした場合は、まったく同じ量のメモリをコピーしますが、これはキャッシュに不向きです。


4

データはキャッシュフレンドリーであるだけでなく、コードにとっても重要であることを明確にする必要があります。これは、分岐予測、命令の並べ替え、実際の除算やその他の手法の回避に追加されます。

通常、コードの密度が高いほど、コードを格納するために必要なキャッシュラインが少なくなります。これにより、データに使用できるキャッシュラインが増えます。

通常、コードは1つ以上の独自のキャッシュラインを必要とし、その結果、データ用のキャッシュラインが少なくなるため、関数をすべての場所で呼び出すことはできません。

関数は、キャッシュラインアライメントに適したアドレスから開始する必要があります。これには(gcc)コンパイラスイッチがありますが、関数が非常に短い場合、それぞれがキャッシュライン全体を占有するのは無駄になる可能性があることに注意してください。たとえば、最も頻繁に使用される3つの関数が1つの64バイトキャッシュラインに収まる場合、それぞれに独自のラインがあり、2つのキャッシュラインが他の用途に使用できない場合よりも無駄が少なくなります。一般的なアライメント値は32または16です。

そのため、コードを密にするために少し時間を費やしてください。さまざまな構成要素をテストし、生成されたコードサイズとプロファイルをコンパイルして確認します。


2

@Marc Claesenが言及したように、キャッシュフレンドリーなコードを書く方法の1つは、データが格納されている構造を利用することです。それに加えて、キャッシュフレンドリーなコードを記述する別の方法は次のとおりです。データの保存方法を変更します。次に、この新しい構造に格納されているデータにアクセスするための新しいコードを記述します。

これは、データベースシステムがテーブルのタプルを線形化して格納する方法の場合に理にかなっています。テーブルのタプルを格納するには、行ストアと列ストアの2つの基本的な方法があります。名前が示すように、行ストアではタプルは行ごとに格納されます。Product格納されているという名前のテーブルに3つの属性、つまりint32_t key, char name[56]int32_t priceがあるため、タプルの合計サイズは64バイトであるとします。

ProductサイズN の構造体の配列を作成することにより、メインメモリでの非常に基本的な行ストアクエリの実行をシミュレートできます。ここで、Nはテーブルの行数です。このようなメモリレイアウトは、構造体の配列とも呼ばれます。したがって、Productの構造体は次のようになります。

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

同様に、サイズNの3つの配列を作成することにより、メインメモリでの非常に基本的な列ストアクエリの実行をシミュレートできProductます。このようなメモリレイアウトは、配列の構造体とも呼ばれます。したがって、Productの各属性の3つの配列は次のようになります。

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

構造体の配列(行レイアウト)と3つの個別の配列(列レイアウト)の両方をロードした後、テーブルに行ストアと列ストアがあります Product、メモリに存在。

次に、キャッシュフレンドリーなコード部分に進みます。テーブルのワークロードが、price属性に対する集計クエリがあるようなものであるとします。といった

SELECT SUM(price)
FROM PRODUCT

行ストアの場合、上記のSQLクエリを次のように変換できます。

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

列ストアの場合、上記のSQLクエリを次のように変換できます。

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

列ストアのコードはこのクエリの行レイアウトのコードよりも高速です。これは、属性のサブセットのみが必要であり、列レイアウトではそれだけを実行する、つまり価格列にのみアクセスするためです。

キャッシュラインのサイズが 64バイトであるます。

キャッシュラインが読み取られるときの行レイアウトの場合、価格値は1(cacheline_size/product_struct_size = 64/64 = 1 64バイトの構造体サイズとキャッシュライン全体を埋めるため、)タプルが読み取られるため、すべてのタプルについて、キャッシュミスが発生します。行レイアウトの。

キャッシュラインが読み取られるときの列レイアウトの場合、価格値16(cacheline_size/price_int_size = 64/4 = 16メモリに保存されている16個の連続する価格値がキャッシュに読み込まれるため)タプルのが読み取られます。列のレイアウト。

したがって、列のレイアウトは、特定のクエリの場合はより速くなり、テーブルの列のサブセットに対するそのような集約クエリではより速くなります。TPC-Hベンチマークのデータを使用して、このような実験を実際に試し、両方のレイアウトの実行時間を比較できます。ウィキペディアの列指向データベース・システム上の記事でも良いです。

したがって、データベースシステムでは、クエリのワークロードが事前にわかっている場合は、ワークロードのクエリに適したレイアウトにデータを格納し、これらのレイアウトからデータにアクセスできます。上記の例の場合、列レイアウトを作成し、合計を計算するようにコードを変更して、キャッシュフレンドリーになるようにしました。


1

キャッシュは連続メモリをキャッシュするだけではないことに注意してください。それらには複数の行(少なくとも4つ)があるため、不連続で重複するメモリを同じくらい効率的に保存できます。

上記のすべての例に欠けているのは、測定されたベンチマークです。パフォーマンスについては多くの神​​話があります。あなたがそれを測定しない限り、あなたは知りません。測定された改善がない限り、コードを複雑にしないでください。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.