CPU缓存临界步长测试根据访问类型给出意外结果。

17
最近在SO上提出的问题和给出的答案的启发,这让我感到非常无知,我决定花些时间学习更多关于CPU缓存的知识,并编写了一个小程序来验证我是否全都理解正确(很可能不是,我很担心)。首先,我会记录下我期望的假设,如果这些假设是错误的,您可以在此处阻止我。根据我所读的,一般而言
An n-way associative cache is divided into s sets, each containing n lines, each line having a fixed size L;
每个n路组相联缓存被划分为s个集合,每个集合包含n条线路,每条线路具有固定的大小L;
Each main memory address A can be mapped into any of the n cache lines of one set;
每个主内存地址A可以映射到一个集合中任意n条缓存线路之一;
The set into which address A is mapped can be found by splitting the address space into slots each of the size of one cache line, then computing the index of A's slot (I = A / L), and finally performing a modulo operation to map the index into the target set T (T = I % s);
可以通过将地址空间分割成每个缓存线路大小的槽,然后计算A所在槽的索引(I=A/L),最后执行模运算将索引映射到目标集合T(T=I%s)来找到A所映射的集合;
A cache read miss causes a higher delay than a cache write miss, because the CPU is less likely to stall and stay idle while waiting for the main memory line to be fetched.
与缓存写失效相比,缓存读失效会导致更高的延迟,因为CPU在等待主内存线路被获取时不太可能停顿和空闲。
我的第一个问题是:这些假设正确吗?
假设它们是,我尝试对这些概念进行一些实际操作,以便我能够看到它们对程序产生具体影响。我编写了一个简单的测试,分配了一个大小为B字节的内存缓冲区,并重复访问该缓冲区的位置,步长为给定的step,从缓冲区的开头开始(这意味着如果B为14且步长为3,则我只会重复访问0、3、6、9和12位置——如果B为13、14或15,则情况相同)。
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. 读取和写入每个缓冲区位置时的性能损失应该比仅写入这些位置时更严重:写入内存位置不应需要等待相应的行被获取,因此访问被映射到同一集合的内存位置(再次使用关键步长作为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. 确认了期望1和2; 2. 未能确认期望3; 3. 在测试中发现读写版本的性能损失比只写版本低。
对于第三点,测试结果表明,当B为256MB且STEP等于关键步长时,只写版本的循环平均性能损失约为6倍(6.234秒 vs 1.078秒),而读写版本的循环平均性能损失约为1.3倍(6.671秒 vs 5.25秒)。因此,作者想知道为什么读写版本的性能损失比只写版本低。
为了完整起见,以下是我编写用于进行测试的程序,其中常量反映了我的机器的硬件参数:L1 8路关联数据缓存的大小为32 KB,每个缓存行的大小为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;
}

如果您能够阅读完这个长问题,我提前感谢您。


有两个L1缓存,一个用于代码,另一个用于数据。因此,您的数据缓存可能为16KB。您的处理器可能具有多个读端口和一个写端口。请说明您拥有什么。 - Hans Passant
@HansPassant:我提供的数据是针对L1数据缓存的。同样大小(和行大小)的独立的L1 8路指令缓存也存在。我该如何检查我的处理器有多少读端口和写端口?我知道这个问题可能听起来很愚蠢,但这些东西对我来说是新的,所以请原谅我的无知。 - Andy Prowl
3个回答

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();

}

那么会发生什么呢?所有大的内存区域都按4k对齐,并且基于先前的假设,同一行的所有元素将映射到同一个缓存集中。当循环中预计的内存区域数量大于缓存的关联性时,所有访问都会导致缓存未命中,每个元素的平均处理时间将增加。
更新:如何处理写操作取决于缓存行的使用方式和CPU。现代CPU应用MESI协议来处理对缓存行的写操作,以确保所有参与方对内存具有相同的视图(缓存一致性)。通常,在写入缓存行之前,必须先读取缓存行,然后再写回。是否意识到写回取决于您如何访问数据。如果您再次读取缓存行,则可能不会注意到任何差异。
然而,虽然程序员通常无法影响数据在CPU缓存中的存储方式,但在写入时存在轻微差异。可以进行所谓的流式写入,这些写入不会污染缓存,而是直接写入内存。这些写操作也称为非临时写入。

谢谢您的回答。没有冒犯,我相信它,但它增加了我的疑问而不是减少它们。首先,我正在Windows上开发:posix_memalign()是否可移植且属于C++标准库?其次,如果我的测试由于内存分配不可靠,为什么只写部分确认了预期的行为,而读写部分却没有? - Andy Prowl
哦,谢谢你提供链接,我一定会阅读那个系列的。 - Andy Prowl
在C++中,过度对齐分配并没有标准的方法(他们正在为C++1y制定标准)。在Windows上,您可以使用"_aligned_malloc"。 - Marc Glisse
请问您能告诉我什么是“critical stride”吗?我在网上找不到太多关于它的解释。 - Tarun

1
首先需要澄清一点 - 在大多数情况下,写操作仍然需要将行数据提取到本地缓存中,因为行通常是64字节,而您的写操作可能只修改了其中部分块 - 合并将在缓存中完成。 即使您要一次性写入整个行(在某些情况下理论上可能会发生),您仍然需要等待访问以接收该行的所有权后才能进行写入 - 这个协议称为RFO(读取所有权),它可能会相当长,特别是如果您有一个多套接字系统或任何具有复杂内存层次结构的系统。
说了这么多,你的第四个假设在某些情况下仍然可能是正确的,因为加载操作确实需要在程序继续之前获取数据,而存储可以缓冲以便稍后写入。但是,如果加载操作在某些关键路径上(也就是其他操作等待其结果),它将只会阻塞程序,这是你的测试程序没有体现出来的行为。由于大多数现代CPU都提供乱序执行,以下独立指令可以自由地执行而不必等待加载完成。 在你的程序中,除了简单的索引增量(可以轻松地运行)之外,没有循环间依赖性,所以你基本上不是受到内存延迟的瓶颈,而是受到内存吞吐量的限制,这是完全不同的事情。 顺便说一句,要添加这样的依赖关系,你可以模拟链表遍历,甚至更简单-确保数组初始化为零(并将写入改为仅写入零),并在每次迭代时将每个读取值的内容添加到索引中(除了增量之外)-这将创建一个依赖关系,而不更改地址本身。或者像这样做一些讨厌的事情(假设编译器不够聪明,无法删除这些...):
    if (onlyWriteToCache)
    {
        buffer[index] = (char)(index % 255);
    }
    else
    {
        buffer[index] = (char)(buffer[index] % 255);
        index += buffer[index];
        index -= buffer[index];
    }

关于结果,当您通过关键步骤跳跃时,似乎写入与读取+写入的行为相同,这是预期的(因为读取与写入发出的RFO没有太大区别)。然而,在非关键步骤中,读取+写入操作要慢得多。现在很难确定具体系统,但这可能是由于在指令生命周期的不同阶段执行了加载(读取)和存储(写入)-这意味着在加载和随后的存储之间,您可能已经清除了该行并需要再次获取它。我对此不太确定,但如果您想检查,也许可以在迭代之间添加一个sfence汇编指令(尽管这会显著减慢速度)。
最后一点注意事项-当您受到带宽限制时,写入可能会使您的速度变慢,因为还有另一个要求-当您写入内存时,会将一行数据获取到缓存并进行修改。修改的行需要写回到内存(尽管实际上在此过程中还有一整套低级缓存),这需要资源并可能使您的计算机变慢。尝试只读循环,看看效果如何。

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

$ 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

使用上述命令进行编译并加载内核模块,等待1秒后查看系统日志的最后100行。

#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");
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接