CPU缓存关键步幅测试根据访问类型给出意外结果

And*_*owl 13 c++ performance cpu-cache c++11

受到最近关于SO的问题和给出的答案的启发,这让我感到非常无知,我决定花一些时间来学习更多有关CPU缓存的知识,并编写了一个小程序来验证我是否正确地完成了这一切(大多数情况下)可能不是,我害怕).我将首先写下构成我期望的假设,所以如果错误的话,你可能会阻止我.基于我所读到的,一般来说:

  1. 一个n三通关联高速缓存被分成s组,每组包含n行,每行具有固定大小L;
  2. 每个主存储器地址A可以被映射到任何所述的n的高速缓存行一个集;
  3. A映射地址的集合可以通过将地址空间拆分为每个大小为一个高速缓存行A的插槽,然后计算插槽(I = A / L)的索引,最后执行模运算以将索引映射到目标中来找到. set T(T = 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!
}
Run Code Online (Sandbox Code Playgroud)

由于上述假设,我的期望是:

  1. 当设置STEP等于临界步幅(即缓存行的大小乘以缓存中的集合数量或者L * s)时,性能应该比设置为时更差STEP(L * 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);
        }
    }
Run Code Online (Sandbox Code Playgroud)

结果在短:

  • 期望1)和2)得到确认;
  • 期望3)得到确认.

这个事实让我感到震惊,让我觉得有些事情我没有做对.当B256 MB并且STEP等于临界步幅时,测试(在GCC 4.7.1上使用-O3编译)显示:

  • 该周期的只写版本平均损失约6倍性能损失(6.234秒对1.078秒);
  • 该周期的读写版本平均损失约1.3倍(6.671秒对5.25秒).

所以我的第二个问题是:为什么这个差异?我认为阅读和写作时的性能损失要高于仅写入时的性能损失.


为了完整起见,下面是我为执行测试而编写的程序,其中常量反映了我的机器的硬件参数:L1 8路关联数据高速缓存的大小为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;
}
Run Code Online (Sandbox Code Playgroud)

如果你设法阅读这个长期的问题,请提前感谢你.

gru*_*zip 2

关于您的期望 3,您是对的。正如您所期望的那样。请查看《每个程序员都应该了解的关于内存的知识》了解更多详细信息。这是解释内存层次结构的一系列优秀文章。

那么为什么第三点很难确认:主要有两个原因。一是内存分配,二是虚拟物理地址转换。

内存分配

没有严格保证分配的内存区域的实际物理地址是什么。当您想要测试 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();

}
Run Code Online (Sandbox Code Playgroud)

那么会发生什么。所有大内存区域都与 4k 对齐,并且基于之前的假设,同一行的所有元素都将映射到同一缓存集。当循环中投影内存区域的数量大于缓存的关联性时,所有访问都将导致缓存未命中,并且每个元素的平均处理时间将增加。

更新

如何处理写入取决于缓存行的使用方式和 CPU。现代 CPU 应用MESI协议来处理对缓存行的写入,以确保各方对内存具有相同的视图(缓存一致性)。通常,在写入高速缓存行之前,必须先读取高速缓存行,然后再写回。是否识别回写取决于您访问数据的方式。如果您再次重新读取缓存行,您可能不会注意到差异。

然而,虽然程序员通常对数据在 CPU 缓存中的存储方式没有影响,但写入时却存在细微差别。可以执行所谓的流写入,这种写入不会污染缓存,而是直接写入内存。这些写入也称为非临时写入。