15

最近关于 SO 的问题和给出的答案的启发,这让我感到非常无知,我决定花一些时间来了解更多关于CPU 缓存的知识,并编写了一个小程序来验证我是否正确地完成了这件事(大多数恐怕不会)。我将首先写下构成我期望的假设,因此如果这些假设是错误的,您可能会在这里阻止我。根据我所读到的,一般来说

  1. 一个n-way关联缓存被分成s集合,每一个包含n行,每行有一个固定的大小L
  2. 每个主存地址A都可以映射到组缓存线中的任何一个n
  3. 地址映射到的集合A可以通过将地址空间划分为每个缓存行大小的槽,然后计算A's slot ( I = A / L) 的索引,最后执行模运算将索引映射到目标设置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!
}

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

  1. 当设置STEP等于临界步长(即高速缓存行的大小乘以高速缓存中的集合数,或)时L * s,性能应该STEP设置为(例如L * s) + 1,因为我们将只访问内存映射到同一集合中的位置,迫使缓存行更频繁地从该集合中逐出并导致更高的缓存未命中率;
  2. STEP等于临界步长时,性能不应该受缓冲区大小B的影响,只要它不是太小(否则会访问的位置太少,缓存未命中率会更少);否则,性能应该受 影响B因为使用更大的缓冲区,我们更有可能访问映射到不同集合的位置(尤其是如果STEP不是 2 的倍数);
  3. 读取和写入每个缓冲区位置时的性能损失应该比只写入这些位置时更糟:写入内存位置不应该需要等待相应的行被获取,因此访问映射到的内存位置的事实相同的集合(同样,通过使用临界步幅 as 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是 256 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;
}

如果您设法阅读了这个长长的问题,请提前感谢您。

4

3 回答 3

2

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

那么为什么很难确认第 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();

}

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

更新

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

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

于 2013-01-27T15:44:35.167 回答
1

首先,需要澄清一点——在大多数情况下,写入仍然需要您将行提取到本地缓存中,因为这些行通常是 64Byte,而您的写入可能只修改其中的一部分- 合并将在缓存中进行。即使您要一口气写完整行(理论上在某些情况下是可能的),您仍然需要等待访问才能在写入之前获得该行的所有权 - 此协议称为RFO(读取所有权),它可能会很长,特别是如果您有一个多插槽系统或任何具有复杂内存层次结构的东西。

话虽如此,在某些情况下,您的第四个假设可能仍然是正确的,因为加载操作确实需要在程序前进之前获取数据,而可以缓冲存储以便稍后在可能的情况下写入。但是,负载只会在程序处于某个关键路径(意味着其他一些操作等待其结果)时才会停止程序,这是您的测试程序不会执行的行为。由于大多数现代 CPU 都提供乱序执行,因此可以自由执行以下独立指令,而无需等待加载完成。在您的程序中,除了简单的索引提前(可以轻松运行)之外,没有循环间依赖性,因此您基本上不会受到内存延迟的瓶颈,而是内存吞吐量的瓶颈,这是完全不同的事情。顺便说一句,要添加这样的依赖,您可以模拟链表遍历,甚至更简单 - 确保数组初始化为零(并将写入仅切换为零),并在每次迭代时将每个读取值的内容添加到索引中(除了增量) - 这将创建一个依赖关系而不改变地址本身。或者,做一些像这样讨厌的事情(假设编译器不够聪明,不能放弃这个......):

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

现在,关于结果,当您跳过关键步骤时,写入与读取+写入的行为似乎相同,正如预期的那样(因为读取与写入发出的 RFO 并没有太大区别)。但是,对于非关键步骤,读+写操作要慢得多。现在很难在不知道确切系统的情况下判断,但这可能是因为加载(读取)和存储(写入)不在指令生命周期的同一阶段执行 - 这意味着在加载和存储之间接下来的商店,您可能已经驱逐了这条线,需要再次取回它。我不太确定,但如果你想检查一下,也许你可以在迭代之间添加一个 sfence 汇编指令(尽管这会显着减慢你的速度)。

最后一点-当您的带宽有限时,由于另一个要求,写入可能会减慢您的速度-当您写入内存时,您会从缓存中获取一行并对其进行修改。修改后的行需要写回内存(尽管实际上有一整套较低级别的缓存在路上),这需要资源并且可能会阻塞您的机器。尝试一个只读循环,看看它是怎么回事。

于 2013-01-27T22:43:48.437 回答
0

当我读到 Agner Frog 在优化 C++ 中的缓存机制时,我也尝试过大步走。

根据这本书,您的第二个假设是错误的,因为内存地址始终属于一组中的特定缓存行。因此,每个字节都可以由相同的缓存行以不同的“方式”进行缓存。

我在用户空间中第一次尝试这样做失败了。(我有 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 hit-stride.cpp -o hit-stride

#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 回答