15

SOに関するこの最近の質問と与えられた回答に触発されて、私は非常に無知であると感じました。私はCPUキャッシングについてもっと学ぶために時間を費やすことに決め、このすべてが正しく行われているかどうかを確認するための小さなプログラムを作成しました(ほとんどおそらくそうではないでしょう、私は恐れています)。私は最初に私の期待の根底にある仮定を書き留めます、それでそれらが間違っているならあなたはおそらくここで私を止めることができます。私が読んだものに基づいて、一般的に

  1. -waynアソシアティブキャッシュはセットに分割されs、各セットにはn行が含まれ、各行のサイズは固定されていLます。
  2. 各メインメモリアドレスは、 1セットの任意のキャッシュラインにAマップできます。n
  3. アドレスがマップされるセットAは、アドレス空間を1つのキャッシュラインのサイズごとにスロットに分割し、Aのスロット(I = A / L)のインデックスを計算し、最後にモジュロ演算を実行してインデックスをターゲットにマップすることで見つけることができます。セットTT = I % s);
  4. メインメモリラインがフェッチされるのを待っている間、CPUがストールしてアイドル状態を維持する可能性が低いため、キャッシュ読み取りミスはキャッシュ書き込みミスよりも高い遅延を引き起こします。

私の最初の質問は:これらの仮定は正しいですか?


それらがそうであると仮定して、私はこれらの概念を少し試してみたので、実際にそれらがプログラムに具体的な影響を与えるのを見ることができました。バイトのメモリバッファを割り当て、バッファの先頭から特定のステップの固定増分Bでそのバッファの場所に繰り返しアクセスする簡単なテストを作成しました(つまり、が14で、ステップが3の場合、場所0のみに繰り返しアクセスします。 、3、6、9、および12-が13、14、または15の場合も同様です): BB

int index = 0;
for (int i = 0; i < REPS; i++)
{
    index += STEP;
    if (index >= B) { index = 0; }
    buffer[index] = ...; // Do something here!
}

上記の仮定により、私の期待は次のとおりでした。

  1. クリティカルストライドSTEPに等しく設定する場合(つまり、キャッシュラインのサイズにキャッシュ内のセット数を掛けたもの、または)、たとえば(、メモリのみにアクセスするため)に設定した場合よりもパフォーマンスが大幅に低下するはずです。同じセットにマップされる場所。キャッシュラインがそのセットからより頻繁に削除され、キャッシュミスの発生率が高くなります。L * sSTEPL * s) + 1
  2. STEPがクリティカルストライドに等しい場合、これが小さすぎない限り、パフォーマンスはバッファのサイズに影響されないはずです(そうでない場合、アクセスされる場所が少なすぎて、キャッシュミスが少なくなります)。Bそうしないと、パフォーマンスが影響を受けるはずです。Bバッファが大きいほど、異なるセットにマップされる場所にアクセスする可能性が高くなります(特にSTEP、2の倍数でない場合)。
  3. 各バッファロケーションからの読み取りと各バッファロケーションへの書き込みの場合、それらのロケーションへの書き込みのみの場合よりもパフォーマンスの低下が悪化するはずです。メモリロケーションへの書き込みでは、対応する行がフェッチされるのを待つ必要がないため、同じセット(ここでも、クリティカルストライドを使用することによるSTEP)は、小さな影響を与えるはずです。

そこで、RightMark Memory Analyzerを使用して、L1 CPUデータキャッシュのパラメーターを見つけ、プログラムのサイズを調整して、試してみました。これが私がメインサイクルを書いた方法です(onlyWriteToCacheコマンドラインから設定できるフラグです):

    ...
    for (int i = 0; i < REPS; i++)
    {
        ...
        if (onlyWriteToCache)
        {
            buffer[index] = (char)(index % 255);
        }
        else
        {
            buffer[index] = (char)(buffer[index] % 255);
        }
    }

要するに結果

  • 期待1)と2)が確認されました。
  • 期待3)は確認されなかった。

この事実は私を驚かせ、私が完全に正しくなかった何かがあると私に思わせます。Bが256MBでSTEP、クリティカルストライドに等しい場合、テスト(GCC 4.7.1で-O3を使用してコンパイル)は次のことを示します。

  • 書き込み専用バージョンのサイクルでは、平均で最大6倍のパフォーマンス低下が発生します(6.234秒対1.078秒)。
  • サイクルの読み取り/書き込みバージョンでは、平均で約1.3倍のパフォーマンス低下が発生します(6.671秒対5.25秒)。

だから私の2番目の質問は:なぜこの違いがあるのですか?書き込みのみの場合よりも読み取りと書き込みの場合の方がパフォーマンスの低下が大きいと思います。


完全を期すために、テストを実行するために作成したプログラムを以下に示します。定数は、マシンのハードウェアパラメーターを反映しています。L18ウェイアソシアティブデータキャッシュのサイズは32 KBで、L各キャッシュラインのサイズは次のとおりです。 64バイト。合計64セットになります(CPUには、同じサイズで同じラインサイズの個別のL1 8ウェイ命令キャッシュがあります)。

#include <iostream>
#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>

using namespace std;

// Auxiliary functions

constexpr int pow(int base, int exp)
{
    return ((exp == 0) ? 1 : base * pow(base, exp - 1));
}

int main(int argc, char* argv[])
{
    //======================================================================
    // Define behavior from command-line arguments
    //======================================================================

    bool useCriticalStep = false;
    bool onlyWriteToCache = true;
    size_t BUFFER_SIZE = pow(2, 28);
    size_t REPS = pow(2, 27);

    if (argc > 0)
    {
        for (int i = 1; i < argc; i++)
        {
            string option = argv[i];
            if (option == "-c")
            {
                useCriticalStep = true;
            }
            else if (option == "-r")
            {
                onlyWriteToCache = false;
            }
            else if (option[1] == 's')
            {
                string encodedSizeInMB = option.substr(2);
                size_t sizeInMB = atoi(encodedSizeInMB.c_str());
                BUFFER_SIZE = sizeInMB * pow(2, 20);
            }
            else if (option[1] == 'f')
            {
                string encodedNumOfReps = option.substr(2);
                size_t millionsOfReps = atoi(encodedNumOfReps.c_str());
                REPS = millionsOfReps * pow(10, 6);
            }
        }
    }

    //======================================================================
    // Machine parameters
    //======================================================================

    constexpr int CACHE_SIZE = pow(2, 15);
    constexpr int CACHE_LINE_SIZE = 64;
    constexpr int CACHE_LINES_PER_SET = 8;
    constexpr int SET_SIZE = CACHE_LINE_SIZE * CACHE_LINES_PER_SET;
    constexpr int NUM_OF_SETS = CACHE_SIZE / SET_SIZE;

    //======================================================================
    // Print out the machine parameters
    //======================================================================

    cout << "CACHE SIZE: " << CACHE_SIZE / 1024 << " KB" << endl;
    cout << "CACHE LINE SIZE: " << CACHE_LINE_SIZE << " bytes" << endl;
    cout << "CACHE LINES PER SET: " << CACHE_LINES_PER_SET << endl;
    cout << "SET SIZE: " << SET_SIZE << " bytes" << endl;
    cout << "NUMBER OF SETS: " << NUM_OF_SETS << endl;

    fill_n(ostream_iterator<char>(cout), 30, '='); cout << endl;

    //======================================================================
    // Test parameters
    //======================================================================

    const int STEP = NUM_OF_SETS * CACHE_LINE_SIZE + (useCriticalStep ? 0 : 1);

    //======================================================================
    // Print out the machine parameters
    //======================================================================

    cout << "BUFFER SIZE: " << BUFFER_SIZE / pow(2, 20) << " MB" << endl;
    cout << "STEP SIZE: " << STEP << " bytes" << endl;
    cout << "NUMBER OF REPS: " << REPS << endl;

    fill_n(ostream_iterator<char>(cout), 30, '='); cout << endl;

    //======================================================================
    // Start the test
    //======================================================================

    char* buffer = new char[BUFFER_SIZE];

    clock_t t1 = clock();

    int index = 0;
    for (size_t i = 0; i < REPS; i++)
    {
        index += STEP;
        if (index >= BUFFER_SIZE)
        {
            index = 0;
        }

        if (onlyWriteToCache)
        {
            buffer[index] = (char)(index % 255);
        }
        else
        {
            buffer[index] = (char)(buffer[index] % 255);
        }
    }

    clock_t t2 = clock();

    //======================================================================
    // Print the execution time (in clock ticks) and cleanup resources
    //======================================================================

    float executionTime = (float)(t2 - t1) / CLOCKS_PER_SEC;
    cout << "EXECUTION TIME: " << executionTime << "s" << endl;

    delete[] buffer;
}

この長い質問をなんとか読んでくださった方、よろしくお願いします。

4

3 に答える 3

2

あなたの期待番号3に関しては、あなたは正しいです。ご想像のとおりです。詳細については、「すべてのプログラマがメモリについて知っておくべきこと」を参照してください。これは、メモリ階層を説明する優れた一連の記事です。

では、3 番目の確認が難しい理由: 主な理由は 2 つあります。1 つはメモリ割り当てで、もう 1 つは仮想物理アドレス変換です。

メモリ割り当て

割り当てられたメモリ領域の実際の物理アドレスが何であるかという厳密な保証はありません。CPU キャッシュをテストする場合はposix_memalign、特定の境界への割り当てを強制するために を使用することを常にお勧めします。そうしないと、奇妙な動作が見られる可能性があります。

アドレス変換

アドレス変換がどのように機能するかについては、私が言及した記事でうまく説明されています。仮定を検証するには、予想される動作を特定する必要があります。これを行う最も簡単な方法は次のとおりです。

実験

一連のk大きなメモリ領域 (512MB など) を配列の形で割り当て、intそれらすべてを 4096b のページ境界に合わせます。k次に、メモリ領域内のすべての要素を反復処理し、実験に領域を段階的に追加します。時間を測定し、読み取った要素の数で正規化します。

コードは次のようになります。

#define N 10000000
for(size_t i=0; i < k; ++i) {

   size_t sum=0;
   clock_t t1= clock();
   for(size_t j=0; j < N; ++j) {
       for(size_t u=0; u<i; ++u) {
           sum += data[u][j];
       }
   }

   clock_t t2= clock();

}

では、どうなるでしょう。すべての大きなメモリ領域は 4k に配置され、前の仮定に基づいて、同じ行のすべての要素が同じキャッシュ セットにマップされます。ループ内の投影されたメモリ領域の数がキャッシュの結合性よりも大きい場合、すべてのアクセスでキャッシュ ミスが発生し、要素あたりの平均処理時間が増加します。

アップデート

書き込みの処理方法は、キャッシュ ラインの使用方法と CPU によって異なります。最新の CPUは、キャッシュ ラインへの書き込みを処理するためにMESIプロトコルを適用し、すべての関係者がメモリに対して同じビューを持つようにします (キャッシュ コヒーレンシ)。通常、キャッシュ ラインに書き込む前に、キャッシュ ラインを読み取ってから書き戻す必要があります。ライトバックを認識できるかどうかは、データへのアクセス方法によって異なります。キャッシュ ラインをもう一度読み直しても、おそらく違いに気付かないでしょう。

ただし、プログラマは通常、データが CPU キャッシュに格納される方法に影響を与えることはありませんが、書き込みの場合はわずかな違いがあります。キャッシュを汚染せずにメモリに直接書き込む、いわゆるストリーミング書き込みを実行することができます。これらの書き込みは、非テンポラル書き込みとも呼ばれます。

于 2013-01-27T15:44:35.167 に答える
1

まず最初に、少し説明する必要があります。ほとんどの場合、書き込みではローカル キャッシュに行をフェッチする必要があります。行は通常 64 バイトであり、書き込みはその部分的なチャンクのみを変更する可能性があるためです。 - マージはキャッシュで行われます。行全体を一度に書き込む場合でも (理論的には可能な場合もあります)、行に書き込む前に行の所有権を取得するためにアクセスを待つ必要があります。このプロトコルが呼び出されます。 RFO (所有権を読み取る) であり、特にマルチソケット システムまたは複雑なメモリ階層を持つものを使用している場合は、かなり長くなる可能性があります。

そうは言っても、ロード操作では実際にプログラムが進む前にデータをフェッチする必要があるため、4番目の仮定は正しい場合もありますが、可能な場合はストアをバッファリングして後で書き込むことができます。ただし、負荷によってプログラムが停止するのは、プログラムがクリティカル パス (他の操作がその結果を待機することを意味する) にある場合のみです。これは、テスト プログラムでは実行されない動作です。最近のほとんどの CPU は順不同で実行するため、次の独立した命令は、ロードの完了を待たずに自由に実行できます。あなたのプログラムには、単純なインデックス アドバンス (簡単に先に実行できる) を除いてループ間の依存関係がないため、基本的にメモリ レイテンシではなくメモリ スループットでボトルネックが発生しますが、これはまったく別のことです。ちなみに、そのような依存関係を追加するには、リンクされたリストのトラバーサルをエミュレートするか、さらに簡単にすることができます-配列がゼロに初期化されていることを確認し(そして書き込みをゼロのみに切り替えます)、各反復で各読み取り値の内容をインデックスに追加します(に加えて増分) - これにより、アドレス自体を変更せずに依存関係が作成されます。または、次のような厄介なことを行います(コンパイラがこれを削除するほど賢くないと仮定して...):

    if (onlyWriteToCache)
    {
        buffer[index] = (char)(index % 255);
    }
    else
    {
        buffer[index] = (char)(buffer[index] % 255);
        index += buffer[index];
        index -= buffer[index];
    }

さて、結果については、予想どおり、重要なステップでジャンプしているとき、書き込みと読み取り+書き込みは同じように動作するようです (読み取りは、書き込みによって発行されるRFOとあまり変わらないため) )。ただし、重要でないステップの場合、読み取りと書き込みの操作ははるかに遅くなります。正確なシステムを知らずに判断するのは困難ですが、これは、ロード (読み取り) とストア (書き込み) が命令の有効期間内の同じ段階で実行されないという事実が原因で発生する可能性があります。次のストアでは、すでに行を削除しており、もう一度フェッチする必要がある場合があります。それについてはよくわかりませんが、確認したい場合は、反復の間に sfence アセンブリ命令を追加できます (ただし、大幅に遅くなります)。

最後に 1 つ注意してください - 帯域幅が制限されている場合、別の要件のために書き込みがかなり遅くなる可能性があります。メモリに書き込むときは、キャッシュに行をフェッチして変更します。変更された行はメモリに書き戻す必要があります (ただし、実際には途中で下位レベルのキャッシュのセット全体が存在します)。これにはリソースが必要であり、マシンが詰まる可能性があります。読み取り専用ループを試して、どうなるか見てみましょう。

于 2013-01-27T22:43:48.437 に答える
0

また、Agner Frog による Optimization C++ のキャッシュ メカニズムについて読んだ後、ストライド レーキを踏もうとしました。

この本によると、メモリアドレスは常にセット内の特定のキャッシュラインに属しているため、2番目の仮定は間違っています。したがって、すべてのバイトは、異なる「方法」で同じキャッシュラインによってキャッシュされる可能性があります。

ユーザー空間でこれを行う最初の試みは失敗しました。(私はCPU i5-4200を持っています)。

Total size 128kb cache set size 8kb => time 18ms; 568000000
Total size 256kb cache set size 16kb => time 13ms; 120000000
Total size 384kb cache set size 24kb => time 12ms; 688000000
Total size 512kb cache set size 32kb => time 14ms; 240000000

$ g++ -std=c++11 -march=native -O3 ヒットストライド.cpp -o ヒットストライド

#include<iostream>
#include<chrono>

using namespace std::chrono;
using namespace std;

int main(int argc, char** argv) {
  unsigned int cacheSetSizes[] = { 8, 16, 24, 32 };
  const int ways = 8;

  for (unsigned int i = 0; i < sizeof(cacheSetSizes) / sizeof(int); ++i) {
    const unsigned int setSize = cacheSetSizes[i] * 1024;
    const unsigned int size = setSize * ways * 2;
    char* buffer = new char[size];
    for (int k = 0; k < size; ++k) {
      buffer[k] = k % 127;
    }
    const auto started = steady_clock::now();
    int sum = 0;
    for (int j = 0; j < 1000000; ++j) {
      for (int k = 0; k < size; k += setSize) {
        sum += buffer[k];
      }
    }
    const auto ended = steady_clock::now();
    cout << "Total size " << (size >> 10) << "kb cache set size " << cacheSetSizes[i]
         << "kb => time " << duration_cast<milliseconds>(ended - started).count()
         << "ms; " << sum << endl;
    delete buffer;
  }
  return 0;
}

カーネル モジュールにラップされた「同じ」コードは、L2 のヒットのように見えます。メモリを物理的に連続させる必要があることに気付きました。カーネルモードでのみ可能です。私のL1キャッシュサイズは32kbです。テストでは、ウェイの数 (8) よりも長いメモリ範囲を、キャッシュ サイズに等しいステップでウォークします。そのため、32kb (最後の行) で顕著な速度低下が見られます。

Apr 26 11:13:54 diehard kernel: [24992.943076] Memory 512 kb is allocated
Apr 26 11:13:54 diehard kernel: [24992.969814] Duration  23524369 ns for cache set size         8 kb; sum = 568000000
Apr 26 11:13:54 diehard kernel: [24992.990886] Duration  21076036 ns for cache set size        16 kb; sum = 120000000
Apr 26 11:13:54 diehard kernel: [24993.013832] Duration  22950526 ns for cache set size        24 kb; sum = 688000000
Apr 26 11:13:54 diehard kernel: [24993.045584] Duration  31760368 ns for cache set size        32 kb; sum = 240000000

$ make && sudo insmod hello.ko && sleep 1 && tail -n 100 /var/log/syslog

#include <linux/module.h>   /* Needed by all modules */
#include <linux/kernel.h>   /* Needed for KERN_INFO */
#include <linux/time.h>    

static unsigned long p = 0;
static struct timespec started, ended;
static unsigned int cacheSetSizes[] = { 8, 16, 24, 32 };
static const u32 ways = 8;
static const u32 m = 2;
static char* buffer;
static unsigned int setSize;
static unsigned int size;
static unsigned int i, j, k;
static int sum;

int init_module(void) {
  s64 st, en, duration;
  u32 max = 1*1024*1024;
  printk(KERN_INFO "Hello world 1.\n");
  p = __get_free_pages(GFP_DMA, get_order(max));
  printk(KERN_INFO "Memory %u kb is allocated\n", ways * m * 32);
  buffer = (char*) p;

  for (k = 0; k < max; ++k) {
    buffer[k] = k % 127;
  }

  for (i = 0; i < sizeof(cacheSetSizes) / sizeof(int); ++i) {
    setSize = cacheSetSizes[i] * 1024;
    size = setSize * ways * m;
    if (size > max) {
      printk(KERN_INFO "size %u is more that %u", size, max);
      return 0;
    }
    getnstimeofday(&started);
    st = timespec_to_ns(&started);

    sum = 0;
    for (j = 0; j < 1000000; ++j) {
      for (k = 0; k < size; k += setSize) {
        sum += buffer[k];
      }
    }

    getnstimeofday(&ended);
    en = timespec_to_ns(&ended);
    duration = en - st;
    printk(KERN_INFO "Duration %9lld ns for cache set size %9u kb; sum = %9d\n",
           duration, cacheSetSizes[i], sum);
  }
  return 0;
}

void cleanup_module(void) {
  printk(KERN_INFO "Goodbye world 1.\n");
  free_pages(p, get_order(1*1024*1024));
  printk(KERN_INFO "Memory is free\n");
}
于 2017-04-26T09:21:16.620 に答える