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

时间:2013-01-27 02:52:46

标签: c++ performance c++11 cpu-cache

受到this recent question on SO and the answers given的启发,这让我感到非常无知,我决定花一些时间来了解更多有关 CPU缓存的信息,并写了一个小程序来验证我是否得到了这一切都是正确的(很可能不是,我害怕)。我会先写下构成我期望的假设,所以如果错误的话,你可能会阻止我。根据我所读到的内容,一般

  1. n - 方式关联缓存分为s个集合,每个集合包含n行,每行包含固定大小L;
  2. 每个主内存地址A都可以映射到一个集的n个缓存行的任意;
  3. 映射地址A的集合可以通过将地址空间拆分为每个大小为一个缓存行的插槽,然后计算A的插槽索引({{ 1}}),最后执行模运算将索引映射到目标集I = A / LT);
  4. 高速缓存读取未命中导致比高速缓存写入未命中更高的延迟,因为CPU在等待提取主存储器行时不太可能停止并保持空闲。
  5. 我的第一个问题是:这些假设是否正确?


    假设它们是,我尝试使用这些概念,所以我实际上看到它们对程序产生了具体影响。我写了一个简单的测试,它分配一个T = I % s字节的内存缓冲区,并重复访问该缓冲区的位置,其中固定增量来自于给定的步骤 缓冲区的开头(意味着如果B为14且步长为3,我只重复访问位置0,3,6,9和12 - 如果{{1是13,14或15):

    B

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

    1. B设置为等于关键步幅时(即缓存行的大小乘以缓存中的集合数量,或int index = 0; for (int i = 0; i < REPS; i++) { index += STEP; if (index >= B) { index = 0; } buffer[index] = ...; // Do something here! } ),性能应该是明显更差STEP设置为<{1}},例如,(L * s,因为我们只会访问映射到相同 set,强制从该集合中更频繁地逐出缓存行,从而导致更高的缓存未命中率;
    2. STEP等于关键步幅时,性能不应受缓冲区大小L * s) + 1的影响,只要这不是太小(否则访问的位置太少,缓存未命中的次数也会减少);否则,性能应该受到STEP的影响,因为有了更大的缓冲区,我们更有可能访问映射到不同集合的位置(特别是如果B不是多个2);
    3. 写入每个缓冲区位置时,性能损失应该会比仅写入时更差到那些位置:写入内存位置不应该要求等待相应的行被提取,因此访问映射到同一组的内存位置(再次,通过使用关键步幅为B)的事实应该影响很小。
    4. 所以我用RightMark Memory Analyzer找出了我的L1 CPU数据缓存的参数,调整了我程序中的大小,然后试了一下。这就是我编写主循环的方式(STEP是一个可以从命令行设置的标志):

      STEP

      结果简而言之:

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

      这个事实让我感到震惊,让我觉得有些事情我没有做得很对。当onlyWriteToCache为256 MB且 ... for (int i = 0; i < REPS; i++) { ... if (onlyWriteToCache) { buffer[index] = (char)(index % 255); } else { buffer[index] = (char)(buffer[index] % 255); } } 等于临界步幅时,测试(在GCC 4.7.1上使用-O3编译)显示:

      • 该周期的只写版本遭受平均 ~6x 性能损失(6.234s vs 1.078s);
      • 该周期的读写版本遭受平均 ~1.3x 性能损失(6.671s vs 5.25s)。

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


      为了完整起见,下面是我为测试编写的程序,其中常量反映了我的机器的硬件参数:L1 8路关联数据缓存的大小是每个高速缓存行的32 KB和大小B是64字节,总共有64个集合(CPU具有相同大小和相同行大小的单独的L1 8路指令高速缓存)。

      STEP

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

3 个答案:

答案 0 :(得分:2)

关于你的期望数3,你是对的。这是你所期望的。有关详细信息,请查看"What every Programmer should know about memory"。这是一系列解释内存层次结构的优秀文章。

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

内存分配

没有严格保证分配的内存区域的实际物理地址是什么。当您想测试CPU缓存时,我总是建议使用posix_memalign强制分配到特定边界。否则你可能会看到一些奇怪的行为。

地址翻译

我提到的文章很好地解释了地址转换的工作方式。要验证您的假设,您必须尝试查明预期的行为。最简单的方法如下:

<强>实验

k数组的形式分配一组int个大内存区域(类似于512MB)并将它们全部对齐到页面边界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缓存中的存储方式,但写入时会略有不同。可以执行所谓的流式写入,其不会污染高速缓存,而是直接写入存储器。这些写操作也称为non-temporal次写入。

答案 1 :(得分: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汇编指令(虽然这会显着减慢你的速度)。

最后一点 - 当你的带宽有限时,由于另一个要求,写入可能会使你的速度降低很多 - 当你写入内存时,你会获取一行到缓存并修改它。修改后的行需要写回内存(虽然实际上路上有一整套较低级别的缓存),这需要资源并且可能会阻塞你的机器。尝试一个只读循环,看看它是怎么回事。

答案 2 :(得分: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&amp;&amp; sudo insmod hello.ko&amp;&amp;睡1&amp;&amp; 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");
}