【オペレーティングシステム】人気の科学CPU CACHEの7例

例1:メモリアクセスと操作

ループ1と比較して、ループ2はどれくらい速く実行すると思いますか?

int[] arr = new int[64 * 1024 * 1024];

// Loop 1
for (int i = 0; i < arr.Length; i++) arr[i] *= 3;

// Loop 2
for (int i = 0; i < arr.Length; i += 16) arr[i] *= 3;

最初のループは配列の各値に3を乗算し、2番目のループは16の値ごとに3を乗算します。2番目のループは最初の約6%の処理のみを実行しますが、最近のマシンでは2つはほぼ私のマシンでは、80ミリ秒と78ミリ秒を同時に実行します。

2つのサイクルが同じ時間を費やす理由は、メモリに関連しています。ループ実行時間の長さは、整数の乗算の数ではなく、配列のメモリアクセスの数によって決まります。以下の2番目の例を説明すると、これら2つのサイクルへのハードウェアメモリアクセスの数が同じであることがわかります。

キャッシュラインの影響

この例をさらに詳しく見てみましょう。1と16だけでなく、さまざまなループステップを試します。

for (int i = 0; i < arr.Length; i += K) arr[i] *= 3;

次の図は、非同期長(K)でのサイクルの実行時間を示しています。
ここに画像の説明を挿入
ステップサイズが1〜16 の範囲にある場合、サイクルの実行時間はほとんど変化しないことに注意してください。しかし、16から始めて、時間ステップが2倍になるたびに、実行時間が半分になります。

この背後にある理由は、今日のCPUがバイト単位でメモリにアクセスするのではなく、キャッシュラインと呼ばれる64バイトのチャンクでそれらを取得するためです。特定のメモリアドレスを読み取ると、キャッシュライン全体がメインメモリからキャッシュにスワップされ、同じキャッシュライン内の他の値にアクセスするオーバーヘッドは非常に小さくなります。

16の整数は64バイト(1キャッシュライン)を占有するため、1〜16のforループのステップサイズは、同じ数のキャッシュライン、つまり配列内のすべてのキャッシュラインに接触する必要があります。ステップサイズが32の場合、2つのキャッシュラインごとに1回だけ接続し、ステップサイズが64の場合、4つごとに1回のみ接続します。

キャッシュラインを理解することは、特定のタイプのプログラム最適化にとって重要です。たとえば、データバイトアライメントは、1つの操作が1つまたは2つのキャッシュラインに接触するかどうかを決定します。上記の例では、不揃いのデータを操作すると、パフォーマンスの半分が失われることは明らかです。

例3:L1およびL2キャッシュサイズ

今日のコンピューターには、一般にL1、L2、場合によってはL3と呼ばれる2つまたは3つのレベルのキャッシュがあります(レベル2キャッシュが何であるかがわからない場合は、この手の込んだブログ投稿を参照しください)。さまざまなキャッシュのサイズを知りたい場合は、システムの内部ツールCoreInfoまたはWindows API呼び出しのGetLogicalProcessorInfoを使用できます。どちらもキャッシュラインとキャッシュ自体のサイズを教えてくれます。

私のマシンで、CoreInfoは、32KB L1データキャッシュ、32KB L1命令キャッシュ、および4MB L2データキャッシュがあることを示しています。L1キャッシュはプロセッサ専用であり、L2キャッシュはプロセッサのペア間で共有されます。

論理プロセッサからキャッシュへのマッ​​プ:
* —データキャッシュ0、レベル1、32 KB、Assoc 8、LineSize 64
* —命令キャッシュ0、レベル1、32 KB、Assoc 8、LineSize 64-
* –データキャッシュ1、レベル1 32 KB、Assoc 8、LineSize 64-
* –命令キャッシュ1、レベル1、32 KB、Assoc 8、LineSize 64
** –統合キャッシュ0、レベル2、4 MB、Assoc 16、LineSize 64
-*-データキャッシュ2 、レベル1、32 KB、Assoc 8、LineSize 64
– *-命令キャッシュ2、レベル1、32 KB、Assoc 8、LineSize 64
— *データキャッシュ3、レベル1、32 KB、Assoc 8、LineSize 64
— *命令キャッシュ3、レベル1、32 KB、Assoc 8、LineSize 64
– **統合キャッシュ1、レベル2、4 MB、Assoc 16、LineSize 64

(プラットフォームはクアッドコアマシンであるため、L1番号は0 3、各データ/命令ごとに1つ、L2にはデータキャッシュのみ、2つのプロセッサは1を共有、番号0 1です。相関フィールドは次の例で説明されています。)

これらの数値を実験で検証してみましょう。整数配列を反復処理し、16の値ごとにインクリメントします。これにより、各キャッシュラインを変更できます。最後の値に移動したら、最初からやり直します。異なる配列サイズを使用します。配列が一次キャッシュサイズをオーバーフローすると、プログラムのパフォーマンスが大幅に低下することがわかります。

int steps = 64 * 1024 * 1024;
// Arbitrary number of steps
int lengthMod = arr.Length - 1;
for (int i = 0; i < steps; i++)
{
    arr[(i * 16) & lengthMod]++; // (x & lengthMod) is equal to (x % arr.Length)
}

ここに画像の説明を挿入
マシンのL1およびL2キャッシュサイズが正確に32KBおよび4MBになると、パフォーマンスが大幅に低下することがわかります。

例4:命令レベルの並行性

では、さまざまなことを見てみましょう。次の2つのループのどちらが速いと思いますか?

int steps = 256 * 1024 * 1024;
int[] a = new int[2];

// Loop 1
for (int i=0; i<steps; i++) { a[0]++; a[0]++; }

// Loop 2
for (int i=0; i<steps; i++) { a[0]++; a[1]++; }

結果は、少なくとも私がテストしたマシンでは、2番目のサイクルは最初のサイクルの約2倍です。なんで?これは、2つのループでの操作命令の依存関係に関連しています。

最初のループでは、操作は相互に依存しています(翻訳者の注:次回は前のものに依存します)。
同じ値の依存関係

しかし、2番目の例では、依存関係が異なります。
異なる値の依存関係

最新のプロセッサは、命令のさまざまな部分に少し並行性があります(翻訳者注:パイプラインに関連して、たとえば、Pentiumプロセッサには、後述するU / V 2つのパイプラインがあります)。これにより、CPUはL1の2つのメモリ位置に同時にアクセスしたり、2つの単純な算術演算を実行したりできます。最初のサイクルでは、プロセッサはこの種の命令レベルの同時実行性を検出できませんが、2番目のサイクルにある可能性があります。

例5:キャッシュアフィニティ

キャッシュ設計における重要な決定は、各メインチャンクを任意のキャッシュスロットまたはそれらの一部(ここではスロットはキャッシュラインです)に確実に格納できるようにすることです。

キャッシュスロットをメインメモリブロックにマップするには、次の3つの方法があります。

  1. 直接マップされたキャッシュ(直接マップされたキャッシュ)
    各メモリブロックは、特定のキャッシュスロットにのみマップできます。簡単な解決策は、チャンクインデックスchunk_indexを介して対応するスロット(chunk_index%cache_slots)にマッピングすることです。同じメモリスロットにマップされた2つのメモリブロックを同時にキャッシュにスワップすることはできません。(Chunk_indexは、物理アドレス/キャッシュラインバイトで計算できます)
  2. Nウェイセットアソシエイティブキャッシュ(Nウェイセットアソシアティブキャッシュ)
    各メモリブロックは、Nウェイ固有のキャッシュスロットのいずれかにマップできます。たとえば、16ウェイキャッシュでは、各メモリブロックを16の異なるキャッシュスロットにマップできます。一般に、特定の低ビットアドレスのメモリブロックは、16ウェイキャッシュスロットを共有します。(同じ下位アドレスは、特定のユニットサイズだけ離れた連続メモリを示します)
  3. 完全連想キャッシュ:
    各メモリブロックは任意のキャッシュスロットにマップできます。演算効果はハッシュテーブルと同等です。

直接マッピングキャッシュは競合を引き起こします。同じキャッシュスロットに対して複数の値が競合すると、それらは互いに追い出され、ヒット率が急激に低下します。一方、完全連想キャッシングは複雑すぎて、ハードウェアの実装は高価です。Nウェイグループアソシエーションは、プロセッサキャッシュの典型的なスキームであり、回路の簡素化と高いヒットレートとの間で適切な妥協を図ります。

たとえば、サイズが4MBのL2キャッシュは、私のマシンの16チャネルに関連付けられています。64バイトのメモリブロックはすべて異なるグループに分割され、同じグループにマップされたメモリブロックは、L2キャッシュの16ウェイスロットをめぐって競合します。

L2キャッシュには65,536キャッシュライン(翻訳者注:4MB / 64)があり、各グループには16キャッシュラインが必要です。4096セットになります。このように、ブロックが属するグループは、ブロックインデックスの下位12ビットに依存します(2 ^ 12 = 4096)。したがって、262,144バイトの倍数(4096 * 64)で分割されたキャッシュラインに対応する物理アドレスは、同じキャッシュスロットで競合します。私のマシンでは、このようなキャッシュスロットを16個まで維持しています。(翻訳者注:上の図の双方向の関連付けで理解を深めてください。ブロックインデックスは64バイトに対応し、chunk0はグループ0の任意のスロットに対応し、chunk1はグループ1の任意のスロットに対応します。 chunk4095はグループ4095の任意のスロットに対応し、chunk0およびchunk4096アドレスの下位12ビットは同じであるため、chunk4096およびchunk8192はグループ0のスロットのchunk0と競合し、それらの間のアドレスは262,144バイトの倍数で異なります。あなたは16回まで競争することができます、そうでなければあなたはチャンクを追放します)。

キャッシュの関連付けの効果をより明確にするために、同じグループ内の16を超える要素に繰り返しアクセスする必要があります。これは、次の方法で証明されています。

public static long UpdateEveryKthByte(byte[] arr, int K)
{
    Stopwatch sw = Stopwatch.StartNew();
    const int rep = 1024*1024; // Number of iterations – arbitrary
    int p = 0;
    for (int i = 0; i < rep; i++)
    {
        arr[p]++;
        p += K;
        if (p >= arr.Length) p = 0;
    }
    sw.Stop();
    return sw.ElapsedMilliseconds;
}

このメソッドは、毎回配列のK値を反復処理し、最後に到達すると最初から開始します。サイクルは十分に長く実行された後に停止します(2 ^ 20回)。

異なる配列サイズ(毎回1MBずつ増加)と異なるステップを使用して、UpdateEveryKthByte()を渡します。次のグラフは描画されたもので、青は実行時間が長く、白は短い時間を表しています。
ここに画像の説明を挿入
青い領域(長い時間)は、配列の反復を繰り返すときに、更新された値を同時にキャッシュに配置できないことを示しています。明るい青色の領域は80 msに対応し、白い領域は10 msに対応します。

チャートの青い部分を説明しましょう:

  1. なぜ縦線があるのですか?垂直線は、ステップ値が同じグループ内の非常に多くのメモリ位置に接触していることを示しています(16回以上)。この間、私のマシンでは、触れられた値を16ウェイ連想キャッシュに同時に入れることができません。

    悪いステップ値には、2の累乗である256と512があります。たとえば、8MB配列の512ステップトラバースを考えてみます。32要素は262,144バイトの間隔で分散されています。512は262,144を除算できるため、ループトラバーサルでは32要素すべてが更新されます。(翻訳者注:ここにステップがありますLongは1バイトを表します)。

    32は16より大きいため、これらの32要素は常にキャッシュ内の16ウェイスロットをめぐって競合します。

    (翻訳者注:512ステップの垂直線が256ステップよりも暗いのはなぜですか?512のブロックインデックスのコンテンツへのアクセスは、256のステップと同じだけのステップ数で、512倍の回数です。たとえば、262,144バイト境界512 512ステップが必要であり、256は1024ステップが必要です。ステップ数が2 ^ 20の場合、512は競合ブロックの2048倍、256は1024回しかアクセスしません。最悪の場合、ステップサイズは262,144の倍数になります。キャッシュラインの立ち退きをトリガーします。)

    2のべき乗ではない一部のステップは、実行時間が長く、不運なだけです。結局、同じグループ内の多くの要素が不均衡にアクセスされます。これらのステップの値も青い線で表示されます。

  2. 垂直線が4MBの配列長で停止するのはなぜですか?4MB以下のアレイの場合、16ウェイ連想キャッシュは完全連想キャッシュと同等です。

    16ウェイ連想キャッシュは、262,144バイトで区切られた最大16のキャッシュラインを保持できます。4MB内のグループ17以上のキャッシュラインは、16 * 262,144 = 4,194,304であるため、262,144バイト境界に整列されません。

  3. 左上隅に青い三角形が表示されるのはなぜですか?三角形の領域では、関連性のためではなく、L2キャッシュのサイズのためだけに、必要なすべてのデータを同時にキャッシュに格納することはできません。

    たとえば、ステップサイズが128の16MB配列をトラバースすることを検討してください。配列は128バイトごとに更新されます。つまり、一度に2つの64バイトメモリブロックにアクセスします。2つおきのキャッシュラインを16MBのアレイに格納するには、8MBのキャッシュが必要です。しかし、私のマシンには4MBのキャッシュしかありません(翻訳者注:これは、競合と遅延が発生する必要があることを意味します)。

    私のマシンの4MBキャッシュは完全に連想的ですが、それでも8MBのデータを同時に保存することはできません。

  4. なぜ三角形の左端が色あせているのですか?

    左の正確に1つのキャッシュラインの0〜64バイトの部分に注意してください。上記の例1と2で述べたように、同じキャッシュラインからのデータへの追加アクセスのオーバーヘッドはほとんどありません。たとえば、ステップサイズが16バイトの場合、次のキャッシュラインに到達するまでに4つのステップが必要です。つまり、4回のメモリアクセスでオーバーヘッドが1つしかありません。

    同じサイクル数のすべてのテストケースで、省力化ステップの実行時間は短いです。

    グラフの拡張モデル:
    ここに画像の説明を挿入
    キャッシュの関連付けは理解しやすく、検証できますが、この記事で説明する他の問題と比較すると、プログラミング時に考慮する必要がある最初の問題ではありません。

キャッシュラインの偽共有(偽共有)

マルチコアマシンでは、キャッシュで別の一貫性の問題が発生します。異なるプロセッサには、完全または部分的に分離されたキャッシュがあります。私のマシンでは、L1キャッシュは独立しています(これは非常に一般的です)、2組のプロセッサがあり、各ペアがL2キャッシュを共有しています。これは特定の状況によって異なりますが、最新のマルチコアマシンに複数のレベルのキャッシュがある場合、高速で小さなキャッシュがプロセッサによって独占されます。

プロセッサが独自のキャッシュに属する値を変更すると、対応するメモリの場所がすべてのキャッシュに対して更新(無効化)されるため、他のプロセッサは独自の元の値を使用できなくなります。また、キャッシュ操作はバイトではなくキャッシュラインに基づいているため、すべてのキャッシュのキャッシュライン全体が更新されます。

この問題を証明するには、次の例を検討してください

private static int[] s_counter = new int[1024];
private void UpdateCounter(int position)
{
    for (int j = 0; j < 100000000; j++)
    {
        s_counter[position] = s_counter[position] + 3;
    }
}

クアッドコアマシンで、4つのスレッドを介してパラメーター0、1、2、3を渡し、UpdateCounterを呼び出すと、すべてのスレッドに4.3秒かかります。

一方、16、32、48、64を渡すと、操作全体に0.28秒かかります。

なぜこれが起こっているのですか?最初の例の4つの値は、同じキャッシュラインにある可能性が高いです。プロセッサがカウントをインクリメントするたびに、これらの4つのカウントが更新されるキャッシュライン、および他のプロセッサが次にそれぞれのカウントにアクセスします注:配列はプライベート属性であり、各スレッドに排他的であることに注意してください)キャッシュが失われます。このマルチスレッド動作は、キャッシュ機能を効果的に無効にし、プログラムのパフォーマンスを低下させます。

例7:ハードウェアの複雑さ

キャッシングの仕組みの基本を理解していても、ハードウェアの動作が驚くかもしれません。プロセッサーなしで作業する場合、さまざまな最適化、ヒューリスティック、および微妙な詳細があります。

一部のプロセッサでは、L1キャッシュは2つのアクセスを同時に処理できます。アクセスが異なるメモリバンクからのものである場合、同じメモリバンクへのアクセスはシリアルでしか処理できません。そして、プロセッサーの賢い最適化戦略はあなたを驚かせます。たとえば、疑似共有の場合、微調整を行わない一部のマシンの以前のパフォーマンスは良くありませんが、私の家のマシンは最も単純な例を最適化できますキャッシュの更新を減らします。

「ハードウェアの奇妙さ」の奇妙な例を次に示します。

private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
    for (int i = 0; i < 200000000; i++)
    {
        // do something...
    }
}

ループ本体で3つの異なる操作を実行すると、次のランタイムが得られます。

運営 時間
A ++; B ++; C ++; D ++; 719ミリ秒
A ++; C ++; E ++; G ++; 448ミリ秒
A ++; C ++; 518ミリ秒

A、B、C、およびDフィールドを増やすと、A、C、E、およびGフィールドを追加するよりも時間がかかります。さらに奇妙なことに、AおよびCフィールドを追加すると、A、C、E、およびGフィールドを増やすよりも時間がかかります。

これらの数字の背後にある理由はわかりませんが、これはストレージに関連していると思われます。これらの数字を説明できる人がいれば、聞いてみます。

この例の教訓は、ハードウェアの動作を完全に予測することは難しいということです。多くのことを予測できますが、最終的には、仮定を測定して検証することが重要です。

7番目の例への回答

Goz:Intelのエンジニアに最後の例を尋ねると、次のような返事がありました。

「これには明らかに、実行ユニット内の命令がどのように終了されるか、マシンがストアヒットロードを処理する速度、およびループアンローリングのヒューリスティックな実行(内部競合により複数回ループするかどうかなど)を迅速かつエレガントに処理する方法が含まれます。しかし、これは理解するために非常に詳細なパイプライントラッカーとシミュレーターが必要であることを意味します。紙でパイプライン内の順序が狂った命令を予測することは、チップを設計する人々にとってさえ、非常に困難な作業です。すみません!」

PS地域の原則と組立ラインの個人認識-同時実行性

プログラムの実行には時間と空間の局所性があります。前者は、メモリ内の値がキャッシュにスワップされる限り、将来何度も参照されることを意味し、後者は、メモリの近くの値もキャッシュにスワップされることを意味します。局所性の原則をプログラミングに適用することに特別な注意を払うと、パフォーマンスのリターンが得られます。

たとえば、C言語では、静的変数の参照を最小限に抑える必要があります。これは、静的変数がグローバルデータセグメントに格納されるためです。繰り返し呼び出される関数本体では、変数を参照するには、キャッシュの内外で複数のスワップが必要です。スタックのローカル変数の場合、スタックの再利用率が高いため、関数がCPUを呼び出すたびにキャッシュから見つけることができます。

別の例として、コードが命令キャッシュに配置されており、特定のコードを複数回読み取る必要がある場合は、命令キャッシュが一次キャッシュであり、サイズが数キロバイトであるため、ループ本文のコードはできるだけ単純にする必要があります。この段落コードは別のL1キャッシュサイズに及び、キャッシュの利点は失われます。

CPUのパイプラインの同時実行性に関して、インテルPentiumプロセッサーには2つのパイプラインUおよびVがあります。各パイプラインは独立してキャッシュを読み書きできるため、1つのクロックサイクルで2つの命令を同時に実行できます。しかし、2つのパイプラインは等しくなく、Uパイプラインはすべての命令セットを処理でき、Vパイプラインは単純な命令しか処理できません。

CPU命令は通常4つのカテゴリに分けられます。最初のカテゴリは、mov、nop、push、pop、add、sub、またはxor、inc、dec、cmp、leaなどの一般的に使用される単純な命令であり、任意のパイプラインに含めることができます。実行は、相互に依存関係がない限り、命令は完全に同時に実行できます。

2番目のタイプの命令は、キャリーやシフト操作など、他のパイプラインと連携する必要があります。そのような命令がUパイプラインにある場合、他の命令はVパイプラインで同時に実行できます。Vパイプラインにある場合、Uパイプラインは中断されます。の。

3番目のタイプの命令は、cmp、call、条件付き分岐などのいくつかのジャンプ命令です。これらは、2番目のタイプの反対です。Vパイプラインで作業する場合、それらはUパイプラインとのみ連携できます。それ以外の場合、CPUを独占することしかできません。

4番目のタイプの命令は他の複雑な命令であり、CPUを独占することしかできないため、一般的には使用されません。

アセンブリレベルのプログラミングの場合、命令レベルの並行性を実現するには、命令間のマッチングに注意を払う必要があります。最初のタイプの命令を使用して、4番目のタイプを避け、コンテキスト依存関係を順番に減らしてください。

公開された434元の記事 ウォン称賛14 ビュー10万+

おすすめ

転載: blog.csdn.net/LU_ZHAO/article/details/105520354